diff --git a/.github/workflows/lint-test-sdk.yml b/.github/workflows/lint-test-sdk.yml
index 62e5c80..2904fcd 100644
--- a/.github/workflows/lint-test-sdk.yml
+++ b/.github/workflows/lint-test-sdk.yml
@@ -13,15 +13,15 @@ jobs:
java-version: ['8', '11', '17'] # Define the Java versions to test against
steps:
- uses: actions/checkout@v3
-
+
- name: Set up JDK ${{ matrix.java-version }}
uses: actions/setup-java@v3
with:
java-version: ${{ matrix.java-version }}
distribution: 'adopt'
-
+
- name: 'Set up GCP SDK'
- uses: 'google-github-actions/setup-gcloud@v0'
-
+ uses: 'google-github-actions/setup-gcloud@v2'
+
- name: Run tests
run: make test
diff --git a/.github/workflows/publish-sdk.yml b/.github/workflows/publish-sdk.yml
index c177040..45396a5 100644
--- a/.github/workflows/publish-sdk.yml
+++ b/.github/workflows/publish-sdk.yml
@@ -20,7 +20,7 @@ jobs:
gpg-passphrase: GPG_PASSPHRASE
- name: 'Set up GCP SDK for downloading test data'
- uses: 'google-github-actions/setup-gcloud@v0'
+ uses: 'google-github-actions/setup-gcloud@v2'
- name: Download test data
run: make test-data
@@ -31,7 +31,7 @@ jobs:
MAVEN_USERNAME: ${{ secrets.OSSRH_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.OSSRH_PASSWORD }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
-
+
- name: Publish package
run: mvn nexus-staging:release
env:
diff --git a/.gitignore b/.gitignore
index 062bbaa..0d4e62e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,36 @@
+# Shared test files
+src/test/resources/assignment-v2
+src/test/resources/rac-experiments-v3.json
+
+### IntelliJ IDEA ###
+*.iml
.idea/
-target/
out/
-src/test/resources/assignment-v2
-src/test/resources/rac-experiments-v*.json
-.DS_Store
+target/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 0d5274e..1a067bf 100644
--- a/Makefile
+++ b/Makefile
@@ -29,18 +29,20 @@ build: test-data
mvn --batch-mode --update-snapshots package
## test-data
-testDataDir := src/test/resources/
-tempDir := ${testDataDir}temp/
-gitDataDir := ${tempDir}sdk-test-data/
+testDataDir := src/test/resources
+banditsDataDir := ${testDataDir}/bandits
+tempDir := ${testDataDir}/temp
+tempBanditsDir := ${tempDir}/bandits
+gitDataDir := ${tempDir}/sdk-test-data
branchName := main
githubRepoLink := https://github.com/Eppo-exp/sdk-test-data.git
.PHONY: test-data
-test-data:
- rm -rf $(testDataDir)
- mkdir -p $(tempDir)
+test-data:
+ find ${testDataDir} -mindepth 1 ! -regex '^${banditsDataDir}.*' -delete
+ mkdir -p ${tempDir}
git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir}
- cp ${gitDataDir}rac-experiments-v3.json ${testDataDir}
- cp -r ${gitDataDir}assignment-v2 ${testDataDir}
+ cp ${gitDataDir}/rac-experiments-v3.json ${testDataDir}
+ cp -r ${gitDataDir}/assignment-v2 ${testDataDir}
rm -rf ${tempDir}
.PHONY: test
diff --git a/pom.xml b/pom.xml
index 22fe7cb..bfa60e2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
cloud.eppo
eppo-server-sdk
- 2.3.0
+ 2.4.0
${project.groupId}:${project.artifactId}
Eppo Server-Side SDK for Java
@@ -107,6 +107,11 @@
httpclient
4.5.13
+
+ ch.qos.logback
+ logback-classic
+ 1.4.12
+
com.github.tomakehurst
wiremock-jre8
@@ -125,6 +130,18 @@
1.9.5
test
+
+ org.mockito
+ mockito-core
+ 5.10.0
+ test
+
+
+ org.skyscreamer
+ jsonassert
+ 1.5.1
+ test
+
@@ -206,4 +223,4 @@
-
\ No newline at end of file
+
diff --git a/src/main/java/com/eppo/sdk/EppoClient.java b/src/main/java/com/eppo/sdk/EppoClient.java
index 85e3300..f603f4f 100644
--- a/src/main/java/com/eppo/sdk/EppoClient.java
+++ b/src/main/java/com/eppo/sdk/EppoClient.java
@@ -1,36 +1,19 @@
package com.eppo.sdk;
import com.eppo.sdk.constants.Constants;
-import com.eppo.sdk.dto.Allocation;
-import com.eppo.sdk.dto.AssignmentLogData;
-import com.eppo.sdk.dto.EppoClientConfig;
-import com.eppo.sdk.dto.EppoValue;
-import com.eppo.sdk.dto.EppoValueType;
-import com.eppo.sdk.dto.ExperimentConfiguration;
-import com.eppo.sdk.dto.Rule;
-import com.eppo.sdk.dto.SubjectAttributes;
-import com.eppo.sdk.dto.Variation;
+import com.eppo.sdk.dto.*;
import com.eppo.sdk.exception.EppoClientIsNotInitializedException;
import com.eppo.sdk.exception.InvalidInputException;
-import com.eppo.sdk.helpers.AppDetails;
-import com.eppo.sdk.helpers.CacheHelper;
-import com.eppo.sdk.helpers.ConfigurationStore;
-import com.eppo.sdk.helpers.EppoHttpClient;
-import com.eppo.sdk.helpers.ExperimentConfigurationRequestor;
-import com.eppo.sdk.helpers.ExperimentHelper;
-import com.eppo.sdk.helpers.FetchConfigurationsTask;
-import com.eppo.sdk.helpers.InputValidator;
-import com.eppo.sdk.helpers.RuleValidator;
-import com.eppo.sdk.helpers.Shard;
+import com.eppo.sdk.helpers.*;
+import com.eppo.sdk.helpers.bandit.BanditEvaluator;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.ehcache.Cache;
-import java.util.List;
-import java.util.Optional;
-import java.util.Timer;
+import java.util.*;
+import java.util.stream.Collectors;
@Slf4j
public class EppoClient {
@@ -61,64 +44,170 @@ private EppoClient(ConfigurationStore configurationStore, Timer poller, EppoClie
protected Optional getAssignmentValue(
String subjectKey,
String flagKey,
- SubjectAttributes subjectAttributes) {
+ EppoAttributes subjectAttributes,
+ Map actionsWithAttributes
+ ) {
// Validate Input Values
InputValidator.validateNotBlank(subjectKey, "Invalid argument: subjectKey cannot be blank");
InputValidator.validateNotBlank(flagKey, "Invalid argument: flagKey cannot be blank");
+ VariationAssignmentResult assignmentResult = this.getAssignedVariation(flagKey, subjectKey, subjectAttributes);
+
+ if (assignmentResult == null) {
+ return Optional.empty();
+ }
+
+ Variation assignedVariation = assignmentResult.getVariation();
+ Optional assignmentValue = Optional.of(assignedVariation.getTypedValue());
+
+ // Below is used for logging
+ String experimentKey = assignmentResult.getExperimentKey();
+ String allocationKey = assignmentResult.getAllocationKey();
+ String assignedVariationString = assignedVariation.getTypedValue().stringValue();
+ AlgorithmType algorithmType = assignedVariation.getAlgorithmType();
+
+ if (algorithmType == AlgorithmType.OVERRIDE) {
+ // Assigned variation was from an override; return its value without logging
+ return assignmentValue;
+ } else if (algorithmType == AlgorithmType.CONTEXTUAL_BANDIT) {
+ // Assigned variation is a bandit; need to use the bandit to determine its value
+ assignmentValue = this.determineAndLogBanditAction(assignmentResult, actionsWithAttributes);
+ }
+
+ // Log the assignment
+ try {
+ this.eppoClientConfig.getAssignmentLogger()
+ .logAssignment(new AssignmentLogData(
+ experimentKey,
+ flagKey,
+ allocationKey,
+ assignedVariationString,
+ subjectKey,
+ subjectAttributes));
+ } catch (Exception e) {
+ log.warn("Error logging assignment", e);
+ }
+
+ return assignmentValue;
+ }
+
+ private VariationAssignmentResult getAssignedVariation(String flagKey, String subjectKey, EppoAttributes subjectAttributes) {
+
// Fetch Experiment Configuration
ExperimentConfiguration configuration = this.configurationStore.getExperimentConfiguration(flagKey);
if (configuration == null) {
log.warn("[Eppo SDK] No configuration found for key: " + flagKey);
- return Optional.empty();
+ return null;
}
// Check if subject has override variations
EppoValue subjectVariationOverride = this.getSubjectVariationOverride(subjectKey, configuration);
if (!subjectVariationOverride.isNull()) {
- return Optional.of(subjectVariationOverride);
+ // Create placeholder variation for the override
+ Variation overrideVariation = new Variation();
+ overrideVariation.setTypedValue(subjectVariationOverride);
+ overrideVariation.setAlgorithmType(AlgorithmType.OVERRIDE);
+ return new VariationAssignmentResult(overrideVariation);
}
// Check if disabled
if (!configuration.isEnabled()) {
log.info("[Eppo SDK] No assigned variation because the experiment or feature flag {} is disabled", flagKey);
- return Optional.empty();
+ return null;
}
// Find matched rule
Optional rule = RuleValidator.findMatchingRule(subjectAttributes, configuration.getRules());
if (!rule.isPresent()) {
log.info("[Eppo SDK] No assigned variation. The subject attributes did not match any targeting rules");
- return Optional.empty();
+ return null;
}
// Check if in experiment sample
String allocationKey = rule.get().getAllocationKey();
Allocation allocation = configuration.getAllocation(allocationKey);
- if (!this.isInExperimentSample(subjectKey, flagKey, configuration.getSubjectShards(),
- allocation.getPercentExposure())) {
+ int subjectShards = configuration.getSubjectShards();
+ if (!this.isInExperimentSample(subjectKey, flagKey, subjectShards,
+ allocation.getPercentExposure())) {
log.info("[Eppo SDK] No assigned variation. The subject is not part of the sample population");
- return Optional.empty();
+ return null;
}
+ List variations = allocation.getVariations();
+
+ String experimentKey = ExperimentHelper.generateKey(flagKey, allocationKey); // Used for logging
+
// Get assigned variation
- Variation assignedVariation = this.getAssignedVariation(subjectKey, flagKey, configuration.getSubjectShards(),
- allocation.getVariations());
+ String assignmentKey = "assignment-" + subjectKey + "-" + flagKey;
+ Variation assignedVariation = VariationHelper.selectVariation(assignmentKey, subjectShards, variations);
+
+ return new VariationAssignmentResult(
+ assignedVariation,
+ subjectKey,
+ subjectAttributes,
+ flagKey,
+ allocationKey,
+ experimentKey,
+ subjectShards
+ );
+ }
- try {
- String experimentKey = ExperimentHelper.generateKey(flagKey, allocationKey);
- this.eppoClientConfig.getAssignmentLogger()
- .logAssignment(new AssignmentLogData(
- experimentKey,
- flagKey,
- allocationKey,
- assignedVariation.getTypedValue().stringValue(),
- subjectKey,
- subjectAttributes));
- } catch (Exception e) {
- // Ignore Exception
+ private Optional determineAndLogBanditAction(VariationAssignmentResult assignmentResult, Map assignmentOptions) {
+ String banditName = assignmentResult.getVariation().getTypedValue().stringValue();
+
+ String banditKey = assignmentResult.getVariation().getTypedValue().stringValue();
+ BanditParameters banditParameters = this.configurationStore.getBanditParameters(banditKey);
+
+ List actionVariations = BanditEvaluator.evaluateBanditActions(
+ assignmentResult.getExperimentKey(),
+ banditParameters,
+ assignmentOptions,
+ assignmentResult.getSubjectKey(),
+ assignmentResult.getSubjectAttributes(),
+ assignmentResult.getSubjectShards()
+ );
+
+ String actionSelectionKey = "bandit-" + banditName + "-" + assignmentResult.getSubjectKey() + "-" + assignmentResult.getFlagKey();
+ Variation selectedAction = VariationHelper.selectVariation(actionSelectionKey, assignmentResult.getSubjectShards(), actionVariations);
+
+ EppoValue actionValue = selectedAction.getTypedValue();
+ String actionString = actionValue.stringValue();
+ double actionProbability = VariationHelper.variationProbability(selectedAction, assignmentResult.getSubjectShards());
+
+ if (this.eppoClientConfig.getBanditLogger() != null) {
+ // Do bandit-specific logging
+
+ String modelVersionToLog = "uninitialized"; // Default model "version" if we have not seen this bandit before or don't have model parameters for it
+ if (banditParameters != null) {
+ modelVersionToLog = banditParameters.getModelName() + " " + banditParameters.getModelVersion();
+ }
+
+ // Get the action-related attributes
+ EppoAttributes actionAttributes = new EppoAttributes();
+ if (assignmentOptions != null && !assignmentOptions.isEmpty()) {
+ actionAttributes = assignmentOptions.get(actionString);
+ }
+
+ Map subjectNumericAttributes = numericAttributes(assignmentResult.getSubjectAttributes());
+ Map subjectCategoricalAttributes = categoricalAttributes(assignmentResult.getSubjectAttributes());
+ Map actionNumericAttributes = numericAttributes(actionAttributes);
+ Map actionCategoricalAttributes = categoricalAttributes(actionAttributes);
+
+ this.eppoClientConfig.getBanditLogger().logBanditAction(new BanditLogData(
+ assignmentResult.getExperimentKey(),
+ banditName,
+ assignmentResult.getSubjectKey(),
+ actionString,
+ actionProbability,
+ modelVersionToLog,
+ subjectNumericAttributes,
+ subjectCategoricalAttributes,
+ actionNumericAttributes,
+ actionCategoricalAttributes
+ ));
}
- return Optional.of(assignedVariation.getTypedValue());
+
+ return Optional.of(actionValue);
}
/**
@@ -130,31 +219,40 @@ protected Optional getAssignmentValue(
* @param subjectAttributes
* @return
*/
- private Optional> getTypedAssignment(String subjectKey, String experimentKey, EppoValueType type,
- SubjectAttributes subjectAttributes) {
+ private Optional> getTypedAssignment(
+ EppoValueType type,
+ String subjectKey,
+ String experimentKey,
+ EppoAttributes subjectAttributes,
+ Map actionsWithAttributes
+ ) {
try {
- Optional value = this.getAssignmentValue(subjectKey, experimentKey, subjectAttributes);
+ Optional value = this.getAssignmentValue(subjectKey, experimentKey, subjectAttributes, actionsWithAttributes);
if (!value.isPresent()) {
return Optional.empty();
}
+ EppoValue eppoValue = value.get();
+
switch (type) {
- case BOOLEAN:
- return Optional.of(value.get().boolValue());
case NUMBER:
- return Optional.of(value.get().doubleValue());
+ return Optional.of(eppoValue.doubleValue());
+ case BOOLEAN:
+ return Optional.of(eppoValue.boolValue());
+ case ARRAY_OF_STRING:
+ return Optional.of(eppoValue.arrayValue());
case JSON_NODE:
- return Optional.of(value.get().jsonNodeValue());
- default:
- return Optional.of(value.get().stringValue());
+ return Optional.of(eppoValue.jsonNodeValue());
+ default: // strings and null
+ return Optional.of(eppoValue.stringValue());
}
} catch (Exception e) {
- // if graceful mode
- if (this.eppoClientConfig.isGracefulMode()) {
- log.warn("[Eppo SDK] Error getting assignment value: " + e.getMessage());
- return Optional.empty();
- }
- throw e;
+ // if graceful mode
+ if (this.eppoClientConfig.isGracefulMode()) {
+ log.warn("[Eppo SDK] Error getting assignment value: " + e.getMessage());
+ return Optional.empty();
+ }
+ throw e;
}
}
@@ -167,7 +265,7 @@ private Optional> getTypedAssignment(String subjectKey, String experimentKey,
* @return
*/
public Optional getAssignment(String subjectKey, String experimentKey,
- SubjectAttributes subjectAttributes) {
+ EppoAttributes subjectAttributes) {
return this.getStringAssignment(subjectKey, experimentKey, subjectAttributes);
}
@@ -180,33 +278,81 @@ public Optional getAssignment(String subjectKey, String experimentKey,
* @return
*/
public Optional getAssignment(String subjectKey, String experimentKey) {
- return this.getStringAssignment(subjectKey, experimentKey, new SubjectAttributes());
+ return this.getStringAssignment(subjectKey, experimentKey, new EppoAttributes());
}
/**
- * This function will return string assignment value
- *
- * @param subjectKey
- * @param experimentKey
- * @param subjectAttributes
- * @return
+ * Maps a subject to a variation for a given flag/experiment.
+ *
+ * @param subjectKey identifier of the experiment subject, for example a user ID.
+ * @param flagKey flagKey feature flag, bandit, or experiment identifier
+ * @return the variation string assigned to the subject, or null if an unrecoverable error was encountered.
*/
- public Optional getStringAssignment(String subjectKey, String experimentKey,
- SubjectAttributes subjectAttributes) {
- return (Optional) this.getTypedAssignment(subjectKey, experimentKey, EppoValueType.STRING,
- subjectAttributes);
+ public Optional getStringAssignment(String subjectKey, String flagKey) {
+ return this.getStringAssignment(subjectKey, flagKey, new EppoAttributes());
}
/**
- * This function will return string assignment value without passing
- * subjectAttributes
- *
- * @param subjectKey
- * @param experimentKey
- * @return
+ * Maps a subject to a variation for a given flag/experiment.
+ *
+ * @param subjectKey identifier of the experiment subject, for example a user ID.
+ * @param flagKey flagKey feature flag, bandit, or experiment identifier
+ * @param subjectAttributes optional attributes associated with the subject, for example name, email,
+ * account age, etc. The subject attributes are used for evaluating any targeting
+ * rules as well as weighting assignment choices for bandits.
+ * @return the variation string assigned to the subject, or null if an unrecoverable error was encountered.
*/
- public Optional getStringAssignment(String subjectKey, String experimentKey) {
- return this.getStringAssignment(subjectKey, experimentKey, new SubjectAttributes());
+ public Optional getStringAssignment(String subjectKey, String flagKey,
+ EppoAttributes subjectAttributes) {
+ return this.getStringAssignment(subjectKey, flagKey, subjectAttributes, new HashSet<>());
+ }
+
+ /**
+ * Maps a subject to a variation for a given flag/bandit/experiment.
+ *
+ * @param subjectKey identifier of the experiment subject, for example a user ID.
+ * @param flagKey flagKey feature flag, bandit, or experiment identifier
+ * @param subjectAttributes optional attributes associated with the subject, for example name, email,
+ * account age, etc. The subject attributes are used for evaluating any targeting
+ * rules as well as weighting assignment choices for bandits.
+ * @param actions used by bandits to know the actions (potential assignments) available.
+ * @return the variation string assigned to the subject, or null if an unrecoverable error was encountered.
+ */
+ public Optional getStringAssignment(
+ String subjectKey,
+ String flagKey,
+ EppoAttributes subjectAttributes,
+ Set actions
+ ) {
+ Map actionsWithEmptyAttributes = actions.stream()
+ .collect(Collectors.toMap(
+ key -> key,
+ value -> new EppoAttributes()
+ ));
+ return this.getStringAssignment(subjectKey, flagKey, subjectAttributes, actionsWithEmptyAttributes);
+ }
+
+ /**
+ * Maps a subject to a variation for a given flag/bandit/experiment.
+ *
+ * @param subjectKey identifier of the experiment subject, for example a user ID.
+ * @param flagKey flagKey feature flag, bandit, or experiment identifier
+ * @param subjectAttributes optional attributes associated with the subject, for example name, email,
+ * account age, etc. The subject attributes are used for evaluating any targeting
+ * rules as well as weighting assignment choices for bandits.
+ * @param actionsWithAttributes used by bandits to know the actions (assignment options) available and any
+ * attributes associated with that option.
+ * @return the variation string assigned to the subject, or null if an unrecoverable error was encountered.
+ */
+ public Optional getStringAssignment(
+ String subjectKey,
+ String flagKey,
+ EppoAttributes subjectAttributes,
+ Map actionsWithAttributes
+ ) {
+ @SuppressWarnings("unchecked")
+ Optional typedAssignment = (Optional) this.getTypedAssignment(EppoValueType.STRING, subjectKey, flagKey, subjectAttributes, actionsWithAttributes);
+ return typedAssignment;
}
/**
@@ -218,9 +364,8 @@ public Optional getStringAssignment(String subjectKey, String experiment
* @return
*/
public Optional getBooleanAssignment(String subjectKey, String experimentKey,
- SubjectAttributes subjectAttributes) {
- return (Optional) this.getTypedAssignment(subjectKey, experimentKey, EppoValueType.BOOLEAN,
- subjectAttributes);
+ EppoAttributes subjectAttributes) {
+ return (Optional) this.getTypedAssignment(EppoValueType.BOOLEAN, subjectKey, experimentKey, subjectAttributes, null);
}
/**
@@ -232,7 +377,7 @@ public Optional getBooleanAssignment(String subjectKey, String experime
* @return
*/
public Optional getBooleanAssignment(String subjectKey, String experimentKey) {
- return this.getBooleanAssignment(subjectKey, experimentKey, new SubjectAttributes());
+ return this.getBooleanAssignment(subjectKey, experimentKey, new EppoAttributes());
}
/**
@@ -244,9 +389,8 @@ public Optional getBooleanAssignment(String subjectKey, String experime
* @return
*/
public Optional getDoubleAssignment(String subjectKey, String experimentKey,
- SubjectAttributes subjectAttributes) {
- return (Optional) this.getTypedAssignment(subjectKey, experimentKey, EppoValueType.NUMBER,
- subjectAttributes);
+ EppoAttributes subjectAttributes) {
+ return (Optional) this.getTypedAssignment(EppoValueType.NUMBER, subjectKey, experimentKey, subjectAttributes, null);
}
/**
@@ -258,7 +402,7 @@ public Optional getDoubleAssignment(String subjectKey, String experiment
* @return
*/
public Optional getDoubleAssignment(String subjectKey, String experimentKey) {
- return this.getDoubleAssignment(subjectKey, experimentKey, new SubjectAttributes());
+ return this.getDoubleAssignment(subjectKey, experimentKey, new EppoAttributes());
}
/**
@@ -270,7 +414,7 @@ public Optional getDoubleAssignment(String subjectKey, String experiment
* @return
*/
public Optional getJSONStringAssignment(String subjectKey, String experimentKey,
- SubjectAttributes subjectAttributes) {
+ EppoAttributes subjectAttributes) {
return this.getStringAssignment(subjectKey, experimentKey, subjectAttributes);
}
@@ -283,7 +427,7 @@ public Optional getJSONStringAssignment(String subjectKey, String experi
* @return
*/
public Optional getJSONStringAssignment(String subjectKey, String experimentKey) {
- return this.getJSONStringAssignment(subjectKey, experimentKey, new SubjectAttributes());
+ return this.getJSONStringAssignment(subjectKey, experimentKey, new EppoAttributes());
}
/**
@@ -295,9 +439,9 @@ public Optional getJSONStringAssignment(String subjectKey, String experi
* @return
*/
public Optional getParsedJSONAssignment(String subjectKey, String experimentKey,
- SubjectAttributes subjectAttributes) {
- return (Optional) this.getTypedAssignment(subjectKey, experimentKey, EppoValueType.JSON_NODE,
- subjectAttributes);
+ EppoAttributes subjectAttributes) {
+ return (Optional) this.getTypedAssignment(EppoValueType.JSON_NODE, subjectKey, experimentKey,
+ subjectAttributes, null);
}
/**
@@ -309,7 +453,7 @@ public Optional getParsedJSONAssignment(String subjectKey, String expe
* @return
*/
public Optional getParsedJSONAssignment(String subjectKey, String experimentKey) {
- return this.getParsedJSONAssignment(subjectKey, experimentKey, new SubjectAttributes());
+ return this.getParsedJSONAssignment(subjectKey, experimentKey, new EppoAttributes());
}
/**
@@ -325,34 +469,11 @@ private boolean isInExperimentSample(
String subjectKey,
String experimentKey,
int subjectShards,
- float percentageExposure) {
+ double percentageExposure) {
int shard = Shard.getShard("exposure-" + subjectKey + "-" + experimentKey, subjectShards);
return shard <= percentageExposure * subjectShards;
}
- /**
- * This function is used to get assigned variation
- *
- * @param subjectKey
- * @param experimentKey
- * @param subjectShards
- * @param subjectShards
- * @return
- */
- private Variation getAssignedVariation(
- String subjectKey,
- String experimentKey,
- int subjectShards,
- List variations) {
- int shard = Shard.getShard("assignment-" + subjectKey + "-" + experimentKey, subjectShards);
-
- Optional variation = variations.stream()
- .filter(config -> Shard.isShardInRange(shard, config.getShardRange()))
- .findFirst();
-
- return variation.get();
- }
-
/**
* This function is used to get override variations.
*
@@ -364,9 +485,83 @@ private EppoValue getSubjectVariationOverride(
String subjectKey,
ExperimentConfiguration experimentConfiguration) {
String hexedSubjectKey = Shard.getHex(subjectKey);
- return experimentConfiguration.getTypedOverrides().getOrDefault(hexedSubjectKey, new EppoValue());
+ return experimentConfiguration.getTypedOverrides().getOrDefault(hexedSubjectKey, EppoValue.nullValue());
+ }
+
+ /***
+ * Logs an action taken that was not selected by the bandit.
+ * Useful for full transparency on what users experienced.
+ * @param subjectKey subjectKey identifier of the experiment subject, for example a user ID.
+ * @param flagKey feature flag, bandit, or experiment identifier
+ * @param subjectAttributes optional attributes associated with the subject, for example name, email, account age, etc.
+ * @param actionString name of the action taken for the subject
+ * @param actionAttributes attributes associated with the given action
+ * @return null if no exception was encountered by logging; otherwise, the encountered exception
+ */
+ public Exception logNonBanditAction(
+ String subjectKey,
+ String flagKey,
+ EppoAttributes subjectAttributes,
+ String actionString,
+ EppoAttributes actionAttributes
+ ) {
+ Exception loggingException = null;
+ try {
+ VariationAssignmentResult assignmentResult = this.getAssignedVariation(flagKey, subjectKey, subjectAttributes);
+
+ if (assignmentResult == null) {
+ // No bandit at play
+ return null;
+ }
+
+ String variationValue = assignmentResult.getVariation().getTypedValue().toString();
+
+ Map subjectNumericAttributes = numericAttributes(subjectAttributes);
+ Map subjectCategoricalAttributes = categoricalAttributes(subjectAttributes);
+ Map actionNumericAttributes = numericAttributes(actionAttributes);
+ Map actionCategoricalAttributes = categoricalAttributes(actionAttributes);
+
+ this.eppoClientConfig.getBanditLogger().logBanditAction(new BanditLogData(
+ assignmentResult.getExperimentKey(),
+ variationValue,
+ subjectKey,
+ actionString,
+ null,
+ null,
+ subjectNumericAttributes,
+ subjectCategoricalAttributes,
+ actionNumericAttributes,
+ actionCategoricalAttributes
+ ));
+ } catch (Exception ex) {
+ loggingException = ex;
+ }
+ return loggingException;
}
+ private Map numericAttributes(EppoAttributes eppoAttributes) {
+ if (eppoAttributes == null) {
+ return new HashMap<>();
+ }
+ return eppoAttributes.entrySet().stream().filter(e -> e.getValue().isNumeric()
+ ).collect(Collectors.toMap(
+ Map.Entry::getKey,
+ e -> e.getValue().doubleValue())
+ );
+ }
+
+ private Map categoricalAttributes(EppoAttributes eppoAttributes) {
+ if (eppoAttributes == null) {
+ return new HashMap<>();
+ }
+ return eppoAttributes.entrySet().stream().filter(e -> !e.getValue().isNumeric() && !e.getValue().isNull()
+ ).collect(Collectors.toMap(
+ Map.Entry::getKey,
+ e -> e.getValue().toString())
+ );
+ }
+
+
/***
* This function is used to initialize the Eppo Client
*
@@ -387,16 +582,24 @@ public static synchronized EppoClient init(EppoClientConfig eppoClientConfig) {
eppoClientConfig.getBaseURL(),
Constants.REQUEST_TIMEOUT_MILLIS);
- // Create wrapper for fetching experiment configuration
- ExperimentConfigurationRequestor expConfigRequestor = new ExperimentConfigurationRequestor(eppoHttpClient);
- // Create Caching for Experiment Configuration
+ // Create wrapper for fetching experiment and bandit configuration
+ ConfigurationRequestor expConfigRequestor =
+ new ConfigurationRequestor<>(ExperimentConfigurationResponse.class, eppoHttpClient, Constants.RAC_ENDPOINT);
+ ConfigurationRequestor banditParametersRequestor =
+ new ConfigurationRequestor<>(BanditParametersResponse.class, eppoHttpClient, Constants.BANDIT_ENDPOINT);
+ // Create Caching for Experiment Configuration and Bandit Parameters
CacheHelper cacheHelper = new CacheHelper();
Cache experimentConfigurationCache = cacheHelper
.createExperimentConfigurationCache(Constants.MAX_CACHE_ENTRIES);
+ Cache banditParametersCache = cacheHelper
+ .createBanditParameterCache(Constants.MAX_CACHE_ENTRIES);
// Create ExperimentConfiguration Store
ConfigurationStore configurationStore = ConfigurationStore.init(
experimentConfigurationCache,
- expConfigRequestor);
+ expConfigRequestor,
+ banditParametersCache,
+ banditParametersRequestor
+ );
// Stop the polling process of any previously initialized client
if (EppoClient.instance != null) {
diff --git a/src/main/java/com/eppo/sdk/constants/Constants.java b/src/main/java/com/eppo/sdk/constants/Constants.java
index 40a4fc0..08288d3 100644
--- a/src/main/java/com/eppo/sdk/constants/Constants.java
+++ b/src/main/java/com/eppo/sdk/constants/Constants.java
@@ -32,9 +32,13 @@ public class Constants {
*/
public static final String RAC_ENDPOINT = "/randomized_assignment/v3/config";
+ public static final String BANDIT_ENDPOINT = "/randomized_assignment/v3/bandits";
+
/**
* Caching Settings
*/
public static final String EXPERIMENT_CONFIGURATION_CACHE_KEY = "experiment-configuration";
+ public static final String BANDIT_PARAMETER_CACHE_KEY = "bandit-parameter";
+
}
diff --git a/src/main/java/com/eppo/sdk/deserializer/BanditsDeserializer.java b/src/main/java/com/eppo/sdk/deserializer/BanditsDeserializer.java
new file mode 100644
index 0000000..5e521e9
--- /dev/null
+++ b/src/main/java/com/eppo/sdk/deserializer/BanditsDeserializer.java
@@ -0,0 +1,132 @@
+package com.eppo.sdk.deserializer;
+
+import com.eppo.sdk.dto.*;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.*;
+
+public class BanditsDeserializer extends StdDeserializer