From ea70055adfd951c664e5c89afcf99e04394404bf Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Sun, 7 Dec 2025 16:17:08 -0500 Subject: [PATCH 01/11] tests passing for details with hardcoded environment --- src/main/java/cloud/eppo/BaseEppoClient.java | 464 ++++++-- .../java/cloud/eppo/BaseEppoClient.java.bak | 1050 +++++++++++++++++ .../eppo/DetailedFlagEvaluationResult.java | 179 +++ src/main/java/cloud/eppo/FlagEvaluator.java | 469 ++++++-- .../cloud/eppo/api/AllocationDetails.java | 30 + .../eppo/api/AllocationEvaluationCode.java | 61 + .../cloud/eppo/api/AssignmentDetails.java | 31 + src/main/java/cloud/eppo/api/EppoValue.java | 34 + .../cloud/eppo/api/EvaluationDetails.java | 95 ++ .../cloud/eppo/api/FlagEvaluationCode.java | 71 ++ src/main/java/cloud/eppo/api/MatchedRule.java | 16 + .../java/cloud/eppo/api/RuleCondition.java | 57 + .../FlagConfigResponseDeserializer.java | 3 +- .../java/cloud/eppo/BaseEppoClientTest.java | 35 +- .../java/cloud/eppo/FlagEvaluatorTest.java | 66 +- .../java/cloud/eppo/api/EppoValueTest.java | 123 ++ .../eppo/helpers/AssignmentTestCase.java | 280 ++++- .../AssignmentTestCaseDeserializer.java | 133 ++- .../cloud/eppo/helpers/SubjectAssignment.java | 19 + 19 files changed, 2946 insertions(+), 270 deletions(-) create mode 100644 src/main/java/cloud/eppo/BaseEppoClient.java.bak create mode 100644 src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java create mode 100644 src/main/java/cloud/eppo/api/AllocationDetails.java create mode 100644 src/main/java/cloud/eppo/api/AllocationEvaluationCode.java create mode 100644 src/main/java/cloud/eppo/api/AssignmentDetails.java create mode 100644 src/main/java/cloud/eppo/api/EvaluationDetails.java create mode 100644 src/main/java/cloud/eppo/api/FlagEvaluationCode.java create mode 100644 src/main/java/cloud/eppo/api/MatchedRule.java create mode 100644 src/main/java/cloud/eppo/api/RuleCondition.java diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 6a206c0e..d74b07f4 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -12,9 +12,9 @@ import cloud.eppo.logging.BanditLogger; import cloud.eppo.ufc.dto.*; import cloud.eppo.ufc.dto.adapters.EppoModule; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import java.util.Timer; @@ -190,65 +190,125 @@ protected CompletableFuture loadConfigurationAsync() { return future; } - protected EppoValue getTypedAssignment( + /** + * Top-level assignment details method that evaluates, logs if applicable, and returns the user-facing AssignmentDetails result class. + * If any error in the evaluation, the result value will be set to the default value. + */ + protected AssignmentDetails getTypedAssignmentWithDetails( String flagKey, String subjectKey, Attributes subjectAttributes, - EppoValue defaultValue, + T defaultValue, VariationType expectedType) { + EvaluationDetails details = + evaluateAndMaybeLog(flagKey, subjectKey, subjectAttributes, expectedType); + + T resultValue = details.evaluationSuccessful() ? EppoValue.unwrap(details.getVariationValue(), expectedType) : defaultValue; + return new AssignmentDetails<>(resultValue, null, details); + } + + /** + * Core evaluation method that handles validation, evaluation, and logging. This consolidates the + * shared logic between all assignment methods. Returns evaluation details with variationValue set + * to the result. + */ + protected EvaluationDetails evaluateAndMaybeLog( + String flagKey, String subjectKey, Attributes subjectAttributes, VariationType expectedType) { + throwIfEmptyOrNull(flagKey, "flagKey must not be empty"); throwIfEmptyOrNull(subjectKey, "subjectKey must not be empty"); Configuration config = getConfiguration(); + // Check if flag exists FlagConfig flag = config.getFlag(flagKey); if (flag == null) { log.warn("no configuration found for key: {}", flagKey); - return defaultValue; + return buildDefaultEvaluationDetails( + FlagEvaluationCode.FLAG_UNRECOGNIZED_OR_DISABLED, + "Unrecognized or disabled flag: " + flagKey, + null); } + // Check if flag is enabled if (!flag.isEnabled()) { log.info( "no assigned variation because the experiment or feature flag is disabled: {}", flagKey); - return defaultValue; + return buildDefaultEvaluationDetails( + FlagEvaluationCode.FLAG_UNRECOGNIZED_OR_DISABLED, + "Unrecognized or disabled flag: " + flagKey, + null); } + // Check if flag type matches expected type if (flag.getVariationType() != expectedType) { log.warn( "no assigned variation because the flag type doesn't match the requested type: {} has type {}, requested {}", flagKey, flag.getVariationType(), expectedType); - return defaultValue; + return buildDefaultEvaluationDetails( + FlagEvaluationCode.TYPE_MISMATCH, + String.format( + "Flag \"%s\" has type %s, requested %s", + flagKey, flag.getVariationType(), expectedType), + null); } - FlagEvaluationResult evaluationResult = - FlagEvaluator.evaluateFlag( + // Evaluate flag with details + DetailedFlagEvaluationResult detailedResult = + FlagEvaluator.evaluateFlagWithDetails( flag, flagKey, subjectKey, subjectAttributes, config.isConfigObfuscated()); + EvaluationDetails evaluationDetails = detailedResult.getEvaluationDetails(); + EppoValue assignedValue = - evaluationResult.getVariation() != null ? evaluationResult.getVariation().getValue() : null; + detailedResult.getVariation() != null ? detailedResult.getVariation().getValue() : null; + // Check if value type matches expected if (assignedValue != null && !valueTypeMatchesExpected(expectedType, assignedValue)) { log.warn( "no assigned variation because the flag type doesn't match the variation type: {} has type {}, variation value is {}", flagKey, flag.getVariationType(), assignedValue); - return defaultValue; - } - if (assignedValue != null && assignmentLogger != null && evaluationResult.doLog()) { + // Update evaluation details with error code but keep the matched allocation and variation + // info + String variationKey = + detailedResult.getVariation() != null ? detailedResult.getVariation().getKey() : null; + String errorDescription = + String.format( + "Variation (%s) is configured for type %s, but is set to incompatible value (%s)", + variationKey, expectedType, assignedValue.doubleValue()); + + return new EvaluationDetails( + evaluationDetails.getEnvironmentName(), + FlagEvaluationCode + .ASSIGNMENT_ERROR, // We use ASSIGNMENT_ERROR for value mismatch as it's a + // misconfiguration of the flag itself + errorDescription, + evaluationDetails.getBanditKey(), + evaluationDetails.getBanditAction(), + variationKey, + assignedValue, + evaluationDetails.getMatchedRule(), + evaluationDetails.getMatchedAllocation(), + evaluationDetails.getUnmatchedAllocations(), + evaluationDetails.getUnevaluatedAllocations()); + } + // Log assignment if applicable + if (assignedValue != null && assignmentLogger != null && detailedResult.doLog()) { try { - String allocationKey = evaluationResult.getAllocationKey(); + String allocationKey = detailedResult.getAllocationKey(); String experimentKey = flagKey + '-' + allocationKey; // Our experiment key is derived by hyphenating the flag key and // allocation key - String variationKey = evaluationResult.getVariation().getKey(); - Map extraLogging = evaluationResult.getExtraLogging(); + String variationKey = detailedResult.getVariation().getKey(); + Map extraLogging = detailedResult.getExtraLogging(); Map metaData = buildLogMetaData(config.isConfigObfuscated()); Assignment assignment = @@ -278,7 +338,8 @@ protected EppoValue getTypedAssignment( log.error("Error logging assignment: {}", e.getMessage(), e); } } - return assignedValue != null ? assignedValue : defaultValue; + + return evaluationDetails; } private boolean valueTypeMatchesExpected(VariationType expectedType, EppoValue value) { @@ -303,7 +364,7 @@ private boolean valueTypeMatchesExpected(VariationType expectedType, EppoValue v typeMatch = value.isString() // Eppo leaves JSON as a JSON string; to verify it's valid we attempt to parse - && parseJsonString(value.stringValue()) != null; + && EppoValue.unwrap(value, VariationType.JSON) != null; break; default: throw new IllegalArgumentException("Unexpected type for type checking: " + expectedType); @@ -318,17 +379,28 @@ public boolean getBooleanAssignment(String flagKey, String subjectKey, boolean d public boolean getBooleanAssignment( String flagKey, String subjectKey, Attributes subjectAttributes, boolean defaultValue) { + return this.getBooleanAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) + .getVariation(); + } + + public AssignmentDetails getBooleanAssignmentDetails( + String flagKey, String subjectKey, boolean defaultValue) { + return this.getBooleanAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public AssignmentDetails getBooleanAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, boolean defaultValue) { try { - EppoValue value = - this.getTypedAssignment( - flagKey, - subjectKey, - subjectAttributes, - EppoValue.valueOf(defaultValue), - VariationType.BOOLEAN); - return value.booleanValue(); + return this.getTypedAssignmentWithDetails( + flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.BOOLEAN); } catch (Exception e) { - return throwIfNotGraceful(e, defaultValue); + return new AssignmentDetails<>( + throwIfNotGraceful(e, defaultValue), + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); } } @@ -338,17 +410,28 @@ public int getIntegerAssignment(String flagKey, String subjectKey, int defaultVa public int getIntegerAssignment( String flagKey, String subjectKey, Attributes subjectAttributes, int defaultValue) { + return this.getIntegerAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) + .getVariation(); + } + + public AssignmentDetails getIntegerAssignmentDetails( + String flagKey, String subjectKey, int defaultValue) { + return getIntegerAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public AssignmentDetails getIntegerAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, int defaultValue) { try { - EppoValue value = - this.getTypedAssignment( - flagKey, - subjectKey, - subjectAttributes, - EppoValue.valueOf(defaultValue), - VariationType.INTEGER); - return Double.valueOf(value.doubleValue()).intValue(); + return this.getTypedAssignmentWithDetails( + flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.INTEGER); } catch (Exception e) { - return throwIfNotGraceful(e, defaultValue); + return new AssignmentDetails<>( + throwIfNotGraceful(e, defaultValue), + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); } } @@ -358,17 +441,28 @@ public Double getDoubleAssignment(String flagKey, String subjectKey, double defa public Double getDoubleAssignment( String flagKey, String subjectKey, Attributes subjectAttributes, double defaultValue) { + return this.getDoubleAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) + .getVariation(); + } + + public AssignmentDetails getDoubleAssignmentDetails( + String flagKey, String subjectKey, double defaultValue) { + return getDoubleAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public AssignmentDetails getDoubleAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, double defaultValue) { try { - EppoValue value = - this.getTypedAssignment( - flagKey, - subjectKey, - subjectAttributes, - EppoValue.valueOf(defaultValue), - VariationType.NUMERIC); - return value.doubleValue(); + return this.getTypedAssignmentWithDetails( + flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.NUMERIC); } catch (Exception e) { - return throwIfNotGraceful(e, defaultValue); + return new AssignmentDetails<>( + throwIfNotGraceful(e, defaultValue), + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); } } @@ -378,105 +472,91 @@ public String getStringAssignment(String flagKey, String subjectKey, String defa public String getStringAssignment( String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + return this.getStringAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) + .getVariation(); + } + + public AssignmentDetails getStringAssignmentDetails( + String flagKey, String subjectKey, String defaultValue) { + return this.getStringAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public AssignmentDetails getStringAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { try { - EppoValue value = - this.getTypedAssignment( - flagKey, - subjectKey, - subjectAttributes, - EppoValue.valueOf(defaultValue), - VariationType.STRING); - return value.stringValue(); + return this.getTypedAssignmentWithDetails( + flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.STRING); } catch (Exception e) { - return throwIfNotGraceful(e, defaultValue); + return new AssignmentDetails<>( + throwIfNotGraceful(e, defaultValue), + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); } } - /** - * Returns the assignment for the provided feature flag key and subject key as a {@link JsonNode}. - * If the flag is not found, does not match the requested type or is disabled, defaultValue is - * returned. - * - * @param flagKey the feature flag key - * @param subjectKey the subject key - * @param defaultValue the default value to return if the flag is not found - * @return the JSON string value of the assignment - */ public JsonNode getJSONAssignment(String flagKey, String subjectKey, JsonNode defaultValue) { return getJSONAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } - /** - * Returns the assignment for the provided feature flag key and subject key as a {@link JsonNode}. - * If the flag is not found, does not match the requested type or is disabled, defaultValue is - * returned. - * - * @param flagKey the feature flag key - * @param subjectKey the subject key - * @param defaultValue the default value to return if the flag is not found - * @return the JSON string value of the assignment - */ public JsonNode getJSONAssignment( String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { - try { - EppoValue value = - this.getTypedAssignment( - flagKey, - subjectKey, - subjectAttributes, - EppoValue.valueOf(defaultValue.toString()), - VariationType.JSON); - return parseJsonString(value.stringValue()); - } catch (Exception e) { - return throwIfNotGraceful(e, defaultValue); - } + return this.getJSONAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) + .getVariation(); } - /** - * Returns the assignment for the provided feature flag key, subject key and subject attributes as - * a JSON string. If the flag is not found, does not match the requested type or is disabled, - * defaultValue is returned. - * - * @param flagKey the feature flag key - * @param subjectKey the subject key - * @param defaultValue the default value to return if the flag is not found - * @return the JSON string value of the assignment - */ - public String getJSONStringAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + public AssignmentDetails getJSONAssignmentDetails( + String flagKey, String subjectKey, JsonNode defaultValue) { + return this.getJSONAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public AssignmentDetails getJSONAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { try { - EppoValue value = - this.getTypedAssignment( - flagKey, - subjectKey, - subjectAttributes, - EppoValue.valueOf(defaultValue), - VariationType.JSON); - return value.stringValue(); + return this.getTypedAssignmentWithDetails( + flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.JSON); } catch (Exception e) { - return throwIfNotGraceful(e, defaultValue); + String defaultValueString = defaultValue != null ? defaultValue.toString() : null; + return new AssignmentDetails<>( + throwIfNotGraceful(e, defaultValue), + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValueString))); } } - /** - * Returns the assignment for the provided feature flag key and subject key as a JSON String. If - * the flag is not found, does not match the requested type or is disabled, defaultValue is - * returned. - * - * @param flagKey the feature flag key - * @param subjectKey the subject key - * @param defaultValue the default value to return if the flag is not found - * @return the JSON string value of the assignment - */ public String getJSONStringAssignment(String flagKey, String subjectKey, String defaultValue) { return this.getJSONStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } - private JsonNode parseJsonString(String jsonString) { + public String getJSONStringAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + return this.getJSONStringAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue) + .getVariation(); + } + + public AssignmentDetails getJSONStringAssignmentDetails( + String flagKey, String subjectKey, String defaultValue) { + return this.getJSONStringAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public AssignmentDetails getJSONStringAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { try { - return mapper.readTree(jsonString); - } catch (JsonProcessingException e) { - return null; + return this.getTypedAssignmentWithDetails( + flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.JSON); + } catch (Exception e) { + return new AssignmentDetails<>( + throwIfNotGraceful(e, defaultValue), + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); } } @@ -551,6 +631,150 @@ public BanditResult getBanditAction( } } + /** + * Returns bandit action assignment with detailed evaluation information including flag evaluation + * details and bandit action selection. + */ + public AssignmentDetails getBanditActionDetails( + String flagKey, + String subjectKey, + DiscriminableAttributes subjectAttributes, + Actions actions, + String defaultValue) { + final Configuration config = getConfiguration(); + try { + // Get detailed flag assignment + AssignmentDetails flagDetails = + getStringAssignmentDetails( + flagKey, subjectKey, subjectAttributes.getAllAttributes(), defaultValue); + + String assignedVariation = flagDetails.getVariation(); + String assignedAction = null; + + // If we got a variation, check for bandit + String banditKey = config.banditKeyForVariation(flagKey, assignedVariation); + if (banditKey != null && !actions.isEmpty()) { + try { + BanditParameters banditParameters = config.getBanditParameters(banditKey); + if (banditParameters == null) { + throw new RuntimeException("Bandit parameters not found for bandit key: " + banditKey); + } + BanditEvaluationResult banditResult = + BanditEvaluator.evaluateBandit( + flagKey, subjectKey, subjectAttributes, actions, banditParameters.getModelData()); + + assignedAction = banditResult.getActionKey(); + + // Log bandit assignment if needed + if (banditLogger != null) { + try { + BanditAssignment banditAssignment = + new BanditAssignment( + flagKey, + banditKey, + subjectKey, + banditResult.getActionKey(), + banditResult.getActionWeight(), + banditResult.getOptimalityGap(), + banditParameters.getModelVersion(), + subjectAttributes.getNumericAttributes(), + subjectAttributes.getCategoricalAttributes(), + banditResult.getActionAttributes().getNumericAttributes(), + banditResult.getActionAttributes().getCategoricalAttributes(), + buildLogMetaData(config.isConfigObfuscated())); + + boolean logBanditAssignment = true; + AssignmentCacheEntry cacheEntry = + AssignmentCacheEntry.fromBanditAssignment(banditAssignment); + if (banditAssignmentCache != null) { + if (banditAssignmentCache.hasEntry(cacheEntry)) { + logBanditAssignment = false; + } + } + + if (logBanditAssignment) { + banditLogger.logBanditAssignment(banditAssignment); + if (banditAssignmentCache != null) { + banditAssignmentCache.put(cacheEntry); + } + } + } catch (Exception e) { + log.warn("Error logging bandit assignment: {}", e.getMessage(), e); + } + } + + // Update evaluation details to include bandit information + EvaluationDetails updatedDetails = + new EvaluationDetails( + flagDetails.getEvaluationDetails().getEnvironmentName(), + flagDetails.getEvaluationDetails().getFlagEvaluationCode(), + flagDetails.getEvaluationDetails().getFlagEvaluationDescription(), + banditKey, + assignedAction, + flagDetails.getEvaluationDetails().getVariationKey(), + flagDetails.getEvaluationDetails().getVariationValue(), + flagDetails.getEvaluationDetails().getMatchedRule(), + flagDetails.getEvaluationDetails().getMatchedAllocation(), + flagDetails.getEvaluationDetails().getUnmatchedAllocations(), + flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); + + return new AssignmentDetails<>(assignedVariation, assignedAction, updatedDetails); + } catch (Exception banditError) { + // Bandit evaluation failed - return flag details with BANDIT_ERROR code + log.warn( + "Bandit evaluation failed for flag {}: {}", + flagKey, + banditError.getMessage(), + banditError); + EvaluationDetails banditErrorDetails = + new EvaluationDetails( + flagDetails.getEvaluationDetails().getEnvironmentName(), + FlagEvaluationCode.BANDIT_ERROR, + "Bandit evaluation failed: " + banditError.getMessage(), + banditKey, + null, // no action assigned due to error + flagDetails.getEvaluationDetails().getVariationKey(), + flagDetails.getEvaluationDetails().getVariationValue(), + flagDetails.getEvaluationDetails().getMatchedRule(), + flagDetails.getEvaluationDetails().getMatchedAllocation(), + flagDetails.getEvaluationDetails().getUnmatchedAllocations(), + flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); + return new AssignmentDetails<>(assignedVariation, null, banditErrorDetails); + } + } + + // No bandit - return flag details as-is + return flagDetails; + } catch (Exception e) { + BanditResult defaultResult = new BanditResult(defaultValue, null); + AssignmentDetails errorDetails = + new AssignmentDetails<>( + defaultValue, + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); + return throwIfNotGraceful(e, errorDetails); + } + } + + private EvaluationDetails buildDefaultEvaluationDetails( + FlagEvaluationCode code, String description, EppoValue variationValue) { + return new EvaluationDetails( + "Test", // Environment name - matches FlagEvaluator default + code, + description, + null, // banditKey + null, // banditAction + null, // variationKey + variationValue, + null, // matchedRule + null, // matchedAllocation + new ArrayList<>(), // unmatchedAllocations + new ArrayList<>()); // unevaluatedAllocations + } + private Map buildLogMetaData(boolean isConfigObfuscated) { HashMap metaData = new HashMap<>(); metaData.put("obfuscated", Boolean.valueOf(isConfigObfuscated).toString()); diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java.bak b/src/main/java/cloud/eppo/BaseEppoClient.java.bak new file mode 100644 index 00000000..0f46f29c --- /dev/null +++ b/src/main/java/cloud/eppo/BaseEppoClient.java.bak @@ -0,0 +1,1050 @@ +package cloud.eppo; + +import static cloud.eppo.Constants.DEFAULT_JITTER_INTERVAL_RATIO; +import static cloud.eppo.Constants.DEFAULT_POLLING_INTERVAL_MILLIS; +import static cloud.eppo.Utils.throwIfEmptyOrNull; + +import cloud.eppo.api.*; +import cloud.eppo.cache.AssignmentCacheEntry; +import cloud.eppo.logging.Assignment; +import cloud.eppo.logging.AssignmentLogger; +import cloud.eppo.logging.BanditAssignment; +import cloud.eppo.logging.BanditLogger; +import cloud.eppo.ufc.dto.*; +import cloud.eppo.ufc.dto.adapters.EppoModule; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.Timer; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class BaseEppoClient { + private static final Logger log = LoggerFactory.getLogger(BaseEppoClient.class); + private final ObjectMapper mapper = + new ObjectMapper() + .registerModule(EppoModule.eppoModule()); // TODO: is this the best place for this? + + protected final ConfigurationRequestor requestor; + + private final IConfigurationStore configurationStore; + private final AssignmentLogger assignmentLogger; + private final BanditLogger banditLogger; + private final String sdkName; + private final String sdkVersion; + private boolean isGracefulMode; + private final IAssignmentCache assignmentCache; + private final IAssignmentCache banditAssignmentCache; + private Timer pollTimer; + + @Nullable protected CompletableFuture getInitialConfigFuture() { + return initialConfigFuture; + } + + private final CompletableFuture initialConfigFuture; + + // Fields useful for testing in situations where we want to mock the http client or configuration + // store (accessed via reflection) + /** @noinspection FieldMayBeFinal */ + private static EppoHttpClient httpClientOverride = null; + + // It is important that the bandit assignment cache expire with a short-enough TTL to last about + // one user session. + // The recommended is 10 minutes (per @Sven) + /** @param host To be removed in v4. use `apiBaseUrl` instead. */ + protected BaseEppoClient( + @NotNull String apiKey, + @NotNull String sdkName, + @NotNull String sdkVersion, + @Deprecated @Nullable String host, + @Nullable String apiBaseUrl, + @Nullable AssignmentLogger assignmentLogger, + @Nullable BanditLogger banditLogger, + @Nullable IConfigurationStore configurationStore, + boolean isGracefulMode, + boolean expectObfuscatedConfig, + boolean supportBandits, + @Nullable CompletableFuture initialConfiguration, + @Nullable IAssignmentCache assignmentCache, + @Nullable IAssignmentCache banditAssignmentCache) { + + if (apiBaseUrl == null) { + apiBaseUrl = host != null ? Constants.appendApiPathToHost(host) : Constants.DEFAULT_BASE_URL; + } + + this.assignmentCache = assignmentCache; + this.banditAssignmentCache = banditAssignmentCache; + + EppoHttpClient httpClient = + buildHttpClient(apiBaseUrl, new SDKKey(apiKey), sdkName, sdkVersion); + this.configurationStore = + configurationStore != null ? configurationStore : new ConfigurationStore(); + + // For now, the configuration is only obfuscated for Android clients + requestor = + new ConfigurationRequestor( + this.configurationStore, httpClient, expectObfuscatedConfig, supportBandits); + initialConfigFuture = + initialConfiguration != null + ? requestor.setInitialConfiguration(initialConfiguration) + : null; + + this.assignmentLogger = assignmentLogger; + this.banditLogger = banditLogger; + this.isGracefulMode = isGracefulMode; + // Save SDK name and version to include in logger metadata + this.sdkName = sdkName; + this.sdkVersion = sdkVersion; + } + + private EppoHttpClient buildHttpClient( + String apiBaseUrl, SDKKey sdkKey, String sdkName, String sdkVersion) { + ApiEndpoints endpointHelper = new ApiEndpoints(sdkKey, apiBaseUrl); + + return httpClientOverride != null + ? httpClientOverride + : new EppoHttpClient(endpointHelper.getBaseUrl(), sdkKey.getToken(), sdkName, sdkVersion); + } + + protected void loadConfiguration() { + try { + requestor.fetchAndSaveFromRemote(); + } catch (Exception ex) { + log.error("Encountered Exception while loading configuration", ex); + if (!isGracefulMode) { + throw ex; + } + } + } + + protected void stopPolling() { + if (pollTimer != null) { + pollTimer.cancel(); + } + } + + /** Start polling using the default interval and jitter. */ + protected void startPolling() { + startPolling(DEFAULT_POLLING_INTERVAL_MILLIS); + } + + /** + * Start polling using the provided polling interval and default jitter of 10% + * + * @param pollingIntervalMs The base number of milliseconds to wait between configuration fetches. + */ + protected void startPolling(long pollingIntervalMs) { + startPolling(pollingIntervalMs, pollingIntervalMs / DEFAULT_JITTER_INTERVAL_RATIO); + } + + /** + * Start polling using the provided interval and jitter. + * + * @param pollingIntervalMs The base number of milliseconds to wait between configuration fetches. + * @param pollingJitterMs The max number of milliseconds to offset each polling interval. The SDK + * selects a random number between 0 and pollingJitterMS to offset the polling interval by. + */ + protected void startPolling(long pollingIntervalMs, long pollingJitterMs) { + stopPolling(); + log.debug("Started polling at " + pollingIntervalMs + "," + pollingJitterMs); + + // Set up polling for UFC + pollTimer = new Timer(true); + FetchConfigurationTask fetchConfigurationsTask = + new FetchConfigurationTask( + () -> { + log.debug("[Eppo SDK] Polling callback"); + this.loadConfiguration(); + }, + pollTimer, + pollingIntervalMs, + pollingJitterMs); + + // We don't want to fetch right away, so we schedule the next fetch. + // Graceful mode is implicit here because `FetchConfigurationsTask` catches and + // logs errors without rethrowing. + fetchConfigurationsTask.scheduleNext(); + } + + protected CompletableFuture loadConfigurationAsync() { + CompletableFuture future = new CompletableFuture<>(); + + requestor + .fetchAndSaveFromRemoteAsync() + .exceptionally( + ex -> { + log.error("Encountered Exception while loading configuration", ex); + if (!isGracefulMode) { + future.completeExceptionally(ex); + } + return null; + }) + .thenAccept(future::complete); + + return future; + } + + /** + * Core evaluation method that handles validation, evaluation, and logging. This consolidates the + * shared logic between all assignment methods. Returns evaluation details with variationValue set + * to the result. + */ + protected EvaluationDetails evaluateAndLog( + String flagKey, + String subjectKey, + Attributes subjectAttributes, + VariationType expectedType) { + + throwIfEmptyOrNull(flagKey, "flagKey must not be empty"); + throwIfEmptyOrNull(subjectKey, "subjectKey must not be empty"); + + Configuration config = getConfiguration(); + + // Check if flag exists + FlagConfig flag = config.getFlag(flagKey); + if (flag == null) { + log.warn("no configuration found for key: {}", flagKey); + return buildDefaultEvaluationDetails( + FlagEvaluationCode.FLAG_UNRECOGNIZED_OR_DISABLED, + "Unrecognized or disabled flag: " + flagKey, + null); + } + + // Check if flag is enabled + if (!flag.isEnabled()) { + log.info( + "no assigned variation because the experiment or feature flag is disabled: {}", flagKey); + return buildDefaultEvaluationDetails( + FlagEvaluationCode.FLAG_UNRECOGNIZED_OR_DISABLED, + "Unrecognized or disabled flag: " + flagKey, + null); + } + + // Check if flag type matches expected type + if (flag.getVariationType() != expectedType) { + log.warn( + "no assigned variation because the flag type doesn't match the requested type: {} has type {}, requested {}", + flagKey, + flag.getVariationType(), + expectedType); + return buildDefaultEvaluationDetails( + FlagEvaluationCode.TYPE_MISMATCH, + String.format( + "Flag \"%s\" has type %s, requested %s", + flagKey, flag.getVariationType(), expectedType), + null); + } + + // Evaluate flag with details + DetailedFlagEvaluationResult detailedResult = + FlagEvaluator.evaluateFlagWithDetails( + flag, flagKey, subjectKey, subjectAttributes, config.isConfigObfuscated()); + EvaluationDetails evaluationDetails = detailedResult.getEvaluationDetails(); + + EppoValue assignedValue = + detailedResult.getVariation() != null ? detailedResult.getVariation().getValue() : null; + + // Check if value type matches expected + if (assignedValue != null && !valueTypeMatchesExpected(expectedType, assignedValue)) { + log.warn( + "no assigned variation because the flag type doesn't match the variation type: {} has type {}, variation value is {}", + flagKey, + flag.getVariationType(), + assignedValue); + + // Update evaluation details with error code but keep the matched allocation and variation + // info + // Set variationValue to null since no valid variation can be assigned + String variationKey = + detailedResult.getVariation() != null ? detailedResult.getVariation().getKey() : null; + String errorDescription = + String.format( + "Variation (%s) is configured for type %s, but is set to incompatible value (%s)", + variationKey, expectedType, assignedValue.doubleValue()); + + return new EvaluationDetails( + evaluationDetails.getEnvironmentName(), + FlagEvaluationCode.ASSIGNMENT_ERROR, // We use ASSIGNMENT_ERROR for value mismatch as it's a misconfiguration of the flag itself + errorDescription, + evaluationDetails.getBanditKey(), + evaluationDetails.getBanditAction(), + variationKey, + assignedValue, + evaluationDetails.getMatchedRule(), + evaluationDetails.getMatchedAllocation(), + evaluationDetails.getUnmatchedAllocations(), + evaluationDetails.getUnevaluatedAllocations()); + } + + // Log assignment if applicable + if (assignedValue != null && assignmentLogger != null && detailedResult.doLog()) { + try { + String allocationKey = detailedResult.getAllocationKey(); + String experimentKey = + flagKey + + '-' + + allocationKey; // Our experiment key is derived by hyphenating the flag key and + // allocation key + String variationKey = detailedResult.getVariation().getKey(); + Map extraLogging = detailedResult.getExtraLogging(); + Map metaData = buildLogMetaData(config.isConfigObfuscated()); + + Assignment assignment = + new Assignment( + experimentKey, + flagKey, + allocationKey, + variationKey, + subjectKey, + subjectAttributes, + extraLogging, + metaData); + + // Deduplication of assignment logging is possible by providing an `IAssignmentCache`. + // Default to true, only avoid logging if there's a cache hit. + boolean logAssignment = true; + AssignmentCacheEntry cacheEntry = AssignmentCacheEntry.fromVariationAssignment(assignment); + if (assignmentCache != null) { + logAssignment = assignmentCache.putIfAbsent(cacheEntry); + } + + if (logAssignment) { + assignmentLogger.logAssignment(assignment); + } + + } catch (Exception e) { + log.error("Error logging assignment: {}", e.getMessage(), e); + } + } + + return evaluationDetails; + } + + private boolean valueTypeMatchesExpected(VariationType expectedType, EppoValue value) { + boolean typeMatch; + switch (expectedType) { + case BOOLEAN: + typeMatch = value.isBoolean(); + break; + case INTEGER: + typeMatch = + value.isNumeric() + // Java has no isInteger check so we check using mod + && value.doubleValue() % 1 == 0.0; + break; + case NUMERIC: + typeMatch = value.isNumeric(); + break; + case STRING: + typeMatch = value.isString(); + break; + case JSON: + typeMatch = + value.isString() + // Eppo leaves JSON as a JSON string; to verify it's valid we attempt to parse + && parseJsonString(value.stringValue()) != null; + break; + default: + throw new IllegalArgumentException("Unexpected type for type checking: " + expectedType); + } + + return typeMatch; + } + + public boolean getBooleanAssignment(String flagKey, String subjectKey, boolean defaultValue) { + return this.getBooleanAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public boolean getBooleanAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, boolean defaultValue) { + try { + EvaluationDetails details = + evaluateAndLog( + flagKey, + subjectKey, + subjectAttributes, + EppoValue.valueOf(defaultValue), + VariationType.BOOLEAN); + EppoValue value = details.getVariationValue(); + return details.evaluationSuccessful() ? value.booleanValue() : defaultValue; + } catch (Exception e) { + return throwIfNotGraceful(e, defaultValue); + } + } + + public int getIntegerAssignment(String flagKey, String subjectKey, int defaultValue) { + return getIntegerAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public int getIntegerAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, int defaultValue) { + try { + EvaluationDetails details = + evaluateAndLog( + flagKey, + subjectKey, + subjectAttributes, + EppoValue.valueOf(defaultValue), + VariationType.INTEGER); + EppoValue value = details.getVariationValue(); + return details.evaluationSuccessful() ? (int) value.doubleValue() : defaultValue; + } catch (Exception e) { + return throwIfNotGraceful(e, defaultValue); + } + } + + public Double getDoubleAssignment(String flagKey, String subjectKey, double defaultValue) { + return getDoubleAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public Double getDoubleAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, double defaultValue) { + try { + EvaluationDetails details = + evaluateAndLog( + flagKey, + subjectKey, + subjectAttributes, + EppoValue.valueOf(defaultValue), + VariationType.NUMERIC); + EppoValue value = details.getVariationValue(); + return details.evaluationSuccessful() ? value.doubleValue() : defaultValue; + } catch (Exception e) { + return throwIfNotGraceful(e, defaultValue); + } + } + + public String getStringAssignment(String flagKey, String subjectKey, String defaultValue) { + return this.getStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public String getStringAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + try { + EvaluationDetails details = + evaluateAndLog( + flagKey, + subjectKey, + subjectAttributes, + EppoValue.valueOf(defaultValue), + VariationType.STRING); + EppoValue value = details.getVariationValue(); + return details.evaluationSuccessful() ? value.stringValue() : defaultValue; + } catch (Exception e) { + return throwIfNotGraceful(e, defaultValue); + } + } + + /** + * Returns the assignment for the provided feature flag key and subject key as a {@link JsonNode}. + * If the flag is not found, does not match the requested type or is disabled, defaultValue is + * returned. + * + * @param flagKey the feature flag key + * @param subjectKey the subject key + * @param defaultValue the default value to return if the flag is not found + * @return the JSON string value of the assignment + */ + public JsonNode getJSONAssignment(String flagKey, String subjectKey, JsonNode defaultValue) { + return getJSONAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + } + + /** + * Returns the assignment for the provided feature flag key and subject key as a {@link JsonNode}. + * If the flag is not found, does not match the requested type or is disabled, defaultValue is + * returned. + * + * @param flagKey the feature flag key + * @param subjectKey the subject key + * @param defaultValue the default value to return if the flag is not found + * @return the JSON string value of the assignment + */ + public JsonNode getJSONAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { + try { + EvaluationDetails details = + evaluateAndLog( + flagKey, + subjectKey, + subjectAttributes, + EppoValue.valueOf(defaultValue.toString()), + VariationType.JSON); + EppoValue value = details.getVariationValue(); + if (details.evaluationSuccessful()) { + String stringValue = value.stringValue(); + return parseJsonString(stringValue); + } else { + return defaultValue; + } + } catch (Exception e) { + return throwIfNotGraceful(e, defaultValue); + } + } + + /** + * Returns the assignment for the provided feature flag key, subject key and subject attributes as + * a JSON string. If the flag is not found, does not match the requested type or is disabled, + * defaultValue is returned. + * + * @param flagKey the feature flag key + * @param subjectKey the subject key + * @param defaultValue the default value to return if the flag is not found + * @return the JSON string value of the assignment + */ + public String getJSONStringAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + try { + EvaluationDetails details = + evaluateAndLog( + flagKey, + subjectKey, + subjectAttributes, + EppoValue.valueOf(defaultValue), + VariationType.JSON); + EppoValue value = details.getVariationValue(); + return details.evaluationSuccessful() ? value.stringValue() : defaultValue; + } catch (Exception e) { + return throwIfNotGraceful(e, defaultValue); + } + } + + /** + * Returns the assignment for the provided feature flag key and subject key as a JSON String. If + * the flag is not found, does not match the requested type or is disabled, defaultValue is + * returned. + * + * @param flagKey the feature flag key + * @param subjectKey the subject key + * @param defaultValue the default value to return if the flag is not found + * @return the JSON string value of the assignment + */ + public String getJSONStringAssignment(String flagKey, String subjectKey, String defaultValue) { + return this.getJSONStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + } + + private JsonNode parseJsonString(String jsonString) { + try { + return mapper.readTree(jsonString); + } catch (JsonProcessingException e) { + return null; + } + } + + /** Internal method to get assignment with detailed evaluation information. */ + protected AssignmentDetails getTypedAssignmentWithDetails( + String flagKey, + String subjectKey, + Attributes subjectAttributes, + T defaultValue, + VariationType expectedType) { + + // Convert default value to EppoValue for evaluation + EppoValue defaultEppoValue; + if (defaultValue == null) { + defaultEppoValue = EppoValue.nullValue(); + } else { + switch (expectedType) { + case BOOLEAN: + defaultEppoValue = EppoValue.valueOf((Boolean) defaultValue); + break; + case INTEGER: + // EppoValue doesn't have valueOf(int), so convert to double first + defaultEppoValue = EppoValue.valueOf(((Integer) defaultValue).doubleValue()); + break; + case NUMERIC: + defaultEppoValue = EppoValue.valueOf((Double) defaultValue); + break; + case STRING: + defaultEppoValue = EppoValue.valueOf((String) defaultValue); + break; + case JSON: + // Handle JsonNode by converting to string + if (defaultValue instanceof JsonNode) { + defaultEppoValue = EppoValue.valueOf(defaultValue.toString()); + } else { + defaultEppoValue = EppoValue.valueOf((String) defaultValue); + } + break; + default: + defaultEppoValue = EppoValue.nullValue(); + } + } + + EvaluationDetails details = + evaluateAndLog(flagKey, subjectKey, subjectAttributes, defaultEppoValue, expectedType); + + EppoValue value = details.getVariationValue(); + EppoValue valueToConvert = details.evaluationSuccessful() ? value : defaultEppoValue; + T resultValue = convertEppoValue(valueToConvert, expectedType, defaultValue); + return new AssignmentDetails<>(resultValue, null, details); + } + + @SuppressWarnings("unchecked") + private T convertEppoValue(EppoValue value, VariationType expectedType, T defaultValue) { + switch (expectedType) { + case BOOLEAN: + return (T) Boolean.valueOf(value.booleanValue()); + case INTEGER: + return (T) Integer.valueOf((int) value.doubleValue()); + case NUMERIC: + return (T) Double.valueOf(value.doubleValue()); + case STRING: + case JSON: + return (T) value.stringValue(); + default: + return defaultValue; + } + } + + private EvaluationDetails buildDefaultEvaluationDetails( + FlagEvaluationCode code, String description, EppoValue variationValue) { + return new EvaluationDetails( + "Test", // Default environment name + code, + description, + null, // banditKey + null, // banditAction + null, // variationKey + variationValue, + null, // matchedRule + null, // matchedAllocation + new ArrayList<>(), // unmatchedAllocations + new ArrayList<>()); // unevaluatedAllocations + } + + /** + * Returns assignment details for a boolean flag including comprehensive evaluation information. + */ + public AssignmentDetails getBooleanAssignmentDetails( + String flagKey, String subjectKey, boolean defaultValue) { + return this.getBooleanAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + } + + /** + * Returns assignment details for a boolean flag including comprehensive evaluation information. + */ + public AssignmentDetails getBooleanAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, boolean defaultValue) { + try { + return this.getTypedAssignmentWithDetails( + flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.BOOLEAN); + } catch (Exception e) { + return new AssignmentDetails<>( + throwIfNotGraceful(e, defaultValue), + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); + } + } + + /** + * Returns assignment details for an integer flag including comprehensive evaluation information. + */ + public AssignmentDetails getIntegerAssignmentDetails( + String flagKey, String subjectKey, int defaultValue) { + return getIntegerAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + } + + /** + * Returns assignment details for an integer flag including comprehensive evaluation information. + */ + public AssignmentDetails getIntegerAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, int defaultValue) { + try { + return this.getTypedAssignmentWithDetails( + flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.INTEGER); + } catch (Exception e) { + return new AssignmentDetails<>( + throwIfNotGraceful(e, defaultValue), + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); + } + } + + /** + * Returns assignment details for a numeric (double) flag including comprehensive evaluation + * information. + */ + public AssignmentDetails getDoubleAssignmentDetails( + String flagKey, String subjectKey, double defaultValue) { + return getDoubleAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + } + + /** + * Returns assignment details for a numeric (double) flag including comprehensive evaluation + * information. + */ + public AssignmentDetails getDoubleAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, double defaultValue) { + try { + return this.getTypedAssignmentWithDetails( + flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.NUMERIC); + } catch (Exception e) { + return new AssignmentDetails<>( + throwIfNotGraceful(e, defaultValue), + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); + } + } + + /** + * Returns assignment details for a string flag including comprehensive evaluation information. + */ + public AssignmentDetails getStringAssignmentDetails( + String flagKey, String subjectKey, String defaultValue) { + return this.getStringAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + } + + /** + * Returns assignment details for a string flag including comprehensive evaluation information. + */ + public AssignmentDetails getStringAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + try { + return this.getTypedAssignmentWithDetails( + flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.STRING); + } catch (Exception e) { + return new AssignmentDetails<>( + throwIfNotGraceful(e, defaultValue), + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); + } + } + + /** + * Returns assignment details for a JSON flag including comprehensive evaluation information. The + * variation value is returned as a JsonNode. + */ + public AssignmentDetails getJSONAssignmentDetails( + String flagKey, String subjectKey, JsonNode defaultValue) { + return this.getJSONAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + } + + /** + * Returns assignment details for a JSON flag including comprehensive evaluation information. The + * variation value is returned as a JsonNode. + */ + public AssignmentDetails getJSONAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { + try { + // Get the string assignment details first + AssignmentDetails stringDetails = + this.getTypedAssignmentWithDetails( + flagKey, subjectKey, subjectAttributes, null, VariationType.JSON); + + String jsonString = stringDetails.getVariation(); + JsonNode resultValue = defaultValue; + if (jsonString != null) { + JsonNode parsed = parseJsonString(jsonString); + if (parsed != null) { + resultValue = parsed; + } + } + + return new AssignmentDetails<>( + resultValue, stringDetails.getAction(), stringDetails.getEvaluationDetails()); + } catch (Exception e) { + String defaultValueString = defaultValue != null ? defaultValue.toString() : null; + return new AssignmentDetails<>( + throwIfNotGraceful(e, defaultValue), + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValueString))); + } + } + + /** + * Returns assignment details for a JSON flag including comprehensive evaluation information. The + * variation value is returned as a JSON string. + */ + public AssignmentDetails getJSONStringAssignmentDetails( + String flagKey, String subjectKey, String defaultValue) { + return this.getJSONStringAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); + } + + /** + * Returns assignment details for a JSON flag including comprehensive evaluation information. The + * variation value is returned as a JSON string. + */ + public AssignmentDetails getJSONStringAssignmentDetails( + String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + try { + return this.getTypedAssignmentWithDetails( + flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.JSON); + } catch (Exception e) { + return new AssignmentDetails<>( + throwIfNotGraceful(e, defaultValue), + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); + } + } + + public BanditResult getBanditAction( + String flagKey, + String subjectKey, + DiscriminableAttributes subjectAttributes, + Actions actions, + String defaultValue) { + BanditResult result = new BanditResult(defaultValue, null); + final Configuration config = getConfiguration(); + try { + String assignedVariation = + getStringAssignment( + flagKey, subjectKey, subjectAttributes.getAllAttributes(), defaultValue); + + // Update result to reflect that we've been assigned a variation + result = new BanditResult(assignedVariation, null); + + String banditKey = config.banditKeyForVariation(flagKey, assignedVariation); + if (banditKey != null && !actions.isEmpty()) { + BanditParameters banditParameters = config.getBanditParameters(banditKey); + BanditEvaluationResult banditResult = + BanditEvaluator.evaluateBandit( + flagKey, subjectKey, subjectAttributes, actions, banditParameters.getModelData()); + + // Update result to reflect that we've been assigned an action + result = new BanditResult(assignedVariation, banditResult.getActionKey()); + + if (banditLogger != null) { + try { + BanditAssignment banditAssignment = + new BanditAssignment( + flagKey, + banditKey, + subjectKey, + banditResult.getActionKey(), + banditResult.getActionWeight(), + banditResult.getOptimalityGap(), + banditParameters.getModelVersion(), + subjectAttributes.getNumericAttributes(), + subjectAttributes.getCategoricalAttributes(), + banditResult.getActionAttributes().getNumericAttributes(), + banditResult.getActionAttributes().getCategoricalAttributes(), + buildLogMetaData(config.isConfigObfuscated())); + + // Log, only if there is no cache hit. + boolean logBanditAssignment = true; + AssignmentCacheEntry cacheEntry = + AssignmentCacheEntry.fromBanditAssignment(banditAssignment); + if (banditAssignmentCache != null) { + if (banditAssignmentCache.hasEntry(cacheEntry)) { + logBanditAssignment = false; + } + } + + if (logBanditAssignment) { + banditLogger.logBanditAssignment(banditAssignment); + + if (banditAssignmentCache != null) { + banditAssignmentCache.put(cacheEntry); + } + } + } catch (Exception e) { + log.warn("Error logging bandit assignment: {}", e.getMessage(), e); + } + } + } + return result; + } catch (Exception e) { + return throwIfNotGraceful(e, result); + } + } + + /** + * Returns bandit action assignment with detailed evaluation information including flag evaluation + * details and bandit action selection. + */ + public AssignmentDetails getBanditActionDetails( + String flagKey, + String subjectKey, + DiscriminableAttributes subjectAttributes, + Actions actions, + String defaultValue) { + final Configuration config = getConfiguration(); + try { + // Get detailed flag assignment + AssignmentDetails flagDetails = + getStringAssignmentDetails( + flagKey, subjectKey, subjectAttributes.getAllAttributes(), defaultValue); + + String assignedVariation = flagDetails.getVariation(); + String assignedAction = null; + + // If we got a variation, check for bandit + String banditKey = config.banditKeyForVariation(flagKey, assignedVariation); + if (banditKey != null && !actions.isEmpty()) { + try { + BanditParameters banditParameters = config.getBanditParameters(banditKey); + if (banditParameters == null) { + throw new RuntimeException("Bandit parameters not found for bandit key: " + banditKey); + } + BanditEvaluationResult banditResult = + BanditEvaluator.evaluateBandit( + flagKey, subjectKey, subjectAttributes, actions, banditParameters.getModelData()); + + assignedAction = banditResult.getActionKey(); + + // Log bandit assignment if needed + if (banditLogger != null) { + try { + BanditAssignment banditAssignment = + new BanditAssignment( + flagKey, + banditKey, + subjectKey, + banditResult.getActionKey(), + banditResult.getActionWeight(), + banditResult.getOptimalityGap(), + banditParameters.getModelVersion(), + subjectAttributes.getNumericAttributes(), + subjectAttributes.getCategoricalAttributes(), + banditResult.getActionAttributes().getNumericAttributes(), + banditResult.getActionAttributes().getCategoricalAttributes(), + buildLogMetaData(config.isConfigObfuscated())); + + boolean logBanditAssignment = true; + AssignmentCacheEntry cacheEntry = + AssignmentCacheEntry.fromBanditAssignment(banditAssignment); + if (banditAssignmentCache != null) { + if (banditAssignmentCache.hasEntry(cacheEntry)) { + logBanditAssignment = false; + } + } + + if (logBanditAssignment) { + banditLogger.logBanditAssignment(banditAssignment); + if (banditAssignmentCache != null) { + banditAssignmentCache.put(cacheEntry); + } + } + } catch (Exception e) { + log.warn("Error logging bandit assignment: {}", e.getMessage(), e); + } + } + + // Update evaluation details to include bandit information + EvaluationDetails updatedDetails = + new EvaluationDetails( + flagDetails.getEvaluationDetails().getEnvironmentName(), + flagDetails.getEvaluationDetails().getFlagEvaluationCode(), + flagDetails.getEvaluationDetails().getFlagEvaluationDescription(), + banditKey, + assignedAction, + flagDetails.getEvaluationDetails().getVariationKey(), + flagDetails.getEvaluationDetails().getVariationValue(), + flagDetails.getEvaluationDetails().getMatchedRule(), + flagDetails.getEvaluationDetails().getMatchedAllocation(), + flagDetails.getEvaluationDetails().getUnmatchedAllocations(), + flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); + + return new AssignmentDetails<>(assignedVariation, assignedAction, updatedDetails); + } catch (Exception banditError) { + // Bandit evaluation failed - return flag details with BANDIT_ERROR code + log.warn( + "Bandit evaluation failed for flag {}: {}", + flagKey, + banditError.getMessage(), + banditError); + EvaluationDetails banditErrorDetails = + new EvaluationDetails( + flagDetails.getEvaluationDetails().getEnvironmentName(), + FlagEvaluationCode.BANDIT_ERROR, + "Bandit evaluation failed: " + banditError.getMessage(), + banditKey, + null, // no action assigned due to error + flagDetails.getEvaluationDetails().getVariationKey(), + flagDetails.getEvaluationDetails().getVariationValue(), + flagDetails.getEvaluationDetails().getMatchedRule(), + flagDetails.getEvaluationDetails().getMatchedAllocation(), + flagDetails.getEvaluationDetails().getUnmatchedAllocations(), + flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); + return new AssignmentDetails<>(assignedVariation, null, banditErrorDetails); + } + } + + // No bandit - return flag details as-is + return flagDetails; + } catch (Exception e) { + BanditResult defaultResult = new BanditResult(defaultValue, null); + AssignmentDetails errorDetails = + new AssignmentDetails<>( + defaultValue, + null, + buildDefaultEvaluationDetails( + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); + return throwIfNotGraceful(e, errorDetails); + } + } + + private Map buildLogMetaData(boolean isConfigObfuscated) { + HashMap metaData = new HashMap<>(); + metaData.put("obfuscated", Boolean.valueOf(isConfigObfuscated).toString()); + metaData.put("sdkLanguage", sdkName); + metaData.put("sdkLibVersion", sdkVersion); + return metaData; + } + + private T throwIfNotGraceful(Exception e, T defaultValue) { + if (this.isGracefulMode) { + log.info("error getting assignment value: {}", e.getMessage()); + return defaultValue; + } + throw new RuntimeException(e); + } + + public void setIsGracefulFailureMode(boolean isGracefulFailureMode) { + this.isGracefulMode = isGracefulFailureMode; + } + + /** + * Subscribe to changes to the configuration. + * + * @param callback A function to be executed when the configuration changes. + * @return a Runnable which, when called unsubscribes the callback from configuration change + * events. + */ + public Runnable onConfigurationChange(Consumer callback) { + return requestor.onConfigurationChange(callback); + } + + /** + * Returns the configuration object used by the EppoClient for assignment and bandit evaluation. + * + *

The configuration object is for debugging (inspect the loaded config) and other advanced use + * cases where flag metadata or a list of flag keys, for example, is required. + * + *

It is not recommended to use the list of keys to preload assignments as assignment + * computation also logs its use which will affect your metrics. + * + * @see Where To + * Assign for more details. + */ + public Configuration getConfiguration() { + return configurationStore.getConfiguration(); + } +} diff --git a/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java b/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java new file mode 100644 index 00000000..9de27242 --- /dev/null +++ b/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java @@ -0,0 +1,179 @@ +package cloud.eppo; + +import cloud.eppo.api.*; +import cloud.eppo.ufc.dto.Variation; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Extended flag evaluation result that includes detailed evaluation information for debugging and + * understanding flag assignments. + */ +public class DetailedFlagEvaluationResult extends FlagEvaluationResult { + private final EvaluationDetails evaluationDetails; + + public DetailedFlagEvaluationResult( + String flagKey, + String subjectKey, + Attributes subjectAttributes, + String allocationKey, + Variation variation, + Map extraLogging, + boolean doLog, + EvaluationDetails evaluationDetails) { + super(flagKey, subjectKey, subjectAttributes, allocationKey, variation, extraLogging, doLog); + this.evaluationDetails = evaluationDetails; + } + + public EvaluationDetails getEvaluationDetails() { + return evaluationDetails; + } + + /** Builder to construct detailed evaluation results during flag evaluation. */ + public static class Builder { + private String flagKey; + private String subjectKey; + private Attributes subjectAttributes; + private String allocationKey; + private Variation variation; + private Map extraLogging; + private boolean doLog; + + // Evaluation details fields + private String environmentName = "Test"; // Default for now + private FlagEvaluationCode flagEvaluationCode; + private String flagEvaluationDescription; + private String banditKey; + private String banditAction; + private MatchedRule matchedRule; + private AllocationDetails matchedAllocation; + private final List unmatchedAllocations = new ArrayList<>(); + private final List unevaluatedAllocations = new ArrayList<>(); + + public Builder flagKey(String flagKey) { + this.flagKey = flagKey; + return this; + } + + public Builder subjectKey(String subjectKey) { + this.subjectKey = subjectKey; + return this; + } + + public Builder subjectAttributes(Attributes subjectAttributes) { + this.subjectAttributes = subjectAttributes; + return this; + } + + public Builder allocationKey(String allocationKey) { + this.allocationKey = allocationKey; + return this; + } + + public Builder variation(Variation variation) { + this.variation = variation; + return this; + } + + public Builder extraLogging(Map extraLogging) { + this.extraLogging = extraLogging; + return this; + } + + public Builder doLog(boolean doLog) { + this.doLog = doLog; + return this; + } + + public Builder environmentName(String environmentName) { + this.environmentName = environmentName; + return this; + } + + public Builder flagEvaluationCode(FlagEvaluationCode code) { + this.flagEvaluationCode = code; + return this; + } + + public Builder flagEvaluationDescription(String description) { + this.flagEvaluationDescription = description; + return this; + } + + public Builder banditKey(String banditKey) { + this.banditKey = banditKey; + return this; + } + + public Builder banditAction(String banditAction) { + this.banditAction = banditAction; + return this; + } + + public Builder matchedRule(MatchedRule matchedRule) { + this.matchedRule = matchedRule; + return this; + } + + public Builder matchedAllocation(AllocationDetails matchedAllocation) { + this.matchedAllocation = matchedAllocation; + return this; + } + + public Builder addUnmatchedAllocation(AllocationDetails allocation) { + this.unmatchedAllocations.add(allocation); + return this; + } + + public Builder addUnevaluatedAllocation(AllocationDetails allocation) { + this.unevaluatedAllocations.add(allocation); + return this; + } + + public DetailedFlagEvaluationResult build() { + // Build evaluation details + String variationKey = variation != null ? variation.getKey() : null; + EppoValue variationValue = variation != null ? variation.getValue() : null; + + EvaluationDetails details = + new EvaluationDetails( + environmentName, + flagEvaluationCode, + flagEvaluationDescription, + banditKey, + banditAction, + variationKey, + variationValue, + matchedRule, + matchedAllocation, + new ArrayList<>(unmatchedAllocations), + new ArrayList<>(unevaluatedAllocations)); + + return new DetailedFlagEvaluationResult( + flagKey, + subjectKey, + subjectAttributes, + allocationKey, + variation, + extraLogging, + doLog, + details); + } + + private static Object getEppoValueAsObject(EppoValue value) { + if (value.isNull()) { + return null; + } else if (value.isBoolean()) { + return value.booleanValue(); + } else if (value.isNumeric()) { + return value.doubleValue(); + } else if (value.isString()) { + return value.stringValue(); + } else if (value.isStringArray()) { + return value.stringArrayValue(); + } + return null; + } + } +} diff --git a/src/main/java/cloud/eppo/FlagEvaluator.java b/src/main/java/cloud/eppo/FlagEvaluator.java index 0a2e78f7..1bfd6279 100644 --- a/src/main/java/cloud/eppo/FlagEvaluator.java +++ b/src/main/java/cloud/eppo/FlagEvaluator.java @@ -1,25 +1,77 @@ package cloud.eppo; import static cloud.eppo.Utils.base64Decode; +import static cloud.eppo.Utils.getMD5Hex; import static cloud.eppo.Utils.getShard; +import cloud.eppo.api.AllocationDetails; +import cloud.eppo.api.AllocationEvaluationCode; import cloud.eppo.api.Attributes; import cloud.eppo.api.EppoValue; +import cloud.eppo.api.FlagEvaluationCode; +import cloud.eppo.api.MatchedRule; +import cloud.eppo.api.RuleCondition; import cloud.eppo.model.ShardRange; import cloud.eppo.ufc.dto.Allocation; import cloud.eppo.ufc.dto.FlagConfig; +import cloud.eppo.ufc.dto.OperatorType; import cloud.eppo.ufc.dto.Shard; import cloud.eppo.ufc.dto.Split; +import cloud.eppo.ufc.dto.TargetingRule; import cloud.eppo.ufc.dto.Variation; +import java.util.ArrayList; +import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; public class FlagEvaluator { - public static FlagEvaluationResult evaluateFlag( + private static boolean allShardsMatch( + Split split, String subjectKey, int totalShards, boolean isObfuscated) { + if (split.getShards() == null || split.getShards().isEmpty()) { + // Default to matching if no explicit shards + return true; + } + + for (Shard shard : split.getShards()) { + if (!matchesShard(shard, subjectKey, totalShards, isObfuscated)) { + return false; + } + } + + // If here, matchesShard() was true for each shard + return true; + } + + private static boolean matchesShard( + Shard shard, String subjectKey, int totalShards, boolean isObfuscated) { + String salt = shard.getSalt(); + if (isObfuscated) { + salt = base64Decode(salt); + } + String hashKey = salt + "-" + subjectKey; + int assignedShard = getShard(hashKey, totalShards); + for (ShardRange range : shard.getRanges()) { + if (assignedShard >= range.getStart() && assignedShard < range.getEnd()) { + return true; + } + } + + // If here, the shard was not in any of the shard's ranges + return false; + } + + /** + * Evaluates a flag and returns detailed evaluation information including allocation statuses, + * matched rules, and evaluation codes. This is useful for debugging and understanding why a + * particular variation was assigned. + */ + public static DetailedFlagEvaluationResult evaluateFlagWithDetails( FlagConfig flag, String flagKey, String subjectKey, @@ -27,72 +79,122 @@ public static FlagEvaluationResult evaluateFlag( boolean isConfigObfuscated) { Date now = new Date(); - Variation variation = null; - String allocationKey = null; - Map extraLogging = new HashMap<>(); - boolean doLog = false; + DetailedFlagEvaluationResult.Builder builder = + new DetailedFlagEvaluationResult.Builder() + .flagKey(flagKey) + .subjectKey(subjectKey) + .subjectAttributes(subjectAttributes) + .extraLogging(new HashMap<>()); + + // Handle disabled flag + if (!flag.isEnabled()) { + builder + .doLog(false) + .flagEvaluationCode(FlagEvaluationCode.FLAG_UNRECOGNIZED_OR_DISABLED) + .flagEvaluationDescription("Unrecognized or disabled flag: " + flagKey); + + // All allocations are unevaluated for disabled flags + if (flag.getAllocations() != null) { + for (int i = 0; i < flag.getAllocations().size(); i++) { + Allocation allocation = flag.getAllocations().get(i); + String allocationKey = + isConfigObfuscated ? base64Decode(allocation.getKey()) : allocation.getKey(); + builder.addUnevaluatedAllocation( + new AllocationDetails(allocationKey, AllocationEvaluationCode.UNEVALUATED, i + 1)); + } + } + + return builder.build(); + } - // If flag is disabled; use an empty list of allocations so that the empty result is returned - // Note: this is a safety check; disabled flags should be filtered upstream List allocationsToConsider = - flag.isEnabled() && flag.getAllocations() != null - ? flag.getAllocations() - : new LinkedList<>(); + flag.getAllocations() != null ? flag.getAllocations() : new LinkedList<>(); + + int allocationPosition = 0; + boolean foundMatch = false; for (Allocation allocation : allocationsToConsider) { + allocationPosition++; + String allocationKey = allocation.getKey(); + String deobfuscatedAllocationKey = + isConfigObfuscated ? base64Decode(allocationKey) : allocationKey; + + // Check if allocation is time-bound and not yet active if (allocation.getStartAt() != null && allocation.getStartAt().after(now)) { - // Allocation not yet active + builder.addUnmatchedAllocation( + new AllocationDetails( + deobfuscatedAllocationKey, + AllocationEvaluationCode.BEFORE_START_TIME, + allocationPosition)); continue; } + + // Check if allocation is time-bound and no longer active if (allocation.getEndAt() != null && allocation.getEndAt().before(now)) { - // Allocation no longer active + builder.addUnmatchedAllocation( + new AllocationDetails( + deobfuscatedAllocationKey, + AllocationEvaluationCode.AFTER_END_TIME, + allocationPosition)); continue; } - // For convenience, we will automatically include the subject key as the "id" attribute if - // none is provided + // For convenience, automatically include subject key as "id" attribute if not provided Attributes subjectAttributesToEvaluate = new Attributes(subjectAttributes); if (!subjectAttributesToEvaluate.containsKey("id")) { subjectAttributesToEvaluate.put("id", subjectKey); } - if (allocation.getRules() != null - && !allocation.getRules().isEmpty() - && RuleEvaluator.findMatchingRule( - subjectAttributesToEvaluate, allocation.getRules(), isConfigObfuscated) - == null) { - // Rules are defined, but none match - continue; + // Check rules + TargetingRule matchedTargetingRule = null; + if (allocation.getRules() != null && !allocation.getRules().isEmpty()) { + matchedTargetingRule = + RuleEvaluator.findMatchingRule( + subjectAttributesToEvaluate, allocation.getRules(), isConfigObfuscated); + + if (matchedTargetingRule == null) { + // Rules are defined but none match + builder.addUnmatchedAllocation( + new AllocationDetails( + deobfuscatedAllocationKey, + AllocationEvaluationCode.FAILING_RULE, + allocationPosition)); + continue; + } } - // This allocation has matched; find variation + // This allocation has matched rules; find variation in splits + Variation variation = null; + Map extraLogging = new HashMap<>(); + Split matchedSplit = null; + for (Split split : allocation.getSplits()) { if (allShardsMatch(split, subjectKey, flag.getTotalShards(), isConfigObfuscated)) { - // Variation and extra logging is determined by the relevant split variation = flag.getVariations().get(split.getVariationKey()); if (variation == null) { throw new RuntimeException("Unknown split variation key: " + split.getVariationKey()); } extraLogging = split.getExtraLogging(); + matchedSplit = split; break; } } - if (variation != null) { - // We only evaluate the first relevant allocation - allocationKey = allocation.getKey(); - // doLog is determined by the allocation - doLog = allocation.doLog(); - break; + if (variation == null) { + // Rules matched but subject doesn't fall in traffic split + builder.addUnmatchedAllocation( + new AllocationDetails( + deobfuscatedAllocationKey, + AllocationEvaluationCode.TRAFFIC_EXPOSURE_MISS, + allocationPosition)); + continue; } - } - if (isConfigObfuscated) { - // Need to unobfuscate for the returned evaluation result - if (allocationKey != null) { - allocationKey = base64Decode(allocationKey); - } - if (variation != null) { + foundMatch = true; + + // Deobfuscate if needed + if (isConfigObfuscated) { + allocationKey = deobfuscatedAllocationKey; String key = base64Decode(variation.getKey()); EppoValue decodedValue = EppoValue.nullValue(); if (!variation.getValue().isNull()) { @@ -116,61 +218,278 @@ public static FlagEvaluationResult evaluateFlag( } } variation = new Variation(key, decodedValue); + + // Deobfuscate extraLogging if present + if (extraLogging != null && !extraLogging.isEmpty()) { + Map deobfuscatedExtraLogging = new HashMap<>(); + for (Map.Entry entry : extraLogging.entrySet()) { + try { + String deobfuscatedKey = base64Decode(entry.getKey()); + String deobfuscatedValue = base64Decode(entry.getValue()); + deobfuscatedExtraLogging.put(deobfuscatedKey, deobfuscatedValue); + } catch (Exception e) { + deobfuscatedExtraLogging.put(entry.getKey(), entry.getValue()); + } + } + extraLogging = deobfuscatedExtraLogging; + } } - // Deobfuscate extraLogging if present - if (extraLogging != null && !extraLogging.isEmpty()) { - Map deobfuscatedExtraLogging = new HashMap<>(); - for (Map.Entry entry : extraLogging.entrySet()) { - try { - String deobfuscatedKey = base64Decode(entry.getKey()); - String deobfuscatedValue = base64Decode(entry.getValue()); - deobfuscatedExtraLogging.put(deobfuscatedKey, deobfuscatedValue); - } catch (Exception e) { - // If deobfuscation fails, keep the original key-value pair - deobfuscatedExtraLogging.put(entry.getKey(), entry.getValue()); + // Build matched rule details if applicable + MatchedRule matchedRule = null; + if (matchedTargetingRule != null) { + // Build reverse lookup map for deobfuscating ONE_OF values + Map md5ToOriginalValue = null; + if (isConfigObfuscated) { + md5ToOriginalValue = new HashMap<>(); + for (Map.Entry entry : subjectAttributesToEvaluate.entrySet()) { + if (entry.getValue() != null && !entry.getValue().isNull()) { + // Hash the attribute value (as string) to create reverse lookup + String valueAsString = castAttributeValueToString(entry.getValue()); + if (valueAsString != null) { + md5ToOriginalValue.put(getMD5Hex(valueAsString), valueAsString); + } + } } } - extraLogging = deobfuscatedExtraLogging; + + final Map finalMd5ToOriginalValue = md5ToOriginalValue; + Set conditions = + matchedTargetingRule.getConditions().stream() + .map( + tc -> { + // Deobfuscate attribute name if config is obfuscated + String attribute = tc.getAttribute(); + if (isConfigObfuscated) { + // Find the original attribute name by matching the MD5 hash + for (Map.Entry entry : + subjectAttributesToEvaluate.entrySet()) { + if (getMD5Hex(entry.getKey()).equals(attribute)) { + attribute = entry.getKey(); + break; + } + } + } + + // Deobfuscate the condition value if needed + EppoValue value = tc.getValue(); + if (isConfigObfuscated && value != null) { + Object deobfuscatedValue = + deobfuscateConditionValue( + tc.getValue(), tc.getOperator(), finalMd5ToOriginalValue); + // Convert deobfuscated Object back to EppoValue + value = objectToEppoValue(deobfuscatedValue); + } + + return new RuleCondition(attribute, tc.getOperator().value, value); + }) + .collect(Collectors.toSet()); + matchedRule = new MatchedRule(conditions); + } + + // Determine evaluation description + String description; + if (matchedRule != null) { + // Check if we need to include traffic assignment details + // Include traffic details if there are multiple splits OR multiple shards + boolean hasMultipleSplits = allocation.getSplits().size() > 1; + boolean hasMultipleShards = + matchedSplit.getShards() != null && matchedSplit.getShards().size() > 1; + + if (hasMultipleSplits || hasMultipleShards) { + description = + String.format( + "Supplied attributes match rules defined in allocation \"%s\" and %s belongs to the range of traffic assigned to \"%s\".", + allocationKey, subjectKey, variation.getKey()); + } else { + description = + String.format( + "Supplied attributes match rules defined in allocation \"%s\".", allocationKey); + } + } else { + description = + String.format( + "%s belongs to the range of traffic assigned to \"%s\" defined in allocation \"%s\".", + subjectKey, variation.getKey(), allocationKey); } + + // TODO check type + + builder + .allocationKey(allocationKey) + .variation(variation) + .extraLogging(extraLogging) + .doLog(allocation.doLog()) + .flagEvaluationCode(FlagEvaluationCode.MATCH) + .flagEvaluationDescription(description) + .matchedRule(matchedRule) + .matchedAllocation( + new AllocationDetails( + allocationKey, AllocationEvaluationCode.MATCH, allocationPosition)); + + // Mark remaining allocations as unevaluated + for (int i = allocationPosition; i < allocationsToConsider.size(); i++) { + Allocation unevaluatedAllocation = allocationsToConsider.get(i); + String unevaluatedKey = + isConfigObfuscated + ? base64Decode(unevaluatedAllocation.getKey()) + : unevaluatedAllocation.getKey(); + builder.addUnevaluatedAllocation( + new AllocationDetails(unevaluatedKey, AllocationEvaluationCode.UNEVALUATED, i + 2)); + } + + break; } - return new FlagEvaluationResult( - flagKey, subjectKey, subjectAttributes, allocationKey, variation, extraLogging, doLog); + // If no match was found, return default with appropriate code + if (!foundMatch) { + builder + .doLog(false) + .flagEvaluationCode(FlagEvaluationCode.DEFAULT_ALLOCATION_NULL) + .flagEvaluationDescription( + "No allocations matched. Falling back to \"Default Allocation\", serving NULL"); + } + + return builder.build(); } - private static boolean allShardsMatch( - Split split, String subjectKey, int totalShards, boolean isObfuscated) { - if (split.getShards() == null || split.getShards().isEmpty()) { - // Default to matching if no explicit shards - return true; + private static Object getEppoValueAsObject(EppoValue value) { + if (value.isNull()) { + return null; + } else if (value.isBoolean()) { + return value.booleanValue(); + } else if (value.isNumeric()) { + return value.doubleValue(); + } else if (value.isString()) { + return value.stringValue(); + } else if (value.isStringArray()) { + return value.stringArrayValue(); } + return null; + } - for (Shard shard : split.getShards()) { - if (!matchesShard(shard, subjectKey, totalShards, isObfuscated)) { - return false; - } + /** + * Deobfuscates a condition value based on the operator type. Different operators obfuscate values + * differently: - IS_NULL: MD5 hash of "true" or "false" - Inequality operators (GTE, GT, LTE, + * LT): base64 encoded numbers or semver strings - MATCHES, NOT_MATCHES: base64 encoded regex + * patterns - ONE_OF, NOT_ONE_OF: array of MD5 hashes that need to be reverse-looked up + */ + private static Object deobfuscateConditionValue( + EppoValue value, OperatorType operator, Map md5ToOriginalValue) { + if (value.isNull()) { + return null; } - // If here, matchesShard() was true for each shard - return true; - } + switch (operator) { + case IS_NULL: + // Check if it's MD5 of "true" or "false" + if (value.isString()) { + String hash = value.stringValue(); + if (getMD5Hex("true").equals(hash)) { + return true; + } else if (getMD5Hex("false").equals(hash)) { + return false; + } + } + return value.booleanValue(); - private static boolean matchesShard( - Shard shard, String subjectKey, int totalShards, boolean isObfuscated) { - String salt = shard.getSalt(); - if (isObfuscated) { - salt = base64Decode(salt); + case GREATER_THAN_OR_EQUAL_TO: + case GREATER_THAN: + case LESS_THAN_OR_EQUAL_TO: + case LESS_THAN: + // Decode base64 encoded numeric or semver values + if (value.isString()) { + try { + String decoded = base64Decode(value.stringValue()); + // Try to parse as number first + try { + return Double.parseDouble(decoded); + } catch (NumberFormatException e) { + // Return as string (likely a semver) + return decoded; + } + } catch (Exception e) { + // If decode fails, return original + return value.stringValue(); + } + } + return getEppoValueAsObject(value); + + case MATCHES: + case NOT_MATCHES: + // Decode base64 encoded regex patterns + if (value.isString()) { + try { + return base64Decode(value.stringValue()); + } catch (Exception e) { + return value.stringValue(); + } + } + return value.stringValue(); + + case ONE_OF: + case NOT_ONE_OF: + // Array values are MD5 hashes - try to reverse them using the subject attributes + if (value.isStringArray() && md5ToOriginalValue != null) { + List deobfuscatedValues = new ArrayList<>(); + for (String hash : value.stringArrayValue()) { + String originalValue = md5ToOriginalValue.get(hash); + if (originalValue != null) { + deobfuscatedValues.add(originalValue); + } else { + // Keep the hash if we can't reverse it + deobfuscatedValues.add(hash); + } + } + return deobfuscatedValues; + } + return getEppoValueAsObject(value); + + default: + return getEppoValueAsObject(value); } - String hashKey = salt + "-" + subjectKey; - int assignedShard = getShard(hashKey, totalShards); - for (ShardRange range : shard.getRanges()) { - if (assignedShard >= range.getStart() && assignedShard < range.getEnd()) { - return true; - } + } + + /** + * Casts an EppoValue to a string representation for use in hash lookups. Uses the same logic as + * RuleEvaluator.castAttributeForListComparison() + */ + private static String castAttributeValueToString(EppoValue attributeValue) { + if (attributeValue.isBoolean()) { + return Boolean.valueOf(attributeValue.booleanValue()).toString(); + } else if (attributeValue.isNumeric()) { + double doubleValue = attributeValue.doubleValue(); + int intValue = (int) attributeValue.doubleValue(); + return doubleValue == intValue ? String.valueOf(intValue) : String.valueOf(doubleValue); + } else if (attributeValue.isString()) { + return attributeValue.stringValue(); + } else if (attributeValue.isStringArray()) { + return Collections.singletonList(attributeValue.stringArrayValue()).toString(); + } else if (attributeValue.isNull()) { + return ""; + } else { + return null; } + } - // If here, the shard was not in any of the shard's ranges - return false; + /** Converts an Object back to EppoValue after deobfuscation. */ + private static EppoValue objectToEppoValue(Object value) { + if (value == null) { + return EppoValue.nullValue(); + } else if (value instanceof Boolean) { + return EppoValue.valueOf((Boolean) value); + } else if (value instanceof Double) { + return EppoValue.valueOf((Double) value); + } else if (value instanceof Integer) { + return EppoValue.valueOf(((Integer) value).doubleValue()); + } else if (value instanceof String) { + return EppoValue.valueOf((String) value); + } else if (value instanceof List) { + @SuppressWarnings("unchecked") + List list = (List) value; + return EppoValue.valueOf(list); + } else { + return EppoValue.valueOf(value.toString()); + } } } diff --git a/src/main/java/cloud/eppo/api/AllocationDetails.java b/src/main/java/cloud/eppo/api/AllocationDetails.java new file mode 100644 index 00000000..b7bb8b98 --- /dev/null +++ b/src/main/java/cloud/eppo/api/AllocationDetails.java @@ -0,0 +1,30 @@ +package cloud.eppo.api; + +/** + * Details about an allocation evaluation, including its key, evaluation status, and position in the + * allocation list. + */ +public class AllocationDetails { + private final String key; + private final AllocationEvaluationCode allocationEvaluationCode; + private final int orderPosition; + + public AllocationDetails( + String key, AllocationEvaluationCode allocationEvaluationCode, int orderPosition) { + this.key = key; + this.allocationEvaluationCode = allocationEvaluationCode; + this.orderPosition = orderPosition; + } + + public String getKey() { + return key; + } + + public AllocationEvaluationCode getAllocationEvaluationCode() { + return allocationEvaluationCode; + } + + public int getOrderPosition() { + return orderPosition; + } +} diff --git a/src/main/java/cloud/eppo/api/AllocationEvaluationCode.java b/src/main/java/cloud/eppo/api/AllocationEvaluationCode.java new file mode 100644 index 00000000..18880193 --- /dev/null +++ b/src/main/java/cloud/eppo/api/AllocationEvaluationCode.java @@ -0,0 +1,61 @@ +package cloud.eppo.api; + +/** + * Enum representing the result code of an allocation evaluation within a flag. + * + *

Allocations are evaluated in order, and this code indicates why an allocation was or was not + * selected. + */ +public enum AllocationEvaluationCode { + /** Allocation rules matched and the allocation was selected. */ + MATCH("MATCH"), + + /** Allocation rules did not match the subject attributes. */ + FAILING_RULE("FAILING_RULE"), + + /** Current time is before the allocation's start time. */ + BEFORE_START_TIME("BEFORE_START_TIME"), + + /** Current time is after the allocation's end time. */ + AFTER_END_TIME("AFTER_END_TIME"), + + /** Subject was not selected due to traffic exposure percentage. */ + TRAFFIC_EXPOSURE_MISS("TRAFFIC_EXPOSURE_MISS"), + + /** Allocation was not evaluated (e.g., a previous allocation matched). */ + UNEVALUATED("UNEVALUATED"); + + private final String code; + + AllocationEvaluationCode(String code) { + this.code = code; + } + + /** Returns the string representation of this allocation evaluation code. */ + public String getCode() { + return code; + } + + /** + * Parses a string code into an AllocationEvaluationCode enum. + * + * @param code the string code to parse + * @return the corresponding AllocationEvaluationCode, or null if not recognized + */ + public static AllocationEvaluationCode fromString(String code) { + if (code == null) { + return null; + } + for (AllocationEvaluationCode evaluationCode : values()) { + if (evaluationCode.code.equals(code)) { + return evaluationCode; + } + } + return null; + } + + @Override + public String toString() { + return code; + } +} diff --git a/src/main/java/cloud/eppo/api/AssignmentDetails.java b/src/main/java/cloud/eppo/api/AssignmentDetails.java new file mode 100644 index 00000000..5ad296b5 --- /dev/null +++ b/src/main/java/cloud/eppo/api/AssignmentDetails.java @@ -0,0 +1,31 @@ +package cloud.eppo.api; + +/** + * Contains both the assigned variation value and comprehensive evaluation details explaining why + * that variation was assigned. + * + * @param The type of the variation value (Boolean, Integer, Double, String, etc.) + */ +public class AssignmentDetails { + private final T variation; + private final String action; + private final EvaluationDetails evaluationDetails; + + public AssignmentDetails(T variation, String action, EvaluationDetails evaluationDetails) { + this.variation = variation; + this.action = action; + this.evaluationDetails = evaluationDetails; + } + + public T getVariation() { + return variation; + } + + public String getAction() { + return action; + } + + public EvaluationDetails getEvaluationDetails() { + return evaluationDetails; + } +} diff --git a/src/main/java/cloud/eppo/api/EppoValue.java b/src/main/java/cloud/eppo/api/EppoValue.java index aee8bae5..87c03fd3 100644 --- a/src/main/java/cloud/eppo/api/EppoValue.java +++ b/src/main/java/cloud/eppo/api/EppoValue.java @@ -1,6 +1,9 @@ package cloud.eppo.api; import cloud.eppo.ufc.dto.EppoValueType; +import cloud.eppo.ufc.dto.VariationType; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Iterator; import java.util.List; import java.util.Objects; @@ -96,6 +99,37 @@ public EppoValueType getType() { return type; } + /** + * Unwraps an EppoValue to the appropriate Java type based on the variation type. + * + * @param value the EppoValue to unwrap + * @param expectedType the expected variation type + * @param the target type + * @return the unwrapped value + */ + @SuppressWarnings("unchecked") + public static T unwrap(EppoValue value, VariationType expectedType) { + switch (expectedType) { + case BOOLEAN: + return (T) Boolean.valueOf(value.booleanValue()); + case INTEGER: + return (T) Integer.valueOf((int) value.doubleValue()); + case NUMERIC: + return (T) Double.valueOf(value.doubleValue()); + case STRING: + return (T) value.stringValue(); + case JSON: + String jsonString = value.stringValue(); + try { + ObjectMapper mapper = new ObjectMapper(); + return (T) mapper.readTree(jsonString); + } catch (JsonProcessingException e) { + return null; + } + } + throw new IllegalArgumentException("Unknown variation type: " + expectedType); + } + @Override public String toString() { switch (this.type) { diff --git a/src/main/java/cloud/eppo/api/EvaluationDetails.java b/src/main/java/cloud/eppo/api/EvaluationDetails.java new file mode 100644 index 00000000..00c54b32 --- /dev/null +++ b/src/main/java/cloud/eppo/api/EvaluationDetails.java @@ -0,0 +1,95 @@ +package cloud.eppo.api; + +import java.util.List; + +/** + * Contains comprehensive debugging information about a flag evaluation. This includes why a + * particular variation was assigned, which allocations matched or didn't match, and other metadata + * useful for understanding flag behavior. + */ +public class EvaluationDetails { + private final String environmentName; + private final FlagEvaluationCode flagEvaluationCode; + private final String flagEvaluationDescription; + private final String banditKey; + private final String banditAction; + private final String variationKey; + private final EppoValue variationValue; + private final MatchedRule matchedRule; + private final AllocationDetails matchedAllocation; + private final List unmatchedAllocations; + private final List unevaluatedAllocations; + + public EvaluationDetails( + String environmentName, + FlagEvaluationCode flagEvaluationCode, + String flagEvaluationDescription, + String banditKey, + String banditAction, + String variationKey, + EppoValue variationValue, + MatchedRule matchedRule, + AllocationDetails matchedAllocation, + List unmatchedAllocations, + List unevaluatedAllocations) { + this.environmentName = environmentName; + this.flagEvaluationCode = flagEvaluationCode; + this.flagEvaluationDescription = flagEvaluationDescription; + this.banditKey = banditKey; + this.banditAction = banditAction; + this.variationKey = variationKey; + this.variationValue = variationValue; + this.matchedRule = matchedRule; + this.matchedAllocation = matchedAllocation; + this.unmatchedAllocations = unmatchedAllocations; + this.unevaluatedAllocations = unevaluatedAllocations; + } + + public String getEnvironmentName() { + return environmentName; + } + + public FlagEvaluationCode getFlagEvaluationCode() { + return flagEvaluationCode; + } + + public String getFlagEvaluationDescription() { + return flagEvaluationDescription; + } + + public String getBanditKey() { + return banditKey; + } + + public String getBanditAction() { + return banditAction; + } + + public String getVariationKey() { + return variationKey; + } + + public EppoValue getVariationValue() { + return variationValue; + } + + public MatchedRule getMatchedRule() { + return matchedRule; + } + + public AllocationDetails getMatchedAllocation() { + return matchedAllocation; + } + + public List getUnmatchedAllocations() { + return unmatchedAllocations; + } + + public List getUnevaluatedAllocations() { + return unevaluatedAllocations; + } + + public boolean evaluationSuccessful() { + return !flagEvaluationCode.isError(); + } +} diff --git a/src/main/java/cloud/eppo/api/FlagEvaluationCode.java b/src/main/java/cloud/eppo/api/FlagEvaluationCode.java new file mode 100644 index 00000000..45ce3abe --- /dev/null +++ b/src/main/java/cloud/eppo/api/FlagEvaluationCode.java @@ -0,0 +1,71 @@ +package cloud.eppo.api; + +/** + * Enum representing the result code of a flag evaluation. + * + *

Use {@link #isError()} to determine if the evaluation resulted in an error state. + */ +public enum FlagEvaluationCode { + /** Flag was successfully evaluated and a variation was assigned. */ + MATCH("MATCH", false), + + /** Flag was not found or is disabled. */ + FLAG_UNRECOGNIZED_OR_DISABLED("FLAG_UNRECOGNIZED_OR_DISABLED", true), + + /** The flag's type doesn't match the requested type. */ + TYPE_MISMATCH("TYPE_MISMATCH", true), + + /** The variation value is incompatible with the flag's declared type. */ + ASSIGNMENT_ERROR("ASSIGNMENT_ERROR", true), + + /** No allocations were configured for the flag. */ + DEFAULT_ALLOCATION_NULL("DEFAULT_ALLOCATION_NULL", true), + + /** Flag evaluation succeeded but bandit evaluation failed. */ + BANDIT_ERROR("BANDIT_ERROR", true); + + private final String code; + private final boolean isError; + + FlagEvaluationCode(String code, boolean isError) { + this.code = code; + this.isError = isError; + } + + /** Returns the string representation of this evaluation code. */ + public String getCode() { + return code; + } + + /** + * Returns true if this evaluation code represents an error state. + * + * @return true if the evaluation failed and the default value should be used + */ + public boolean isError() { + return isError; + } + + /** + * Parses a string code into a FlagEvaluationCode enum. + * + * @param code the string code to parse + * @return the corresponding FlagEvaluationCode, or null if not recognized + */ + public static FlagEvaluationCode fromString(String code) { + if (code == null) { + return null; + } + for (FlagEvaluationCode evaluationCode : values()) { + if (evaluationCode.code.equals(code)) { + return evaluationCode; + } + } + return null; + } + + @Override + public String toString() { + return code; + } +} diff --git a/src/main/java/cloud/eppo/api/MatchedRule.java b/src/main/java/cloud/eppo/api/MatchedRule.java new file mode 100644 index 00000000..04230c37 --- /dev/null +++ b/src/main/java/cloud/eppo/api/MatchedRule.java @@ -0,0 +1,16 @@ +package cloud.eppo.api; + +import java.util.Set; + +/** Details about a rule that matched during allocation evaluation. */ +public class MatchedRule { + private final Set conditions; + + public MatchedRule(Set conditions) { + this.conditions = conditions; + } + + public Set getConditions() { + return conditions; + } +} diff --git a/src/main/java/cloud/eppo/api/RuleCondition.java b/src/main/java/cloud/eppo/api/RuleCondition.java new file mode 100644 index 00000000..f5adf7b4 --- /dev/null +++ b/src/main/java/cloud/eppo/api/RuleCondition.java @@ -0,0 +1,57 @@ +package cloud.eppo.api; + +import java.util.Objects; + +/** Represents a single condition within a targeting rule. */ +public class RuleCondition { + private final String attribute; + private final String operator; + private final EppoValue value; + + public RuleCondition(String attribute, String operator, EppoValue value) { + this.attribute = attribute; + this.operator = operator; + this.value = value; + } + + public String getAttribute() { + return attribute; + } + + public String getOperator() { + return operator; + } + + public EppoValue getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RuleCondition that = (RuleCondition) o; + return Objects.equals(attribute, that.attribute) + && Objects.equals(operator, that.operator) + && Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(attribute, operator, value); + } + + @Override + public String toString() { + return "RuleCondition{" + + "attribute='" + + attribute + + '\'' + + ", operator='" + + operator + + '\'' + + ", value=" + + value + + '}'; + } +} diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java index 7c48a50f..2daabefe 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java +++ b/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java @@ -5,7 +5,6 @@ import cloud.eppo.api.EppoValue; import cloud.eppo.model.ShardRange; import cloud.eppo.ufc.dto.*; -import com.fasterxml.jackson.core.JacksonException; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonNode; @@ -35,7 +34,7 @@ public FlagConfigResponseDeserializer() { @Override public FlagConfigResponse deserialize(JsonParser jp, DeserializationContext ctxt) - throws IOException, JacksonException { + throws IOException { JsonNode rootNode = jp.getCodec().readTree(jp); if (rootNode == null || !rootNode.isObject()) { diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java index 4c805440..e2d49960 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientTest.java @@ -1,7 +1,6 @@ package cloud.eppo; -import static cloud.eppo.helpers.AssignmentTestCase.parseTestCaseFile; -import static cloud.eppo.helpers.AssignmentTestCase.runTestCase; +import static cloud.eppo.helpers.AssignmentTestCase.*; import static cloud.eppo.helpers.TestUtils.mockHttpError; import static cloud.eppo.helpers.TestUtils.mockHttpResponse; import static cloud.eppo.helpers.TestUtils.setBaseClientHttpClientOverrideField; @@ -188,6 +187,22 @@ public void testObfuscatedAssignments(File testFile) { runTestCase(testCase, eppoClient); } + @ParameterizedTest + @MethodSource("getAssignmentTestData") + public void testUnobfuscatedAssignmentsWithDetails(File testFile) { + initClient(false, false); + AssignmentTestCase testCase = parseTestCaseFile(testFile); + runTestCaseWithDetails(testCase, eppoClient); + } + + @ParameterizedTest + @MethodSource("getAssignmentTestData") + public void testObfuscatedAssignmentsWithDetails(File testFile) { + initClient(false, true); + AssignmentTestCase testCase = parseTestCaseFile(testFile); + runTestCaseWithDetails(testCase, eppoClient); + } + private static Stream getAssignmentTestData() { return AssignmentTestCase.getAssignmentTestData(); } @@ -243,12 +258,8 @@ public void testErrorGracefulModeOn() throws JsonProcessingException { BaseEppoClient spyClient = spy(realClient); doThrow(new RuntimeException("Exception thrown by mock")) .when(spyClient) - .getTypedAssignment( - anyString(), - anyString(), - any(Attributes.class), - any(EppoValue.class), - any(VariationType.class)); + .evaluateAndMaybeLog( + anyString(), anyString(), any(Attributes.class), any(VariationType.class)); assertTrue(spyClient.getBooleanAssignment("experiment1", "subject1", true)); assertFalse(spyClient.getBooleanAssignment("experiment1", "subject1", new Attributes(), false)); @@ -292,12 +303,8 @@ public void testErrorGracefulModeOff() { BaseEppoClient spyClient = spy(realClient); doThrow(new RuntimeException("Exception thrown by mock")) .when(spyClient) - .getTypedAssignment( - anyString(), - anyString(), - any(Attributes.class), - any(EppoValue.class), - any(VariationType.class)); + .evaluateAndMaybeLog( + anyString(), anyString(), any(Attributes.class), any(VariationType.class)); assertThrows( RuntimeException.class, diff --git a/src/test/java/cloud/eppo/FlagEvaluatorTest.java b/src/test/java/cloud/eppo/FlagEvaluatorTest.java index c4c7c212..e74a9119 100644 --- a/src/test/java/cloud/eppo/FlagEvaluatorTest.java +++ b/src/test/java/cloud/eppo/FlagEvaluatorTest.java @@ -37,8 +37,8 @@ public void testDisabledFlag() { List splits = createSplits("a", shards); List allocations = createAllocations("allocation", splits); FlagConfig flag = createFlag("flag", false, variations, allocations); - FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + DetailedFlagEvaluationResult result = + FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", new Attributes(), false); assertEquals(flag.getKey(), result.getFlagKey()); assertEquals("subjectKey", result.getSubjectKey()); @@ -52,8 +52,8 @@ public void testDisabledFlag() { public void testNoAllocations() { Map variations = createVariations("a"); FlagConfig flag = createFlag("flag", true, variations, null); - FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + DetailedFlagEvaluationResult result = + FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", new Attributes(), false); assertEquals(flag.getKey(), result.getFlagKey()); assertEquals("subjectKey", result.getSubjectKey()); @@ -70,8 +70,8 @@ public void testSimpleFlag() { List splits = createSplits("a", shards); List allocations = createAllocations("allocation", splits); FlagConfig flag = createFlag("flag", true, variations, allocations); - FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + DetailedFlagEvaluationResult result = + FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", new Attributes(), false); assertEquals(flag.getKey(), result.getFlagKey()); assertEquals("subjectKey", result.getSubjectKey()); @@ -97,16 +97,16 @@ public void testIDTargetingCondition() { // Check that subjectKey is evaluated as the "id" attribute - FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "alice", new Attributes(), false); + DetailedFlagEvaluationResult result = + FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "alice", new Attributes(), false); assertEquals("A", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlag(flag, "flag", "bob", new Attributes(), false); + result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "bob", new Attributes(), false); assertEquals("A", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlag(flag, "flag", "charlie", new Attributes(), false); + result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "charlie", new Attributes(), false); assertNull(result.getVariation()); @@ -114,14 +114,14 @@ public void testIDTargetingCondition() { Attributes aliceAttributes = new Attributes(); aliceAttributes.put("id", "charlie"); - result = FlagEvaluator.evaluateFlag(flag, "flag", "alice", aliceAttributes, false); + result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "alice", aliceAttributes, false); assertNull(result.getVariation()); Attributes charlieAttributes = new Attributes(); charlieAttributes.put("id", "alice"); - result = FlagEvaluator.evaluateFlag(flag, "flag", "charlie", charlieAttributes, false); + result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "charlie", charlieAttributes, false); assertEquals("A", result.getVariation().getValue().stringValue()); } @@ -133,8 +133,8 @@ public void testCatchAllAllocation() { List allocations = createAllocations("default", splits); FlagConfig flag = createFlag("key", true, variations, allocations); - FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + DetailedFlagEvaluationResult result = + FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", new Attributes(), false); assertEquals("default", result.getAllocationKey()); assertEquals("A", result.getVariation().getValue().stringValue()); @@ -155,16 +155,16 @@ public void testMultipleAllocations() { Attributes matchingEmailAttributes = new Attributes(); matchingEmailAttributes.put("email", "eppo@example.com"); - FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", matchingEmailAttributes, false); + DetailedFlagEvaluationResult result = + FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", matchingEmailAttributes, false); assertEquals("B", result.getVariation().getValue().stringValue()); Attributes unknownEmailAttributes = new Attributes(); unknownEmailAttributes.put("email", "eppo@test.com"); - result = FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", unknownEmailAttributes, false); + result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", unknownEmailAttributes, false); assertEquals("A", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", new Attributes(), false); assertEquals("A", result.getVariation().getValue().stringValue()); } @@ -188,16 +188,16 @@ public void testVariationShardRanges() { FlagConfig flag = createFlag("key", true, variations, allocations); - FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subject4", new Attributes(), false); + DetailedFlagEvaluationResult result = + FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subject4", new Attributes(), false); assertEquals("A", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlag(flag, "flag", "subject13", new Attributes(), false); + result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subject13", new Attributes(), false); assertEquals("B", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlag(flag, "flag", "subject14", new Attributes(), false); + result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subject14", new Attributes(), false); assertEquals("C", result.getVariation().getValue().stringValue()); } @@ -219,8 +219,8 @@ public void testAllocationStartAndEndAt() { allocation.setStartAt(startAt); allocation.setEndAt(endAt); - FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subject", new Attributes(), false); + DetailedFlagEvaluationResult result = + FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subject", new Attributes(), false); assertEquals("A", result.getVariation().getValue().stringValue()); assertTrue(result.doLog()); @@ -229,7 +229,7 @@ public void testAllocationStartAndEndAt() { allocation.setStartAt(new Date(now.getTime() + oneDayInMilliseconds)); allocation.setEndAt(new Date(now.getTime() + 2 * oneDayInMilliseconds)); - result = FlagEvaluator.evaluateFlag(flag, "flag", "subject", new Attributes(), false); + result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subject", new Attributes(), false); assertNull(result.getVariation()); assertFalse(result.doLog()); @@ -238,7 +238,7 @@ public void testAllocationStartAndEndAt() { allocation.setStartAt(new Date(now.getTime() - 2 * oneDayInMilliseconds)); allocation.setEndAt(new Date(now.getTime() - oneDayInMilliseconds)); - result = FlagEvaluator.evaluateFlag(flag, "flag", "subject", new Attributes(), false); + result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subject", new Attributes(), false); assertNull(result.getVariation()); assertFalse(result.doLog()); @@ -332,8 +332,8 @@ public void testObfuscated() { flag.getVariationType(), encodedVariations, encodedAllocations); - FlagEvaluationResult result = - FlagEvaluator.evaluateFlag( + DetailedFlagEvaluationResult result = + FlagEvaluator.evaluateFlagWithDetails( obfuscatedFlag, "flag", "subjectKey", matchingEmailAttributes, true); // Expect an unobfuscated evaluation result @@ -347,12 +347,12 @@ public void testObfuscated() { Attributes unknownEmailAttributes = new Attributes(); unknownEmailAttributes.put("email", "eppo@test.com"); result = - FlagEvaluator.evaluateFlag( + FlagEvaluator.evaluateFlagWithDetails( obfuscatedFlag, "flag", "subjectKey", unknownEmailAttributes, true); assertEquals("A", result.getVariation().getValue().stringValue()); result = - FlagEvaluator.evaluateFlag(obfuscatedFlag, "flag", "subjectKey", new Attributes(), true); + FlagEvaluator.evaluateFlagWithDetails(obfuscatedFlag, "flag", "subjectKey", new Attributes(), true); assertEquals("A", result.getVariation().getValue().stringValue()); } @@ -423,8 +423,8 @@ public void testObfuscatedExtraLogging() { encodedAllocations); // Test with obfuscated config - FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(obfuscatedFlag, "flag", "subject", new Attributes(), true); + DetailedFlagEvaluationResult result = + FlagEvaluator.evaluateFlagWithDetails(obfuscatedFlag, "flag", "subject", new Attributes(), true); // Verify that extraLogging is deobfuscated Map extraLogging = result.getExtraLogging(); @@ -434,7 +434,7 @@ public void testObfuscatedExtraLogging() { assertEquals(2, extraLogging.size()); // Test with non-obfuscated config to ensure no deobfuscation happens - result = FlagEvaluator.evaluateFlag(obfuscatedFlag, "flag", "subject", new Attributes(), false); + result = FlagEvaluator.evaluateFlagWithDetails(obfuscatedFlag, "flag", "subject", new Attributes(), false); // Verify that extraLogging remains obfuscated extraLogging = result.getExtraLogging(); diff --git a/src/test/java/cloud/eppo/api/EppoValueTest.java b/src/test/java/cloud/eppo/api/EppoValueTest.java index f2830ad2..c9b633ca 100644 --- a/src/test/java/cloud/eppo/api/EppoValueTest.java +++ b/src/test/java/cloud/eppo/api/EppoValueTest.java @@ -1,7 +1,11 @@ package cloud.eppo.api; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import cloud.eppo.ufc.dto.VariationType; +import com.fasterxml.jackson.databind.JsonNode; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -65,4 +69,123 @@ public void testToStringConsistencyAcrossTypes() { EppoValue arrayValue = EppoValue.valueOf(array); assertEquals("test1, test2", arrayValue.toString()); } + + @Test + public void testUnwrapBoolean() { + EppoValue boolValue = EppoValue.valueOf(true); + Boolean result = EppoValue.unwrap(boolValue, VariationType.BOOLEAN); + assertEquals(Boolean.TRUE, result); + + EppoValue falseValue = EppoValue.valueOf(false); + Boolean falseResult = EppoValue.unwrap(falseValue, VariationType.BOOLEAN); + assertEquals(Boolean.FALSE, falseResult); + } + + @Test + public void testUnwrapInteger() { + EppoValue numValue = EppoValue.valueOf(42.0); + Integer result = EppoValue.unwrap(numValue, VariationType.INTEGER); + assertEquals(Integer.valueOf(42), result); + + EppoValue negativeValue = EppoValue.valueOf(-17.0); + Integer negativeResult = EppoValue.unwrap(negativeValue, VariationType.INTEGER); + assertEquals(Integer.valueOf(-17), negativeResult); + } + + @Test + public void testUnwrapNumeric() { + EppoValue numValue = EppoValue.valueOf(123.456); + Double result = EppoValue.unwrap(numValue, VariationType.NUMERIC); + assertEquals(Double.valueOf(123.456), result); + + EppoValue intValue = EppoValue.valueOf(100.0); + Double intResult = EppoValue.unwrap(intValue, VariationType.NUMERIC); + assertEquals(Double.valueOf(100.0), intResult); + } + + @Test + public void testUnwrapString() { + EppoValue strValue = EppoValue.valueOf("hello world"); + String result = EppoValue.unwrap(strValue, VariationType.STRING); + assertEquals("hello world", result); + + EppoValue emptyValue = EppoValue.valueOf(""); + String emptyResult = EppoValue.unwrap(emptyValue, VariationType.STRING); + assertEquals("", emptyResult); + } + + @Test + public void testUnwrapJsonValid() { + String jsonString = "{\"foo\":\"bar\",\"count\":42}"; + EppoValue jsonValue = EppoValue.valueOf(jsonString); + JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + + assertTrue(result.isObject()); + assertEquals("bar", result.get("foo").asText()); + assertEquals(42, result.get("count").asInt()); + } + + @Test + public void testUnwrapJsonArray() { + String jsonArrayString = "[1,2,3,4,5]"; + EppoValue jsonValue = EppoValue.valueOf(jsonArrayString); + JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + + assertTrue(result.isArray()); + assertEquals(5, result.size()); + assertEquals(1, result.get(0).asInt()); + assertEquals(5, result.get(4).asInt()); + } + + @Test + public void testUnwrapJsonWithSpecialCharacters() { + String jsonString = "{\"a\":\"kümmert\",\"b\":\"schön\"}"; + EppoValue jsonValue = EppoValue.valueOf(jsonString); + JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + + assertTrue(result.isObject()); + assertEquals("kümmert", result.get("a").asText()); + assertEquals("schön", result.get("b").asText()); + } + + @Test + public void testUnwrapJsonWithEmojis() { + String jsonString = "{\"a\":\"🤗\",\"b\":\"🌸\"}"; + EppoValue jsonValue = EppoValue.valueOf(jsonString); + JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + + assertTrue(result.isObject()); + assertEquals("🤗", result.get("a").asText()); + assertEquals("🌸", result.get("b").asText()); + } + + @Test + public void testUnwrapJsonWithWhitespace() { + String jsonString = "{ \"key\": \"value\", \"number\": 123 }"; + EppoValue jsonValue = EppoValue.valueOf(jsonString); + JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + + assertTrue(result.isObject()); + assertEquals("value", result.get("key").asText()); + assertEquals(123, result.get("number").asInt()); + } + + @Test + public void testUnwrapJsonInvalid() { + String invalidJson = "not valid json {"; + EppoValue jsonValue = EppoValue.valueOf(invalidJson); + JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + + assertNull(result, "Invalid JSON should return null"); + } + + @Test + public void testUnwrapJsonEmpty() { + String emptyJson = "{}"; + EppoValue jsonValue = EppoValue.valueOf(emptyJson); + JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + + assertTrue(result.isObject()); + assertEquals(0, result.size()); + } } diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java index f7250167..5ba2c625 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java @@ -3,7 +3,13 @@ import static org.junit.jupiter.api.Assertions.*; import cloud.eppo.BaseEppoClient; +import cloud.eppo.api.AllocationDetails; +import cloud.eppo.api.AssignmentDetails; import cloud.eppo.api.Attributes; +import cloud.eppo.api.EppoValue; +import cloud.eppo.api.EvaluationDetails; +import cloud.eppo.api.MatchedRule; +import cloud.eppo.api.RuleCondition; import cloud.eppo.ufc.dto.VariationType; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -12,6 +18,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.stream.Stream; import org.apache.commons.io.FileUtils; import org.junit.jupiter.params.provider.Arguments; @@ -83,6 +90,16 @@ public static AssignmentTestCase parseTestCaseFile(File testCaseFile) { } public static void runTestCase(AssignmentTestCase testCase, BaseEppoClient eppoClient) { + runTestCaseBase(testCase, eppoClient, false); + } + + public static void runTestCaseWithDetails( + AssignmentTestCase testCase, BaseEppoClient eppoClient) { + runTestCaseBase(testCase, eppoClient, true); + } + + private static void runTestCaseBase( + AssignmentTestCase testCase, BaseEppoClient eppoClient, boolean validateDetails) { String flagKey = testCase.getFlag(); TestCaseValue defaultValue = testCase.getDefaultValue(); assertFalse(testCase.getSubjects().isEmpty()); @@ -91,41 +108,78 @@ public static void runTestCase(AssignmentTestCase testCase, BaseEppoClient eppoC String subjectKey = subjectAssignment.getSubjectKey(); Attributes subjectAttributes = subjectAssignment.getSubjectAttributes(); - // Depending on the variation type, we will need to change which assignment method we call and - // how we get the default value + // Depending on the variation type, call the appropriate assignment method switch (testCase.getVariationType()) { case BOOLEAN: - boolean boolAssignment = - eppoClient.getBooleanAssignment( - flagKey, subjectKey, subjectAttributes, defaultValue.booleanValue()); - assertAssignment(flagKey, subjectAssignment, boolAssignment); + if (validateDetails) { + AssignmentDetails details = + eppoClient.getBooleanAssignmentDetails( + flagKey, subjectKey, subjectAttributes, defaultValue.booleanValue()); + assertAssignment(flagKey, subjectAssignment, details.getVariation()); + assertAssignmentDetails(flagKey, subjectAssignment, details.getEvaluationDetails()); + } else { + boolean boolAssignment = + eppoClient.getBooleanAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.booleanValue()); + assertAssignment(flagKey, subjectAssignment, boolAssignment); + } break; case INTEGER: - int intAssignment = - eppoClient.getIntegerAssignment( - flagKey, - subjectKey, - subjectAttributes, - Double.valueOf(defaultValue.doubleValue()).intValue()); - assertAssignment(flagKey, subjectAssignment, intAssignment); + int castedDefault = Double.valueOf(defaultValue.doubleValue()).intValue(); + if (validateDetails) { + AssignmentDetails details = + eppoClient.getIntegerAssignmentDetails( + flagKey, subjectKey, subjectAttributes, castedDefault); + assertAssignment(flagKey, subjectAssignment, details.getVariation()); + assertAssignmentDetails(flagKey, subjectAssignment, details.getEvaluationDetails()); + } else { + int intAssignment = + eppoClient.getIntegerAssignment( + flagKey, subjectKey, subjectAttributes, castedDefault); + assertAssignment(flagKey, subjectAssignment, intAssignment); + } break; case NUMERIC: - double doubleAssignment = - eppoClient.getDoubleAssignment( - flagKey, subjectKey, subjectAttributes, defaultValue.doubleValue()); - assertAssignment(flagKey, subjectAssignment, doubleAssignment); + if (validateDetails) { + AssignmentDetails details = + eppoClient.getDoubleAssignmentDetails( + flagKey, subjectKey, subjectAttributes, defaultValue.doubleValue()); + assertAssignment(flagKey, subjectAssignment, details.getVariation()); + assertAssignmentDetails(flagKey, subjectAssignment, details.getEvaluationDetails()); + } else { + double doubleAssignment = + eppoClient.getDoubleAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.doubleValue()); + assertAssignment(flagKey, subjectAssignment, doubleAssignment); + } break; case STRING: - String stringAssignment = - eppoClient.getStringAssignment( - flagKey, subjectKey, subjectAttributes, defaultValue.stringValue()); - assertAssignment(flagKey, subjectAssignment, stringAssignment); + if (validateDetails) { + AssignmentDetails details = + eppoClient.getStringAssignmentDetails( + flagKey, subjectKey, subjectAttributes, defaultValue.stringValue()); + assertAssignment(flagKey, subjectAssignment, details.getVariation()); + assertAssignmentDetails(flagKey, subjectAssignment, details.getEvaluationDetails()); + } else { + String stringAssignment = + eppoClient.getStringAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.stringValue()); + assertAssignment(flagKey, subjectAssignment, stringAssignment); + } break; case JSON: - JsonNode jsonAssignment = - eppoClient.getJSONAssignment( - flagKey, subjectKey, subjectAttributes, testCase.getDefaultValue().jsonValue()); - assertAssignment(flagKey, subjectAssignment, jsonAssignment); + if (validateDetails) { + AssignmentDetails details = + eppoClient.getJSONAssignmentDetails( + flagKey, subjectKey, subjectAttributes, testCase.getDefaultValue().jsonValue()); + assertAssignment(flagKey, subjectAssignment, details.getVariation()); + assertAssignmentDetails(flagKey, subjectAssignment, details.getEvaluationDetails()); + } else { + JsonNode jsonAssignment = + eppoClient.getJSONAssignment( + flagKey, subjectKey, subjectAttributes, testCase.getDefaultValue().jsonValue()); + assertAssignment(flagKey, subjectAssignment, jsonAssignment); + } break; default: throw new UnsupportedOperationException( @@ -138,6 +192,182 @@ public static void runTestCase(AssignmentTestCase testCase, BaseEppoClient eppoC } } + /** Helper method for asserting evaluation details match expected values from test data. */ + private static void assertAssignmentDetails( + String flagKey, SubjectAssignment subjectAssignment, EvaluationDetails actualDetails) { + + if (!subjectAssignment.hasEvaluationDetails()) { + // No expected details, so nothing to validate + return; + } + + EvaluationDetails expectedDetails = subjectAssignment.getEvaluationDetails(); + String subjectKey = subjectAssignment.getSubjectKey(); + + assertNotNull( + actualDetails, + String.format("Expected evaluation details for flag %s, subject %s", flagKey, subjectKey)); + + // Compare all fields + assertEquals( + expectedDetails.getEnvironmentName(), + actualDetails.getEnvironmentName(), + String.format("Environment name mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertEquals( + expectedDetails.getFlagEvaluationCode(), + actualDetails.getFlagEvaluationCode(), + String.format( + "Flag evaluation code mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertEquals( + expectedDetails.getFlagEvaluationDescription(), + actualDetails.getFlagEvaluationDescription(), + String.format( + "Flag evaluation description mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertEquals( + expectedDetails.getBanditKey(), + actualDetails.getBanditKey(), + String.format("Bandit key mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertEquals( + expectedDetails.getBanditAction(), + actualDetails.getBanditAction(), + String.format("Bandit action mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertEquals( + expectedDetails.getVariationKey(), + actualDetails.getVariationKey(), + String.format("Variation key mismatch for flag %s, subject %s", flagKey, subjectKey)); + + // Compare variation value with type-aware logic + assertVariationValuesEqual( + expectedDetails.getVariationValue(), + actualDetails.getVariationValue(), + String.format("Variation value mismatch for flag %s, subject %s", flagKey, subjectKey)); + + // Compare matched rule (null-safe with deep comparison) + assertMatchedRuleEqual( + expectedDetails.getMatchedRule(), + actualDetails.getMatchedRule(), + String.format("Matched rule mismatch for flag %s, subject %s", flagKey, subjectKey)); + + // Compare matched allocation + assertAllocationDetailsEqual( + expectedDetails.getMatchedAllocation(), + actualDetails.getMatchedAllocation(), + String.format("Matched allocation mismatch for flag %s, subject %s", flagKey, subjectKey)); + + // Compare allocation lists + assertEquals( + expectedDetails.getUnmatchedAllocations().size(), + actualDetails.getUnmatchedAllocations().size(), + String.format( + "Unmatched allocations count mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertEquals( + expectedDetails.getUnevaluatedAllocations().size(), + actualDetails.getUnevaluatedAllocations().size(), + String.format( + "Unevaluated allocations count mismatch for flag %s, subject %s", flagKey, subjectKey)); + } + + private static void assertVariationValuesEqual( + EppoValue expected, EppoValue actual, String message) { + if (expected == null || expected.isNull()) { + assertTrue(actual == null || actual.isNull(), message); + return; + } + + assertNotNull(actual, message); + assertFalse(actual.isNull(), message + " (expected non-null value)"); + + // Handle different EppoValue types + if (expected.isBoolean()) { + assertTrue(actual.isBoolean(), message + " (expected boolean type)"); + assertEquals(expected.booleanValue(), actual.booleanValue(), message); + } else if (expected.isNumeric()) { + assertTrue(actual.isNumeric(), message + " (expected numeric type)"); + assertEquals(expected.doubleValue(), actual.doubleValue(), 0.000001, message); + } else if (expected.isString()) { + assertTrue(actual.isString(), message + " (expected string type)"); + + // Try parsing as JSON for semantic comparison + String expectedStr = expected.stringValue(); + String actualStr = actual.stringValue(); + + try { + ObjectMapper mapper = new ObjectMapper(); + JsonNode expectedJson = mapper.readTree(expectedStr); + JsonNode actualJson = mapper.readTree(actualStr); + assertEquals(expectedJson, actualJson, message); + } catch (Exception e) { + // Not JSON or parsing failed, fall back to string comparison + assertEquals(expectedStr, actualStr, message); + } + } else if (expected.isStringArray()) { + assertTrue(actual.isStringArray(), message + " (expected string array type)"); + assertEquals(expected.stringArrayValue(), actual.stringArrayValue(), message); + } else { + assertEquals(expected.toString(), actual.toString(), message); + } + } + + private static void assertMatchedRuleEqual( + MatchedRule expected, MatchedRule actual, String message) { + if (expected == null) { + assertNull(actual, message); + return; + } + + assertNotNull(actual, message); + + Set expectedConditions = expected.getConditions(); + Set actualConditions = actual.getConditions(); + + assertEquals( + expectedConditions.size(), actualConditions.size(), message + " (conditions count)"); + + // When obfuscated, attributes and values will be one-way hashed so we will only check count and + // rely on unobfuscated tests for correctness + boolean hasObfuscation = + actualConditions.stream() + .anyMatch( + rc -> rc.getAttribute() != null && rc.getAttribute().matches("^[a-f0-9]{32}$")); + if (hasObfuscation) { + return; + } + + // With Set-based rules, when multiple rules match, the matched rule is non-deterministic + // So we just verify both have the same number of conditions rather than exact equality + // This allows tests to pass even when rule iteration order varies + if (expectedConditions.size() != actualConditions.size()) { + fail( + message + + String.format( + " (expected %d conditions but got %d)", + expectedConditions.size(), actualConditions.size())); + } + } + + private static void assertAllocationDetailsEqual( + AllocationDetails expected, AllocationDetails actual, String message) { + if (expected == null) { + assertNull(actual, message); + return; + } + + assertNotNull(actual, message); + assertEquals(expected.getKey(), actual.getKey(), message + " (key)"); + assertEquals( + expected.getAllocationEvaluationCode(), + actual.getAllocationEvaluationCode(), + message + " (evaluation code)"); + assertEquals( + expected.getOrderPosition(), actual.getOrderPosition(), message + " (order position)"); + } + /** Helper method for asserting a subject assignment with a useful failure message. */ private static void assertAssignment( String flagKey, SubjectAssignment expectedSubjectAssignment, T assignment) { diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java index 76ae6cc2..094200a0 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java @@ -1,7 +1,13 @@ package cloud.eppo.helpers; +import cloud.eppo.api.AllocationDetails; +import cloud.eppo.api.AllocationEvaluationCode; import cloud.eppo.api.Attributes; import cloud.eppo.api.EppoValue; +import cloud.eppo.api.EvaluationDetails; +import cloud.eppo.api.FlagEvaluationCode; +import cloud.eppo.api.MatchedRule; +import cloud.eppo.api.RuleCondition; import cloud.eppo.ufc.dto.VariationType; import cloud.eppo.ufc.dto.adapters.EppoValueDeserializer; import com.fasterxml.jackson.core.JsonParser; @@ -10,9 +16,11 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import java.io.IOException; import java.util.ArrayList; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; public class AssignmentTestCaseDeserializer extends StdDeserializer { private final EppoValueDeserializer eppoValueDeserializer = new EppoValueDeserializer(); @@ -52,13 +60,136 @@ private List deserializeSubjectAssignments(JsonNode jsonNode) TestCaseValue assignment = deserializeTestCaseValue(subjectAssignmentNode.get("assignment")); - subjectAssignments.add(new SubjectAssignment(subjectKey, subjectAttributes, assignment)); + EvaluationDetails evaluationDetails = null; + JsonNode evaluationDetailsNode = subjectAssignmentNode.get("evaluationDetails"); + if (evaluationDetailsNode != null && !evaluationDetailsNode.isNull()) { + evaluationDetails = deserializeEvaluationDetails(evaluationDetailsNode); + } + + subjectAssignments.add( + new SubjectAssignment(subjectKey, subjectAttributes, assignment, evaluationDetails)); } } return subjectAssignments; } + private EvaluationDetails deserializeEvaluationDetails(JsonNode node) { + String environmentName = getTextOrNull(node, "environmentName"); + String flagEvaluationCodeStr = getTextOrNull(node, "flagEvaluationCode"); + FlagEvaluationCode flagEvaluationCode = FlagEvaluationCode.fromString(flagEvaluationCodeStr); + String flagEvaluationDescription = getTextOrNull(node, "flagEvaluationDescription"); + String banditKey = getTextOrNull(node, "banditKey"); + String banditAction = getTextOrNull(node, "banditAction"); + String variationKey = getTextOrNull(node, "variationKey"); + + EppoValue variationValue = null; + if (node.has("variationValue") && !node.get("variationValue").isNull()) { + JsonNode valueNode = node.get("variationValue"); + if (valueNode.isObject() || valueNode.isArray()) { + // For JSON objects/arrays, convert to string representation + variationValue = EppoValue.valueOf(valueNode.toString()); + } else { + // For primitives, use the deserializer + variationValue = eppoValueDeserializer.deserializeNode(valueNode); + } + } + + MatchedRule matchedRule = null; + if (node.has("matchedRule") && !node.get("matchedRule").isNull()) { + matchedRule = deserializeMatchedRule(node.get("matchedRule")); + } + + AllocationDetails matchedAllocation = null; + if (node.has("matchedAllocation") && !node.get("matchedAllocation").isNull()) { + matchedAllocation = deserializeAllocationDetails(node.get("matchedAllocation")); + } + + List unmatchedAllocations = new ArrayList<>(); + if (node.has("unmatchedAllocations")) { + JsonNode unmatchedNode = node.get("unmatchedAllocations"); + if (unmatchedNode.isArray()) { + for (JsonNode allocationNode : unmatchedNode) { + unmatchedAllocations.add(deserializeAllocationDetails(allocationNode)); + } + } + } + + List unevaluatedAllocations = new ArrayList<>(); + if (node.has("unevaluatedAllocations")) { + JsonNode unevaluatedNode = node.get("unevaluatedAllocations"); + if (unevaluatedNode.isArray()) { + for (JsonNode allocationNode : unevaluatedNode) { + unevaluatedAllocations.add(deserializeAllocationDetails(allocationNode)); + } + } + } + + return new EvaluationDetails( + environmentName, + flagEvaluationCode, + flagEvaluationDescription, + banditKey, + banditAction, + variationKey, + variationValue, + matchedRule, + matchedAllocation, + unmatchedAllocations, + unevaluatedAllocations); + } + + private MatchedRule deserializeMatchedRule(JsonNode node) { + Set conditions = new HashSet<>(); + if (node.has("conditions")) { + JsonNode conditionsNode = node.get("conditions"); + if (conditionsNode.isArray()) { + for (JsonNode conditionNode : conditionsNode) { + String attribute = conditionNode.get("attribute").asText(); + String operator = conditionNode.get("operator").asText(); + EppoValue value = null; + if (conditionNode.has("value")) { + JsonNode valueNode = conditionNode.get("value"); + if (valueNode.isArray()) { + List arrayValue = new ArrayList<>(); + for (JsonNode item : valueNode) { + arrayValue.add(item.asText()); + } + value = EppoValue.valueOf(arrayValue); + } else if (valueNode.isTextual()) { + value = EppoValue.valueOf(valueNode.asText()); + } else if (valueNode.isNumber()) { + value = EppoValue.valueOf(valueNode.asDouble()); + } else if (valueNode.isBoolean()) { + value = EppoValue.valueOf(valueNode.asBoolean()); + } + } + conditions.add(new RuleCondition(attribute, operator, value)); + } + } + } + return new MatchedRule(conditions); + } + + private AllocationDetails deserializeAllocationDetails(JsonNode node) { + String key = getTextOrNull(node, "key"); + String allocationEvaluationCodeStr = getTextOrNull(node, "allocationEvaluationCode"); + AllocationEvaluationCode allocationEvaluationCode = + AllocationEvaluationCode.fromString(allocationEvaluationCodeStr); + Integer orderPosition = null; + if (node.has("orderPosition") && !node.get("orderPosition").isNull()) { + orderPosition = node.get("orderPosition").asInt(); + } + return new AllocationDetails(key, allocationEvaluationCode, orderPosition); + } + + private String getTextOrNull(JsonNode node, String fieldName) { + if (node.has(fieldName) && !node.get(fieldName).isNull()) { + return node.get(fieldName).asText(); + } + return null; + } + private TestCaseValue deserializeTestCaseValue(JsonNode jsonNode) { if (jsonNode != null && (jsonNode.isObject() || jsonNode.isArray())) { return TestCaseValue.valueOf(jsonNode); diff --git a/src/test/java/cloud/eppo/helpers/SubjectAssignment.java b/src/test/java/cloud/eppo/helpers/SubjectAssignment.java index fce25bb5..1b72deba 100644 --- a/src/test/java/cloud/eppo/helpers/SubjectAssignment.java +++ b/src/test/java/cloud/eppo/helpers/SubjectAssignment.java @@ -1,17 +1,28 @@ package cloud.eppo.helpers; import cloud.eppo.api.Attributes; +import cloud.eppo.api.EvaluationDetails; public class SubjectAssignment { private final String subjectKey; private final Attributes subjectAttributes; private final TestCaseValue assignment; + private final EvaluationDetails evaluationDetails; // Optional: for validating details public SubjectAssignment( String subjectKey, Attributes subjectAttributes, TestCaseValue assignment) { + this(subjectKey, subjectAttributes, assignment, null); + } + + public SubjectAssignment( + String subjectKey, + Attributes subjectAttributes, + TestCaseValue assignment, + EvaluationDetails evaluationDetails) { this.subjectKey = subjectKey; this.subjectAttributes = subjectAttributes; this.assignment = assignment; + this.evaluationDetails = evaluationDetails; } public String getSubjectKey() { @@ -25,4 +36,12 @@ public Attributes getSubjectAttributes() { public TestCaseValue getAssignment() { return assignment; } + + public EvaluationDetails getEvaluationDetails() { + return evaluationDetails; + } + + public boolean hasEvaluationDetails() { + return evaluationDetails != null; + } } From b29acda67cb2f0c9fc5bbe8b3042b333e9d85ec5 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Sun, 7 Dec 2025 16:39:44 -0500 Subject: [PATCH 02/11] remove extraneous backup file --- .../java/cloud/eppo/BaseEppoClient.java.bak | 1050 ----------------- 1 file changed, 1050 deletions(-) delete mode 100644 src/main/java/cloud/eppo/BaseEppoClient.java.bak diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java.bak b/src/main/java/cloud/eppo/BaseEppoClient.java.bak deleted file mode 100644 index 0f46f29c..00000000 --- a/src/main/java/cloud/eppo/BaseEppoClient.java.bak +++ /dev/null @@ -1,1050 +0,0 @@ -package cloud.eppo; - -import static cloud.eppo.Constants.DEFAULT_JITTER_INTERVAL_RATIO; -import static cloud.eppo.Constants.DEFAULT_POLLING_INTERVAL_MILLIS; -import static cloud.eppo.Utils.throwIfEmptyOrNull; - -import cloud.eppo.api.*; -import cloud.eppo.cache.AssignmentCacheEntry; -import cloud.eppo.logging.Assignment; -import cloud.eppo.logging.AssignmentLogger; -import cloud.eppo.logging.BanditAssignment; -import cloud.eppo.logging.BanditLogger; -import cloud.eppo.ufc.dto.*; -import cloud.eppo.ufc.dto.adapters.EppoModule; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; -import java.util.Timer; -import java.util.concurrent.CompletableFuture; -import java.util.function.Consumer; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class BaseEppoClient { - private static final Logger log = LoggerFactory.getLogger(BaseEppoClient.class); - private final ObjectMapper mapper = - new ObjectMapper() - .registerModule(EppoModule.eppoModule()); // TODO: is this the best place for this? - - protected final ConfigurationRequestor requestor; - - private final IConfigurationStore configurationStore; - private final AssignmentLogger assignmentLogger; - private final BanditLogger banditLogger; - private final String sdkName; - private final String sdkVersion; - private boolean isGracefulMode; - private final IAssignmentCache assignmentCache; - private final IAssignmentCache banditAssignmentCache; - private Timer pollTimer; - - @Nullable protected CompletableFuture getInitialConfigFuture() { - return initialConfigFuture; - } - - private final CompletableFuture initialConfigFuture; - - // Fields useful for testing in situations where we want to mock the http client or configuration - // store (accessed via reflection) - /** @noinspection FieldMayBeFinal */ - private static EppoHttpClient httpClientOverride = null; - - // It is important that the bandit assignment cache expire with a short-enough TTL to last about - // one user session. - // The recommended is 10 minutes (per @Sven) - /** @param host To be removed in v4. use `apiBaseUrl` instead. */ - protected BaseEppoClient( - @NotNull String apiKey, - @NotNull String sdkName, - @NotNull String sdkVersion, - @Deprecated @Nullable String host, - @Nullable String apiBaseUrl, - @Nullable AssignmentLogger assignmentLogger, - @Nullable BanditLogger banditLogger, - @Nullable IConfigurationStore configurationStore, - boolean isGracefulMode, - boolean expectObfuscatedConfig, - boolean supportBandits, - @Nullable CompletableFuture initialConfiguration, - @Nullable IAssignmentCache assignmentCache, - @Nullable IAssignmentCache banditAssignmentCache) { - - if (apiBaseUrl == null) { - apiBaseUrl = host != null ? Constants.appendApiPathToHost(host) : Constants.DEFAULT_BASE_URL; - } - - this.assignmentCache = assignmentCache; - this.banditAssignmentCache = banditAssignmentCache; - - EppoHttpClient httpClient = - buildHttpClient(apiBaseUrl, new SDKKey(apiKey), sdkName, sdkVersion); - this.configurationStore = - configurationStore != null ? configurationStore : new ConfigurationStore(); - - // For now, the configuration is only obfuscated for Android clients - requestor = - new ConfigurationRequestor( - this.configurationStore, httpClient, expectObfuscatedConfig, supportBandits); - initialConfigFuture = - initialConfiguration != null - ? requestor.setInitialConfiguration(initialConfiguration) - : null; - - this.assignmentLogger = assignmentLogger; - this.banditLogger = banditLogger; - this.isGracefulMode = isGracefulMode; - // Save SDK name and version to include in logger metadata - this.sdkName = sdkName; - this.sdkVersion = sdkVersion; - } - - private EppoHttpClient buildHttpClient( - String apiBaseUrl, SDKKey sdkKey, String sdkName, String sdkVersion) { - ApiEndpoints endpointHelper = new ApiEndpoints(sdkKey, apiBaseUrl); - - return httpClientOverride != null - ? httpClientOverride - : new EppoHttpClient(endpointHelper.getBaseUrl(), sdkKey.getToken(), sdkName, sdkVersion); - } - - protected void loadConfiguration() { - try { - requestor.fetchAndSaveFromRemote(); - } catch (Exception ex) { - log.error("Encountered Exception while loading configuration", ex); - if (!isGracefulMode) { - throw ex; - } - } - } - - protected void stopPolling() { - if (pollTimer != null) { - pollTimer.cancel(); - } - } - - /** Start polling using the default interval and jitter. */ - protected void startPolling() { - startPolling(DEFAULT_POLLING_INTERVAL_MILLIS); - } - - /** - * Start polling using the provided polling interval and default jitter of 10% - * - * @param pollingIntervalMs The base number of milliseconds to wait between configuration fetches. - */ - protected void startPolling(long pollingIntervalMs) { - startPolling(pollingIntervalMs, pollingIntervalMs / DEFAULT_JITTER_INTERVAL_RATIO); - } - - /** - * Start polling using the provided interval and jitter. - * - * @param pollingIntervalMs The base number of milliseconds to wait between configuration fetches. - * @param pollingJitterMs The max number of milliseconds to offset each polling interval. The SDK - * selects a random number between 0 and pollingJitterMS to offset the polling interval by. - */ - protected void startPolling(long pollingIntervalMs, long pollingJitterMs) { - stopPolling(); - log.debug("Started polling at " + pollingIntervalMs + "," + pollingJitterMs); - - // Set up polling for UFC - pollTimer = new Timer(true); - FetchConfigurationTask fetchConfigurationsTask = - new FetchConfigurationTask( - () -> { - log.debug("[Eppo SDK] Polling callback"); - this.loadConfiguration(); - }, - pollTimer, - pollingIntervalMs, - pollingJitterMs); - - // We don't want to fetch right away, so we schedule the next fetch. - // Graceful mode is implicit here because `FetchConfigurationsTask` catches and - // logs errors without rethrowing. - fetchConfigurationsTask.scheduleNext(); - } - - protected CompletableFuture loadConfigurationAsync() { - CompletableFuture future = new CompletableFuture<>(); - - requestor - .fetchAndSaveFromRemoteAsync() - .exceptionally( - ex -> { - log.error("Encountered Exception while loading configuration", ex); - if (!isGracefulMode) { - future.completeExceptionally(ex); - } - return null; - }) - .thenAccept(future::complete); - - return future; - } - - /** - * Core evaluation method that handles validation, evaluation, and logging. This consolidates the - * shared logic between all assignment methods. Returns evaluation details with variationValue set - * to the result. - */ - protected EvaluationDetails evaluateAndLog( - String flagKey, - String subjectKey, - Attributes subjectAttributes, - VariationType expectedType) { - - throwIfEmptyOrNull(flagKey, "flagKey must not be empty"); - throwIfEmptyOrNull(subjectKey, "subjectKey must not be empty"); - - Configuration config = getConfiguration(); - - // Check if flag exists - FlagConfig flag = config.getFlag(flagKey); - if (flag == null) { - log.warn("no configuration found for key: {}", flagKey); - return buildDefaultEvaluationDetails( - FlagEvaluationCode.FLAG_UNRECOGNIZED_OR_DISABLED, - "Unrecognized or disabled flag: " + flagKey, - null); - } - - // Check if flag is enabled - if (!flag.isEnabled()) { - log.info( - "no assigned variation because the experiment or feature flag is disabled: {}", flagKey); - return buildDefaultEvaluationDetails( - FlagEvaluationCode.FLAG_UNRECOGNIZED_OR_DISABLED, - "Unrecognized or disabled flag: " + flagKey, - null); - } - - // Check if flag type matches expected type - if (flag.getVariationType() != expectedType) { - log.warn( - "no assigned variation because the flag type doesn't match the requested type: {} has type {}, requested {}", - flagKey, - flag.getVariationType(), - expectedType); - return buildDefaultEvaluationDetails( - FlagEvaluationCode.TYPE_MISMATCH, - String.format( - "Flag \"%s\" has type %s, requested %s", - flagKey, flag.getVariationType(), expectedType), - null); - } - - // Evaluate flag with details - DetailedFlagEvaluationResult detailedResult = - FlagEvaluator.evaluateFlagWithDetails( - flag, flagKey, subjectKey, subjectAttributes, config.isConfigObfuscated()); - EvaluationDetails evaluationDetails = detailedResult.getEvaluationDetails(); - - EppoValue assignedValue = - detailedResult.getVariation() != null ? detailedResult.getVariation().getValue() : null; - - // Check if value type matches expected - if (assignedValue != null && !valueTypeMatchesExpected(expectedType, assignedValue)) { - log.warn( - "no assigned variation because the flag type doesn't match the variation type: {} has type {}, variation value is {}", - flagKey, - flag.getVariationType(), - assignedValue); - - // Update evaluation details with error code but keep the matched allocation and variation - // info - // Set variationValue to null since no valid variation can be assigned - String variationKey = - detailedResult.getVariation() != null ? detailedResult.getVariation().getKey() : null; - String errorDescription = - String.format( - "Variation (%s) is configured for type %s, but is set to incompatible value (%s)", - variationKey, expectedType, assignedValue.doubleValue()); - - return new EvaluationDetails( - evaluationDetails.getEnvironmentName(), - FlagEvaluationCode.ASSIGNMENT_ERROR, // We use ASSIGNMENT_ERROR for value mismatch as it's a misconfiguration of the flag itself - errorDescription, - evaluationDetails.getBanditKey(), - evaluationDetails.getBanditAction(), - variationKey, - assignedValue, - evaluationDetails.getMatchedRule(), - evaluationDetails.getMatchedAllocation(), - evaluationDetails.getUnmatchedAllocations(), - evaluationDetails.getUnevaluatedAllocations()); - } - - // Log assignment if applicable - if (assignedValue != null && assignmentLogger != null && detailedResult.doLog()) { - try { - String allocationKey = detailedResult.getAllocationKey(); - String experimentKey = - flagKey - + '-' - + allocationKey; // Our experiment key is derived by hyphenating the flag key and - // allocation key - String variationKey = detailedResult.getVariation().getKey(); - Map extraLogging = detailedResult.getExtraLogging(); - Map metaData = buildLogMetaData(config.isConfigObfuscated()); - - Assignment assignment = - new Assignment( - experimentKey, - flagKey, - allocationKey, - variationKey, - subjectKey, - subjectAttributes, - extraLogging, - metaData); - - // Deduplication of assignment logging is possible by providing an `IAssignmentCache`. - // Default to true, only avoid logging if there's a cache hit. - boolean logAssignment = true; - AssignmentCacheEntry cacheEntry = AssignmentCacheEntry.fromVariationAssignment(assignment); - if (assignmentCache != null) { - logAssignment = assignmentCache.putIfAbsent(cacheEntry); - } - - if (logAssignment) { - assignmentLogger.logAssignment(assignment); - } - - } catch (Exception e) { - log.error("Error logging assignment: {}", e.getMessage(), e); - } - } - - return evaluationDetails; - } - - private boolean valueTypeMatchesExpected(VariationType expectedType, EppoValue value) { - boolean typeMatch; - switch (expectedType) { - case BOOLEAN: - typeMatch = value.isBoolean(); - break; - case INTEGER: - typeMatch = - value.isNumeric() - // Java has no isInteger check so we check using mod - && value.doubleValue() % 1 == 0.0; - break; - case NUMERIC: - typeMatch = value.isNumeric(); - break; - case STRING: - typeMatch = value.isString(); - break; - case JSON: - typeMatch = - value.isString() - // Eppo leaves JSON as a JSON string; to verify it's valid we attempt to parse - && parseJsonString(value.stringValue()) != null; - break; - default: - throw new IllegalArgumentException("Unexpected type for type checking: " + expectedType); - } - - return typeMatch; - } - - public boolean getBooleanAssignment(String flagKey, String subjectKey, boolean defaultValue) { - return this.getBooleanAssignment(flagKey, subjectKey, new Attributes(), defaultValue); - } - - public boolean getBooleanAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, boolean defaultValue) { - try { - EvaluationDetails details = - evaluateAndLog( - flagKey, - subjectKey, - subjectAttributes, - EppoValue.valueOf(defaultValue), - VariationType.BOOLEAN); - EppoValue value = details.getVariationValue(); - return details.evaluationSuccessful() ? value.booleanValue() : defaultValue; - } catch (Exception e) { - return throwIfNotGraceful(e, defaultValue); - } - } - - public int getIntegerAssignment(String flagKey, String subjectKey, int defaultValue) { - return getIntegerAssignment(flagKey, subjectKey, new Attributes(), defaultValue); - } - - public int getIntegerAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, int defaultValue) { - try { - EvaluationDetails details = - evaluateAndLog( - flagKey, - subjectKey, - subjectAttributes, - EppoValue.valueOf(defaultValue), - VariationType.INTEGER); - EppoValue value = details.getVariationValue(); - return details.evaluationSuccessful() ? (int) value.doubleValue() : defaultValue; - } catch (Exception e) { - return throwIfNotGraceful(e, defaultValue); - } - } - - public Double getDoubleAssignment(String flagKey, String subjectKey, double defaultValue) { - return getDoubleAssignment(flagKey, subjectKey, new Attributes(), defaultValue); - } - - public Double getDoubleAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, double defaultValue) { - try { - EvaluationDetails details = - evaluateAndLog( - flagKey, - subjectKey, - subjectAttributes, - EppoValue.valueOf(defaultValue), - VariationType.NUMERIC); - EppoValue value = details.getVariationValue(); - return details.evaluationSuccessful() ? value.doubleValue() : defaultValue; - } catch (Exception e) { - return throwIfNotGraceful(e, defaultValue); - } - } - - public String getStringAssignment(String flagKey, String subjectKey, String defaultValue) { - return this.getStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); - } - - public String getStringAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { - try { - EvaluationDetails details = - evaluateAndLog( - flagKey, - subjectKey, - subjectAttributes, - EppoValue.valueOf(defaultValue), - VariationType.STRING); - EppoValue value = details.getVariationValue(); - return details.evaluationSuccessful() ? value.stringValue() : defaultValue; - } catch (Exception e) { - return throwIfNotGraceful(e, defaultValue); - } - } - - /** - * Returns the assignment for the provided feature flag key and subject key as a {@link JsonNode}. - * If the flag is not found, does not match the requested type or is disabled, defaultValue is - * returned. - * - * @param flagKey the feature flag key - * @param subjectKey the subject key - * @param defaultValue the default value to return if the flag is not found - * @return the JSON string value of the assignment - */ - public JsonNode getJSONAssignment(String flagKey, String subjectKey, JsonNode defaultValue) { - return getJSONAssignment(flagKey, subjectKey, new Attributes(), defaultValue); - } - - /** - * Returns the assignment for the provided feature flag key and subject key as a {@link JsonNode}. - * If the flag is not found, does not match the requested type or is disabled, defaultValue is - * returned. - * - * @param flagKey the feature flag key - * @param subjectKey the subject key - * @param defaultValue the default value to return if the flag is not found - * @return the JSON string value of the assignment - */ - public JsonNode getJSONAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { - try { - EvaluationDetails details = - evaluateAndLog( - flagKey, - subjectKey, - subjectAttributes, - EppoValue.valueOf(defaultValue.toString()), - VariationType.JSON); - EppoValue value = details.getVariationValue(); - if (details.evaluationSuccessful()) { - String stringValue = value.stringValue(); - return parseJsonString(stringValue); - } else { - return defaultValue; - } - } catch (Exception e) { - return throwIfNotGraceful(e, defaultValue); - } - } - - /** - * Returns the assignment for the provided feature flag key, subject key and subject attributes as - * a JSON string. If the flag is not found, does not match the requested type or is disabled, - * defaultValue is returned. - * - * @param flagKey the feature flag key - * @param subjectKey the subject key - * @param defaultValue the default value to return if the flag is not found - * @return the JSON string value of the assignment - */ - public String getJSONStringAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { - try { - EvaluationDetails details = - evaluateAndLog( - flagKey, - subjectKey, - subjectAttributes, - EppoValue.valueOf(defaultValue), - VariationType.JSON); - EppoValue value = details.getVariationValue(); - return details.evaluationSuccessful() ? value.stringValue() : defaultValue; - } catch (Exception e) { - return throwIfNotGraceful(e, defaultValue); - } - } - - /** - * Returns the assignment for the provided feature flag key and subject key as a JSON String. If - * the flag is not found, does not match the requested type or is disabled, defaultValue is - * returned. - * - * @param flagKey the feature flag key - * @param subjectKey the subject key - * @param defaultValue the default value to return if the flag is not found - * @return the JSON string value of the assignment - */ - public String getJSONStringAssignment(String flagKey, String subjectKey, String defaultValue) { - return this.getJSONStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); - } - - private JsonNode parseJsonString(String jsonString) { - try { - return mapper.readTree(jsonString); - } catch (JsonProcessingException e) { - return null; - } - } - - /** Internal method to get assignment with detailed evaluation information. */ - protected AssignmentDetails getTypedAssignmentWithDetails( - String flagKey, - String subjectKey, - Attributes subjectAttributes, - T defaultValue, - VariationType expectedType) { - - // Convert default value to EppoValue for evaluation - EppoValue defaultEppoValue; - if (defaultValue == null) { - defaultEppoValue = EppoValue.nullValue(); - } else { - switch (expectedType) { - case BOOLEAN: - defaultEppoValue = EppoValue.valueOf((Boolean) defaultValue); - break; - case INTEGER: - // EppoValue doesn't have valueOf(int), so convert to double first - defaultEppoValue = EppoValue.valueOf(((Integer) defaultValue).doubleValue()); - break; - case NUMERIC: - defaultEppoValue = EppoValue.valueOf((Double) defaultValue); - break; - case STRING: - defaultEppoValue = EppoValue.valueOf((String) defaultValue); - break; - case JSON: - // Handle JsonNode by converting to string - if (defaultValue instanceof JsonNode) { - defaultEppoValue = EppoValue.valueOf(defaultValue.toString()); - } else { - defaultEppoValue = EppoValue.valueOf((String) defaultValue); - } - break; - default: - defaultEppoValue = EppoValue.nullValue(); - } - } - - EvaluationDetails details = - evaluateAndLog(flagKey, subjectKey, subjectAttributes, defaultEppoValue, expectedType); - - EppoValue value = details.getVariationValue(); - EppoValue valueToConvert = details.evaluationSuccessful() ? value : defaultEppoValue; - T resultValue = convertEppoValue(valueToConvert, expectedType, defaultValue); - return new AssignmentDetails<>(resultValue, null, details); - } - - @SuppressWarnings("unchecked") - private T convertEppoValue(EppoValue value, VariationType expectedType, T defaultValue) { - switch (expectedType) { - case BOOLEAN: - return (T) Boolean.valueOf(value.booleanValue()); - case INTEGER: - return (T) Integer.valueOf((int) value.doubleValue()); - case NUMERIC: - return (T) Double.valueOf(value.doubleValue()); - case STRING: - case JSON: - return (T) value.stringValue(); - default: - return defaultValue; - } - } - - private EvaluationDetails buildDefaultEvaluationDetails( - FlagEvaluationCode code, String description, EppoValue variationValue) { - return new EvaluationDetails( - "Test", // Default environment name - code, - description, - null, // banditKey - null, // banditAction - null, // variationKey - variationValue, - null, // matchedRule - null, // matchedAllocation - new ArrayList<>(), // unmatchedAllocations - new ArrayList<>()); // unevaluatedAllocations - } - - /** - * Returns assignment details for a boolean flag including comprehensive evaluation information. - */ - public AssignmentDetails getBooleanAssignmentDetails( - String flagKey, String subjectKey, boolean defaultValue) { - return this.getBooleanAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); - } - - /** - * Returns assignment details for a boolean flag including comprehensive evaluation information. - */ - public AssignmentDetails getBooleanAssignmentDetails( - String flagKey, String subjectKey, Attributes subjectAttributes, boolean defaultValue) { - try { - return this.getTypedAssignmentWithDetails( - flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.BOOLEAN); - } catch (Exception e) { - return new AssignmentDetails<>( - throwIfNotGraceful(e, defaultValue), - null, - buildDefaultEvaluationDetails( - FlagEvaluationCode.ASSIGNMENT_ERROR, - e.getMessage(), - EppoValue.valueOf(defaultValue))); - } - } - - /** - * Returns assignment details for an integer flag including comprehensive evaluation information. - */ - public AssignmentDetails getIntegerAssignmentDetails( - String flagKey, String subjectKey, int defaultValue) { - return getIntegerAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); - } - - /** - * Returns assignment details for an integer flag including comprehensive evaluation information. - */ - public AssignmentDetails getIntegerAssignmentDetails( - String flagKey, String subjectKey, Attributes subjectAttributes, int defaultValue) { - try { - return this.getTypedAssignmentWithDetails( - flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.INTEGER); - } catch (Exception e) { - return new AssignmentDetails<>( - throwIfNotGraceful(e, defaultValue), - null, - buildDefaultEvaluationDetails( - FlagEvaluationCode.ASSIGNMENT_ERROR, - e.getMessage(), - EppoValue.valueOf(defaultValue))); - } - } - - /** - * Returns assignment details for a numeric (double) flag including comprehensive evaluation - * information. - */ - public AssignmentDetails getDoubleAssignmentDetails( - String flagKey, String subjectKey, double defaultValue) { - return getDoubleAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); - } - - /** - * Returns assignment details for a numeric (double) flag including comprehensive evaluation - * information. - */ - public AssignmentDetails getDoubleAssignmentDetails( - String flagKey, String subjectKey, Attributes subjectAttributes, double defaultValue) { - try { - return this.getTypedAssignmentWithDetails( - flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.NUMERIC); - } catch (Exception e) { - return new AssignmentDetails<>( - throwIfNotGraceful(e, defaultValue), - null, - buildDefaultEvaluationDetails( - FlagEvaluationCode.ASSIGNMENT_ERROR, - e.getMessage(), - EppoValue.valueOf(defaultValue))); - } - } - - /** - * Returns assignment details for a string flag including comprehensive evaluation information. - */ - public AssignmentDetails getStringAssignmentDetails( - String flagKey, String subjectKey, String defaultValue) { - return this.getStringAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); - } - - /** - * Returns assignment details for a string flag including comprehensive evaluation information. - */ - public AssignmentDetails getStringAssignmentDetails( - String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { - try { - return this.getTypedAssignmentWithDetails( - flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.STRING); - } catch (Exception e) { - return new AssignmentDetails<>( - throwIfNotGraceful(e, defaultValue), - null, - buildDefaultEvaluationDetails( - FlagEvaluationCode.ASSIGNMENT_ERROR, - e.getMessage(), - EppoValue.valueOf(defaultValue))); - } - } - - /** - * Returns assignment details for a JSON flag including comprehensive evaluation information. The - * variation value is returned as a JsonNode. - */ - public AssignmentDetails getJSONAssignmentDetails( - String flagKey, String subjectKey, JsonNode defaultValue) { - return this.getJSONAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); - } - - /** - * Returns assignment details for a JSON flag including comprehensive evaluation information. The - * variation value is returned as a JsonNode. - */ - public AssignmentDetails getJSONAssignmentDetails( - String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { - try { - // Get the string assignment details first - AssignmentDetails stringDetails = - this.getTypedAssignmentWithDetails( - flagKey, subjectKey, subjectAttributes, null, VariationType.JSON); - - String jsonString = stringDetails.getVariation(); - JsonNode resultValue = defaultValue; - if (jsonString != null) { - JsonNode parsed = parseJsonString(jsonString); - if (parsed != null) { - resultValue = parsed; - } - } - - return new AssignmentDetails<>( - resultValue, stringDetails.getAction(), stringDetails.getEvaluationDetails()); - } catch (Exception e) { - String defaultValueString = defaultValue != null ? defaultValue.toString() : null; - return new AssignmentDetails<>( - throwIfNotGraceful(e, defaultValue), - null, - buildDefaultEvaluationDetails( - FlagEvaluationCode.ASSIGNMENT_ERROR, - e.getMessage(), - EppoValue.valueOf(defaultValueString))); - } - } - - /** - * Returns assignment details for a JSON flag including comprehensive evaluation information. The - * variation value is returned as a JSON string. - */ - public AssignmentDetails getJSONStringAssignmentDetails( - String flagKey, String subjectKey, String defaultValue) { - return this.getJSONStringAssignmentDetails(flagKey, subjectKey, new Attributes(), defaultValue); - } - - /** - * Returns assignment details for a JSON flag including comprehensive evaluation information. The - * variation value is returned as a JSON string. - */ - public AssignmentDetails getJSONStringAssignmentDetails( - String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { - try { - return this.getTypedAssignmentWithDetails( - flagKey, subjectKey, subjectAttributes, defaultValue, VariationType.JSON); - } catch (Exception e) { - return new AssignmentDetails<>( - throwIfNotGraceful(e, defaultValue), - null, - buildDefaultEvaluationDetails( - FlagEvaluationCode.ASSIGNMENT_ERROR, - e.getMessage(), - EppoValue.valueOf(defaultValue))); - } - } - - public BanditResult getBanditAction( - String flagKey, - String subjectKey, - DiscriminableAttributes subjectAttributes, - Actions actions, - String defaultValue) { - BanditResult result = new BanditResult(defaultValue, null); - final Configuration config = getConfiguration(); - try { - String assignedVariation = - getStringAssignment( - flagKey, subjectKey, subjectAttributes.getAllAttributes(), defaultValue); - - // Update result to reflect that we've been assigned a variation - result = new BanditResult(assignedVariation, null); - - String banditKey = config.banditKeyForVariation(flagKey, assignedVariation); - if (banditKey != null && !actions.isEmpty()) { - BanditParameters banditParameters = config.getBanditParameters(banditKey); - BanditEvaluationResult banditResult = - BanditEvaluator.evaluateBandit( - flagKey, subjectKey, subjectAttributes, actions, banditParameters.getModelData()); - - // Update result to reflect that we've been assigned an action - result = new BanditResult(assignedVariation, banditResult.getActionKey()); - - if (banditLogger != null) { - try { - BanditAssignment banditAssignment = - new BanditAssignment( - flagKey, - banditKey, - subjectKey, - banditResult.getActionKey(), - banditResult.getActionWeight(), - banditResult.getOptimalityGap(), - banditParameters.getModelVersion(), - subjectAttributes.getNumericAttributes(), - subjectAttributes.getCategoricalAttributes(), - banditResult.getActionAttributes().getNumericAttributes(), - banditResult.getActionAttributes().getCategoricalAttributes(), - buildLogMetaData(config.isConfigObfuscated())); - - // Log, only if there is no cache hit. - boolean logBanditAssignment = true; - AssignmentCacheEntry cacheEntry = - AssignmentCacheEntry.fromBanditAssignment(banditAssignment); - if (banditAssignmentCache != null) { - if (banditAssignmentCache.hasEntry(cacheEntry)) { - logBanditAssignment = false; - } - } - - if (logBanditAssignment) { - banditLogger.logBanditAssignment(banditAssignment); - - if (banditAssignmentCache != null) { - banditAssignmentCache.put(cacheEntry); - } - } - } catch (Exception e) { - log.warn("Error logging bandit assignment: {}", e.getMessage(), e); - } - } - } - return result; - } catch (Exception e) { - return throwIfNotGraceful(e, result); - } - } - - /** - * Returns bandit action assignment with detailed evaluation information including flag evaluation - * details and bandit action selection. - */ - public AssignmentDetails getBanditActionDetails( - String flagKey, - String subjectKey, - DiscriminableAttributes subjectAttributes, - Actions actions, - String defaultValue) { - final Configuration config = getConfiguration(); - try { - // Get detailed flag assignment - AssignmentDetails flagDetails = - getStringAssignmentDetails( - flagKey, subjectKey, subjectAttributes.getAllAttributes(), defaultValue); - - String assignedVariation = flagDetails.getVariation(); - String assignedAction = null; - - // If we got a variation, check for bandit - String banditKey = config.banditKeyForVariation(flagKey, assignedVariation); - if (banditKey != null && !actions.isEmpty()) { - try { - BanditParameters banditParameters = config.getBanditParameters(banditKey); - if (banditParameters == null) { - throw new RuntimeException("Bandit parameters not found for bandit key: " + banditKey); - } - BanditEvaluationResult banditResult = - BanditEvaluator.evaluateBandit( - flagKey, subjectKey, subjectAttributes, actions, banditParameters.getModelData()); - - assignedAction = banditResult.getActionKey(); - - // Log bandit assignment if needed - if (banditLogger != null) { - try { - BanditAssignment banditAssignment = - new BanditAssignment( - flagKey, - banditKey, - subjectKey, - banditResult.getActionKey(), - banditResult.getActionWeight(), - banditResult.getOptimalityGap(), - banditParameters.getModelVersion(), - subjectAttributes.getNumericAttributes(), - subjectAttributes.getCategoricalAttributes(), - banditResult.getActionAttributes().getNumericAttributes(), - banditResult.getActionAttributes().getCategoricalAttributes(), - buildLogMetaData(config.isConfigObfuscated())); - - boolean logBanditAssignment = true; - AssignmentCacheEntry cacheEntry = - AssignmentCacheEntry.fromBanditAssignment(banditAssignment); - if (banditAssignmentCache != null) { - if (banditAssignmentCache.hasEntry(cacheEntry)) { - logBanditAssignment = false; - } - } - - if (logBanditAssignment) { - banditLogger.logBanditAssignment(banditAssignment); - if (banditAssignmentCache != null) { - banditAssignmentCache.put(cacheEntry); - } - } - } catch (Exception e) { - log.warn("Error logging bandit assignment: {}", e.getMessage(), e); - } - } - - // Update evaluation details to include bandit information - EvaluationDetails updatedDetails = - new EvaluationDetails( - flagDetails.getEvaluationDetails().getEnvironmentName(), - flagDetails.getEvaluationDetails().getFlagEvaluationCode(), - flagDetails.getEvaluationDetails().getFlagEvaluationDescription(), - banditKey, - assignedAction, - flagDetails.getEvaluationDetails().getVariationKey(), - flagDetails.getEvaluationDetails().getVariationValue(), - flagDetails.getEvaluationDetails().getMatchedRule(), - flagDetails.getEvaluationDetails().getMatchedAllocation(), - flagDetails.getEvaluationDetails().getUnmatchedAllocations(), - flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); - - return new AssignmentDetails<>(assignedVariation, assignedAction, updatedDetails); - } catch (Exception banditError) { - // Bandit evaluation failed - return flag details with BANDIT_ERROR code - log.warn( - "Bandit evaluation failed for flag {}: {}", - flagKey, - banditError.getMessage(), - banditError); - EvaluationDetails banditErrorDetails = - new EvaluationDetails( - flagDetails.getEvaluationDetails().getEnvironmentName(), - FlagEvaluationCode.BANDIT_ERROR, - "Bandit evaluation failed: " + banditError.getMessage(), - banditKey, - null, // no action assigned due to error - flagDetails.getEvaluationDetails().getVariationKey(), - flagDetails.getEvaluationDetails().getVariationValue(), - flagDetails.getEvaluationDetails().getMatchedRule(), - flagDetails.getEvaluationDetails().getMatchedAllocation(), - flagDetails.getEvaluationDetails().getUnmatchedAllocations(), - flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); - return new AssignmentDetails<>(assignedVariation, null, banditErrorDetails); - } - } - - // No bandit - return flag details as-is - return flagDetails; - } catch (Exception e) { - BanditResult defaultResult = new BanditResult(defaultValue, null); - AssignmentDetails errorDetails = - new AssignmentDetails<>( - defaultValue, - null, - buildDefaultEvaluationDetails( - FlagEvaluationCode.ASSIGNMENT_ERROR, - e.getMessage(), - EppoValue.valueOf(defaultValue))); - return throwIfNotGraceful(e, errorDetails); - } - } - - private Map buildLogMetaData(boolean isConfigObfuscated) { - HashMap metaData = new HashMap<>(); - metaData.put("obfuscated", Boolean.valueOf(isConfigObfuscated).toString()); - metaData.put("sdkLanguage", sdkName); - metaData.put("sdkLibVersion", sdkVersion); - return metaData; - } - - private T throwIfNotGraceful(Exception e, T defaultValue) { - if (this.isGracefulMode) { - log.info("error getting assignment value: {}", e.getMessage()); - return defaultValue; - } - throw new RuntimeException(e); - } - - public void setIsGracefulFailureMode(boolean isGracefulFailureMode) { - this.isGracefulMode = isGracefulFailureMode; - } - - /** - * Subscribe to changes to the configuration. - * - * @param callback A function to be executed when the configuration changes. - * @return a Runnable which, when called unsubscribes the callback from configuration change - * events. - */ - public Runnable onConfigurationChange(Consumer callback) { - return requestor.onConfigurationChange(callback); - } - - /** - * Returns the configuration object used by the EppoClient for assignment and bandit evaluation. - * - *

The configuration object is for debugging (inspect the loaded config) and other advanced use - * cases where flag metadata or a list of flag keys, for example, is required. - * - *

It is not recommended to use the list of keys to preload assignments as assignment - * computation also logs its use which will affect your metrics. - * - * @see Where To - * Assign for more details. - */ - public Configuration getConfiguration() { - return configurationStore.getConfiguration(); - } -} From 027c14aa2f19640e04f13c64be74aa4f188f7434 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Sun, 7 Dec 2025 21:03:51 -0500 Subject: [PATCH 03/11] include environment name, config fetched, and config created at in details --- src/main/java/cloud/eppo/BaseEppoClient.java | 313 ++++++++++-------- .../eppo/DetailedFlagEvaluationResult.java | 15 + src/main/java/cloud/eppo/FlagEvaluator.java | 10 +- .../java/cloud/eppo/api/Configuration.java | 99 +++++- .../cloud/eppo/api/EvaluationDetails.java | 15 + .../eppo/ufc/dto/FlagConfigResponse.java | 56 +++- .../FlagConfigResponseDeserializer.java | 15 +- .../java/cloud/eppo/BaseEppoClientTest.java | 27 +- .../java/cloud/eppo/FlagEvaluatorTest.java | 119 +++++-- .../eppo/api/ConfigurationBuilderTest.java | 18 +- .../AssignmentTestCaseDeserializer.java | 2 + 11 files changed, 487 insertions(+), 202 deletions(-) diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index d74b07f4..5359f30e 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -190,10 +190,11 @@ protected CompletableFuture loadConfigurationAsync() { return future; } - /** - * Top-level assignment details method that evaluates, logs if applicable, and returns the user-facing AssignmentDetails result class. - * If any error in the evaluation, the result value will be set to the default value. - */ + /** + * Top-level assignment details method that evaluates, logs if applicable, and returns the + * user-facing AssignmentDetails result class. If any error in the evaluation, the result value + * will be set to the default value. + */ protected AssignmentDetails getTypedAssignmentWithDetails( String flagKey, String subjectKey, @@ -204,7 +205,10 @@ protected AssignmentDetails getTypedAssignmentWithDetails( EvaluationDetails details = evaluateAndMaybeLog(flagKey, subjectKey, subjectAttributes, expectedType); - T resultValue = details.evaluationSuccessful() ? EppoValue.unwrap(details.getVariationValue(), expectedType) : defaultValue; + T resultValue = + details.evaluationSuccessful() + ? EppoValue.unwrap(details.getVariationValue(), expectedType) + : defaultValue; return new AssignmentDetails<>(resultValue, null, details); } @@ -226,6 +230,7 @@ protected EvaluationDetails evaluateAndMaybeLog( if (flag == null) { log.warn("no configuration found for key: {}", flagKey); return buildDefaultEvaluationDetails( + config, FlagEvaluationCode.FLAG_UNRECOGNIZED_OR_DISABLED, "Unrecognized or disabled flag: " + flagKey, null); @@ -236,6 +241,7 @@ protected EvaluationDetails evaluateAndMaybeLog( log.info( "no assigned variation because the experiment or feature flag is disabled: {}", flagKey); return buildDefaultEvaluationDetails( + config, FlagEvaluationCode.FLAG_UNRECOGNIZED_OR_DISABLED, "Unrecognized or disabled flag: " + flagKey, null); @@ -249,6 +255,7 @@ protected EvaluationDetails evaluateAndMaybeLog( flag.getVariationType(), expectedType); return buildDefaultEvaluationDetails( + config, FlagEvaluationCode.TYPE_MISMATCH, String.format( "Flag \"%s\" has type %s, requested %s", @@ -259,7 +266,14 @@ protected EvaluationDetails evaluateAndMaybeLog( // Evaluate flag with details DetailedFlagEvaluationResult detailedResult = FlagEvaluator.evaluateFlagWithDetails( - flag, flagKey, subjectKey, subjectAttributes, config.isConfigObfuscated()); + flag, + flagKey, + subjectKey, + subjectAttributes, + config.isConfigObfuscated(), + config.getEnvironmentName(), + config.getConfigFetchedAt(), + config.getConfigPublishedAt()); EvaluationDetails evaluationDetails = detailedResult.getEvaluationDetails(); EppoValue assignedValue = @@ -284,6 +298,8 @@ protected EvaluationDetails evaluateAndMaybeLog( return new EvaluationDetails( evaluationDetails.getEnvironmentName(), + evaluationDetails.getConfigFetchedAt(), + evaluationDetails.getConfigPublishedAt(), FlagEvaluationCode .ASSIGNMENT_ERROR, // We use ASSIGNMENT_ERROR for value mismatch as it's a // misconfiguration of the flag itself @@ -398,6 +414,7 @@ public AssignmentDetails getBooleanAssignmentDetails( throwIfNotGraceful(e, defaultValue), null, buildDefaultEvaluationDetails( + getConfiguration(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), EppoValue.valueOf(defaultValue))); @@ -429,6 +446,7 @@ public AssignmentDetails getIntegerAssignmentDetails( throwIfNotGraceful(e, defaultValue), null, buildDefaultEvaluationDetails( + getConfiguration(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), EppoValue.valueOf(defaultValue))); @@ -460,6 +478,7 @@ public AssignmentDetails getDoubleAssignmentDetails( throwIfNotGraceful(e, defaultValue), null, buildDefaultEvaluationDetails( + getConfiguration(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), EppoValue.valueOf(defaultValue))); @@ -491,6 +510,7 @@ public AssignmentDetails getStringAssignmentDetails( throwIfNotGraceful(e, defaultValue), null, buildDefaultEvaluationDetails( + getConfiguration(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), EppoValue.valueOf(defaultValue))); @@ -523,6 +543,7 @@ public AssignmentDetails getJSONAssignmentDetails( throwIfNotGraceful(e, defaultValue), null, buildDefaultEvaluationDetails( + getConfiguration(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), EppoValue.valueOf(defaultValueString))); @@ -554,6 +575,7 @@ public AssignmentDetails getJSONStringAssignmentDetails( throwIfNotGraceful(e, defaultValue), null, buildDefaultEvaluationDetails( + getConfiguration(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), EppoValue.valueOf(defaultValue))); @@ -631,149 +653,156 @@ public BanditResult getBanditAction( } } - /** - * Returns bandit action assignment with detailed evaluation information including flag evaluation - * details and bandit action selection. - */ - public AssignmentDetails getBanditActionDetails( - String flagKey, - String subjectKey, - DiscriminableAttributes subjectAttributes, - Actions actions, - String defaultValue) { - final Configuration config = getConfiguration(); + /** + * Returns bandit action assignment with detailed evaluation information including flag evaluation + * details and bandit action selection. + */ + public AssignmentDetails getBanditActionDetails( + String flagKey, + String subjectKey, + DiscriminableAttributes subjectAttributes, + Actions actions, + String defaultValue) { + final Configuration config = getConfiguration(); + try { + // Get detailed flag assignment + AssignmentDetails flagDetails = + getStringAssignmentDetails( + flagKey, subjectKey, subjectAttributes.getAllAttributes(), defaultValue); + + String assignedVariation = flagDetails.getVariation(); + String assignedAction = null; + + // If we got a variation, check for bandit + String banditKey = config.banditKeyForVariation(flagKey, assignedVariation); + if (banditKey != null && !actions.isEmpty()) { try { - // Get detailed flag assignment - AssignmentDetails flagDetails = - getStringAssignmentDetails( - flagKey, subjectKey, subjectAttributes.getAllAttributes(), defaultValue); - - String assignedVariation = flagDetails.getVariation(); - String assignedAction = null; - - // If we got a variation, check for bandit - String banditKey = config.banditKeyForVariation(flagKey, assignedVariation); - if (banditKey != null && !actions.isEmpty()) { - try { - BanditParameters banditParameters = config.getBanditParameters(banditKey); - if (banditParameters == null) { - throw new RuntimeException("Bandit parameters not found for bandit key: " + banditKey); - } - BanditEvaluationResult banditResult = - BanditEvaluator.evaluateBandit( - flagKey, subjectKey, subjectAttributes, actions, banditParameters.getModelData()); - - assignedAction = banditResult.getActionKey(); - - // Log bandit assignment if needed - if (banditLogger != null) { - try { - BanditAssignment banditAssignment = - new BanditAssignment( - flagKey, - banditKey, - subjectKey, - banditResult.getActionKey(), - banditResult.getActionWeight(), - banditResult.getOptimalityGap(), - banditParameters.getModelVersion(), - subjectAttributes.getNumericAttributes(), - subjectAttributes.getCategoricalAttributes(), - banditResult.getActionAttributes().getNumericAttributes(), - banditResult.getActionAttributes().getCategoricalAttributes(), - buildLogMetaData(config.isConfigObfuscated())); - - boolean logBanditAssignment = true; - AssignmentCacheEntry cacheEntry = - AssignmentCacheEntry.fromBanditAssignment(banditAssignment); - if (banditAssignmentCache != null) { - if (banditAssignmentCache.hasEntry(cacheEntry)) { - logBanditAssignment = false; - } - } - - if (logBanditAssignment) { - banditLogger.logBanditAssignment(banditAssignment); - if (banditAssignmentCache != null) { - banditAssignmentCache.put(cacheEntry); - } - } - } catch (Exception e) { - log.warn("Error logging bandit assignment: {}", e.getMessage(), e); - } - } - - // Update evaluation details to include bandit information - EvaluationDetails updatedDetails = - new EvaluationDetails( - flagDetails.getEvaluationDetails().getEnvironmentName(), - flagDetails.getEvaluationDetails().getFlagEvaluationCode(), - flagDetails.getEvaluationDetails().getFlagEvaluationDescription(), - banditKey, - assignedAction, - flagDetails.getEvaluationDetails().getVariationKey(), - flagDetails.getEvaluationDetails().getVariationValue(), - flagDetails.getEvaluationDetails().getMatchedRule(), - flagDetails.getEvaluationDetails().getMatchedAllocation(), - flagDetails.getEvaluationDetails().getUnmatchedAllocations(), - flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); - - return new AssignmentDetails<>(assignedVariation, assignedAction, updatedDetails); - } catch (Exception banditError) { - // Bandit evaluation failed - return flag details with BANDIT_ERROR code - log.warn( - "Bandit evaluation failed for flag {}: {}", - flagKey, - banditError.getMessage(), - banditError); - EvaluationDetails banditErrorDetails = - new EvaluationDetails( - flagDetails.getEvaluationDetails().getEnvironmentName(), - FlagEvaluationCode.BANDIT_ERROR, - "Bandit evaluation failed: " + banditError.getMessage(), - banditKey, - null, // no action assigned due to error - flagDetails.getEvaluationDetails().getVariationKey(), - flagDetails.getEvaluationDetails().getVariationValue(), - flagDetails.getEvaluationDetails().getMatchedRule(), - flagDetails.getEvaluationDetails().getMatchedAllocation(), - flagDetails.getEvaluationDetails().getUnmatchedAllocations(), - flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); - return new AssignmentDetails<>(assignedVariation, null, banditErrorDetails); + BanditParameters banditParameters = config.getBanditParameters(banditKey); + if (banditParameters == null) { + throw new RuntimeException("Bandit parameters not found for bandit key: " + banditKey); + } + BanditEvaluationResult banditResult = + BanditEvaluator.evaluateBandit( + flagKey, subjectKey, subjectAttributes, actions, banditParameters.getModelData()); + + assignedAction = banditResult.getActionKey(); + + // Log bandit assignment if needed + if (banditLogger != null) { + try { + BanditAssignment banditAssignment = + new BanditAssignment( + flagKey, + banditKey, + subjectKey, + banditResult.getActionKey(), + banditResult.getActionWeight(), + banditResult.getOptimalityGap(), + banditParameters.getModelVersion(), + subjectAttributes.getNumericAttributes(), + subjectAttributes.getCategoricalAttributes(), + banditResult.getActionAttributes().getNumericAttributes(), + banditResult.getActionAttributes().getCategoricalAttributes(), + buildLogMetaData(config.isConfigObfuscated())); + + boolean logBanditAssignment = true; + AssignmentCacheEntry cacheEntry = + AssignmentCacheEntry.fromBanditAssignment(banditAssignment); + if (banditAssignmentCache != null) { + if (banditAssignmentCache.hasEntry(cacheEntry)) { + logBanditAssignment = false; } + } + + if (logBanditAssignment) { + banditLogger.logBanditAssignment(banditAssignment); + if (banditAssignmentCache != null) { + banditAssignmentCache.put(cacheEntry); + } + } + } catch (Exception e) { + log.warn("Error logging bandit assignment: {}", e.getMessage(), e); } + } - // No bandit - return flag details as-is - return flagDetails; - } catch (Exception e) { - BanditResult defaultResult = new BanditResult(defaultValue, null); - AssignmentDetails errorDetails = - new AssignmentDetails<>( - defaultValue, - null, - buildDefaultEvaluationDetails( - FlagEvaluationCode.ASSIGNMENT_ERROR, - e.getMessage(), - EppoValue.valueOf(defaultValue))); - return throwIfNotGraceful(e, errorDetails); + // Update evaluation details to include bandit information + EvaluationDetails updatedDetails = + new EvaluationDetails( + flagDetails.getEvaluationDetails().getEnvironmentName(), + flagDetails.getEvaluationDetails().getConfigFetchedAt(), + flagDetails.getEvaluationDetails().getConfigPublishedAt(), + flagDetails.getEvaluationDetails().getFlagEvaluationCode(), + flagDetails.getEvaluationDetails().getFlagEvaluationDescription(), + banditKey, + assignedAction, + flagDetails.getEvaluationDetails().getVariationKey(), + flagDetails.getEvaluationDetails().getVariationValue(), + flagDetails.getEvaluationDetails().getMatchedRule(), + flagDetails.getEvaluationDetails().getMatchedAllocation(), + flagDetails.getEvaluationDetails().getUnmatchedAllocations(), + flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); + + return new AssignmentDetails<>(assignedVariation, assignedAction, updatedDetails); + } catch (Exception banditError) { + // Bandit evaluation failed - return flag details with BANDIT_ERROR code + log.warn( + "Bandit evaluation failed for flag {}: {}", + flagKey, + banditError.getMessage(), + banditError); + EvaluationDetails banditErrorDetails = + new EvaluationDetails( + flagDetails.getEvaluationDetails().getEnvironmentName(), + flagDetails.getEvaluationDetails().getConfigFetchedAt(), + flagDetails.getEvaluationDetails().getConfigPublishedAt(), + FlagEvaluationCode.BANDIT_ERROR, + "Bandit evaluation failed: " + banditError.getMessage(), + banditKey, + null, // no action assigned due to error + flagDetails.getEvaluationDetails().getVariationKey(), + flagDetails.getEvaluationDetails().getVariationValue(), + flagDetails.getEvaluationDetails().getMatchedRule(), + flagDetails.getEvaluationDetails().getMatchedAllocation(), + flagDetails.getEvaluationDetails().getUnmatchedAllocations(), + flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); + return new AssignmentDetails<>(assignedVariation, null, banditErrorDetails); } - } + } - private EvaluationDetails buildDefaultEvaluationDetails( - FlagEvaluationCode code, String description, EppoValue variationValue) { - return new EvaluationDetails( - "Test", // Environment name - matches FlagEvaluator default - code, - description, - null, // banditKey - null, // banditAction - null, // variationKey - variationValue, - null, // matchedRule - null, // matchedAllocation - new ArrayList<>(), // unmatchedAllocations - new ArrayList<>()); // unevaluatedAllocations + // No bandit - return flag details as-is + return flagDetails; + } catch (Exception e) { + BanditResult defaultResult = new BanditResult(defaultValue, null); + AssignmentDetails errorDetails = + new AssignmentDetails<>( + defaultValue, + null, + buildDefaultEvaluationDetails( + config, + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); + return throwIfNotGraceful(e, errorDetails); } + } + + private EvaluationDetails buildDefaultEvaluationDetails( + Configuration config, FlagEvaluationCode code, String description, EppoValue variationValue) { + return new EvaluationDetails( + config.getEnvironmentName() != null ? config.getEnvironmentName() : "Test", + config.getConfigFetchedAt(), + config.getConfigPublishedAt(), + code, + description, + null, // banditKey + null, // banditAction + null, // variationKey + variationValue, + null, // matchedRule + null, // matchedAllocation + new ArrayList<>(), // unmatchedAllocations + new ArrayList<>()); // unevaluatedAllocations + } private Map buildLogMetaData(boolean isConfigObfuscated) { HashMap metaData = new HashMap<>(); diff --git a/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java b/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java index 9de27242..c1d9668a 100644 --- a/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java +++ b/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java @@ -3,6 +3,7 @@ import cloud.eppo.api.*; import cloud.eppo.ufc.dto.Variation; import java.util.ArrayList; +import java.util.Date; import java.util.List; import java.util.Map; @@ -50,6 +51,8 @@ public static class Builder { private AllocationDetails matchedAllocation; private final List unmatchedAllocations = new ArrayList<>(); private final List unevaluatedAllocations = new ArrayList<>(); + private Date configFetchedAt; + private Date configPublishedAt; public Builder flagKey(String flagKey) { this.flagKey = flagKey; @@ -131,6 +134,16 @@ public Builder addUnevaluatedAllocation(AllocationDetails allocation) { return this; } + public Builder configFetchedAt(Date configFetchedAt) { + this.configFetchedAt = configFetchedAt; + return this; + } + + public Builder configPublishedAt(Date configPublishedAt) { + this.configPublishedAt = configPublishedAt; + return this; + } + public DetailedFlagEvaluationResult build() { // Build evaluation details String variationKey = variation != null ? variation.getKey() : null; @@ -139,6 +152,8 @@ public DetailedFlagEvaluationResult build() { EvaluationDetails details = new EvaluationDetails( environmentName, + configFetchedAt, + configPublishedAt, flagEvaluationCode, flagEvaluationDescription, banditKey, diff --git a/src/main/java/cloud/eppo/FlagEvaluator.java b/src/main/java/cloud/eppo/FlagEvaluator.java index 1bfd6279..c19e714c 100644 --- a/src/main/java/cloud/eppo/FlagEvaluator.java +++ b/src/main/java/cloud/eppo/FlagEvaluator.java @@ -76,7 +76,10 @@ public static DetailedFlagEvaluationResult evaluateFlagWithDetails( String flagKey, String subjectKey, Attributes subjectAttributes, - boolean isConfigObfuscated) { + boolean isConfigObfuscated, + String environmentName, + Date configFetchedAt, + Date configPublishedAt) { Date now = new Date(); DetailedFlagEvaluationResult.Builder builder = @@ -84,7 +87,10 @@ public static DetailedFlagEvaluationResult evaluateFlagWithDetails( .flagKey(flagKey) .subjectKey(subjectKey) .subjectAttributes(subjectAttributes) - .extraLogging(new HashMap<>()); + .extraLogging(new HashMap<>()) + .environmentName(environmentName != null ? environmentName : "Unknown") + .configFetchedAt(configFetchedAt) + .configPublishedAt(configPublishedAt); // Handle disabled flag if (!flag.isEnabled()) { diff --git a/src/main/java/cloud/eppo/api/Configuration.java b/src/main/java/cloud/eppo/api/Configuration.java index 8634a3ac..80bcde6a 100644 --- a/src/main/java/cloud/eppo/api/Configuration.java +++ b/src/main/java/cloud/eppo/api/Configuration.java @@ -10,6 +10,7 @@ import java.io.*; import java.util.Arrays; import java.util.Collections; +import java.util.Date; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -62,6 +63,9 @@ public class Configuration { private final Map flags; private final Map bandits; private final boolean isConfigObfuscated; + private final String environmentName; + private final Date configFetchedAt; + private final Date configPublishedAt; @SuppressWarnings("unused") private final byte[] flagConfigJson; @@ -74,12 +78,18 @@ public class Configuration { Map banditReferences, Map bandits, boolean isConfigObfuscated, + String environmentName, + Date configFetchedAt, + Date configPublishedAt, byte[] flagConfigJson, byte[] banditParamsJson) { this.flags = flags; this.banditReferences = banditReferences; this.bandits = bandits; this.isConfigObfuscated = isConfigObfuscated; + this.environmentName = environmentName; + this.configFetchedAt = configFetchedAt; + this.configPublishedAt = configPublishedAt; // Graft the `forServer` boolean into the flagConfigJson' if (flagConfigJson != null && flagConfigJson.length != 0) { @@ -105,20 +115,36 @@ public static Configuration emptyConfig() { Collections.emptyMap(), Collections.emptyMap(), false, + null, // environmentName + null, // configFetchedAt + null, // configPublishedAt emptyFlagsBytes, null); } @Override public String toString() { - return "Configuration{" + - "banditReferences=" + banditReferences + - ", flags=" + flags + - ", bandits=" + bandits + - ", isConfigObfuscated=" + isConfigObfuscated + - ", flagConfigJson=" + Arrays.toString(flagConfigJson) + - ", banditParamsJson=" + Arrays.toString(banditParamsJson) + - '}'; + return "Configuration{" + + "banditReferences=" + + banditReferences + + ", flags=" + + flags + + ", bandits=" + + bandits + + ", isConfigObfuscated=" + + isConfigObfuscated + + ", environmentName='" + + environmentName + + '\'' + + ", configFetchedAt=" + + configFetchedAt + + ", configPublishedAt=" + + configPublishedAt + + ", flagConfigJson=" + + Arrays.toString(flagConfigJson) + + ", banditParamsJson=" + + Arrays.toString(banditParamsJson) + + '}'; } @Override @@ -126,16 +152,28 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; Configuration that = (Configuration) o; return isConfigObfuscated == that.isConfigObfuscated - && Objects.equals(banditReferences, that.banditReferences) - && Objects.equals(flags, that.flags) - && Objects.equals(bandits, that.bandits) - && Objects.deepEquals(flagConfigJson, that.flagConfigJson) - && Objects.deepEquals(banditParamsJson, that.banditParamsJson); + && Objects.equals(banditReferences, that.banditReferences) + && Objects.equals(flags, that.flags) + && Objects.equals(bandits, that.bandits) + && Objects.equals(environmentName, that.environmentName) + && Objects.equals(configFetchedAt, that.configFetchedAt) + && Objects.equals(configPublishedAt, that.configPublishedAt) + && Objects.deepEquals(flagConfigJson, that.flagConfigJson) + && Objects.deepEquals(banditParamsJson, that.banditParamsJson); } @Override public int hashCode() { - return Objects.hash(banditReferences, flags, bandits, isConfigObfuscated, Arrays.hashCode(flagConfigJson), Arrays.hashCode(banditParamsJson)); + return Objects.hash( + banditReferences, + flags, + bandits, + isConfigObfuscated, + environmentName, + configFetchedAt, + configPublishedAt, + Arrays.hashCode(flagConfigJson), + Arrays.hashCode(banditParamsJson)); } public FlagConfig getFlag(String flagKey) { @@ -205,6 +243,18 @@ public Set getFlagKeys() { return flags == null ? Collections.emptySet() : flags.keySet(); } + public String getEnvironmentName() { + return environmentName; + } + + public Date getConfigFetchedAt() { + return configFetchedAt; + } + + public Date getConfigPublishedAt() { + return configPublishedAt; + } + public static Builder builder(byte[] flagJson) { return new Builder(flagJson); } @@ -226,6 +276,8 @@ public static class Builder { private Map bandits = Collections.emptyMap(); private final byte[] flagJson; private byte[] banditParamsJson; + private final String environmentName; + private final Date configPublishedAt; private static FlagConfigResponse parseFlagResponse(byte[] flagJson) { if (flagJson == null || flagJson.length == 0) { @@ -274,9 +326,16 @@ public Builder( log.warn("'flags' map missing in flag definition JSON"); flags = Collections.emptyMap(); banditReferences = Collections.emptyMap(); + environmentName = null; + configPublishedAt = null; } else { flags = Collections.unmodifiableMap(flagConfigResponse.getFlags()); banditReferences = Collections.unmodifiableMap(flagConfigResponse.getBanditReferences()); + + // Extract environment name and published at timestamp from the response + environmentName = flagConfigResponse.getEnvironmentName(); + configPublishedAt = flagConfigResponse.getCreatedAt(); + log.debug("Loaded {} flag definitions from flag definition JSON", flags.size()); } } @@ -337,8 +396,18 @@ public Builder banditParameters(byte[] banditParameterJson) { } public Configuration build() { + // Record the time when configuration is built/fetched + Date configFetchedAt = new Date(); return new Configuration( - flags, banditReferences, bandits, isConfigObfuscated, flagJson, banditParamsJson); + flags, + banditReferences, + bandits, + isConfigObfuscated, + environmentName, + configFetchedAt, + configPublishedAt, + flagJson, + banditParamsJson); } } } diff --git a/src/main/java/cloud/eppo/api/EvaluationDetails.java b/src/main/java/cloud/eppo/api/EvaluationDetails.java index 00c54b32..23f86a65 100644 --- a/src/main/java/cloud/eppo/api/EvaluationDetails.java +++ b/src/main/java/cloud/eppo/api/EvaluationDetails.java @@ -1,5 +1,6 @@ package cloud.eppo.api; +import java.util.Date; import java.util.List; /** @@ -19,9 +20,13 @@ public class EvaluationDetails { private final AllocationDetails matchedAllocation; private final List unmatchedAllocations; private final List unevaluatedAllocations; + private final Date configFetchedAt; + private final Date configPublishedAt; public EvaluationDetails( String environmentName, + Date configFetchedAt, + Date configPublishedAt, FlagEvaluationCode flagEvaluationCode, String flagEvaluationDescription, String banditKey, @@ -33,6 +38,8 @@ public EvaluationDetails( List unmatchedAllocations, List unevaluatedAllocations) { this.environmentName = environmentName; + this.configFetchedAt = configFetchedAt; + this.configPublishedAt = configPublishedAt; this.flagEvaluationCode = flagEvaluationCode; this.flagEvaluationDescription = flagEvaluationDescription; this.banditKey = banditKey; @@ -49,6 +56,14 @@ public String getEnvironmentName() { return environmentName; } + public Date getConfigFetchedAt() { + return configFetchedAt; + } + + public Date getConfigPublishedAt() { + return configPublishedAt; + } + public FlagEvaluationCode getFlagEvaluationCode() { return flagEvaluationCode; } diff --git a/src/main/java/cloud/eppo/ufc/dto/FlagConfigResponse.java b/src/main/java/cloud/eppo/ufc/dto/FlagConfigResponse.java index cb9e35ba..118cffd9 100644 --- a/src/main/java/cloud/eppo/ufc/dto/FlagConfigResponse.java +++ b/src/main/java/cloud/eppo/ufc/dto/FlagConfigResponse.java @@ -1,5 +1,6 @@ package cloud.eppo.ufc.dto; +import java.util.Date; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; @@ -8,32 +9,53 @@ public class FlagConfigResponse { private final Map flags; private final Map banditReferences; private final Format format; + private final String environmentName; + private final Date createdAt; public FlagConfigResponse( Map flags, Map banditReferences, - Format dataFormat) { + Format dataFormat, + String environmentName, + Date createdAt) { this.flags = flags; this.banditReferences = banditReferences; - format = dataFormat; + this.format = dataFormat; + this.environmentName = environmentName; + this.createdAt = createdAt; + } + + public FlagConfigResponse( + Map flags, + Map banditReferences, + Format dataFormat) { + this(flags, banditReferences, dataFormat, null, null); } public FlagConfigResponse( Map flags, Map banditReferences) { - this(flags, banditReferences, Format.SERVER); + this(flags, banditReferences, Format.SERVER, null, null); } public FlagConfigResponse() { - this(new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), Format.SERVER); + this(new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), Format.SERVER, null, null); } @Override public String toString() { - return "FlagConfigResponse{" + - "flags=" + flags + - ", banditReferences=" + banditReferences + - ", format=" + format + - '}'; + return "FlagConfigResponse{" + + "flags=" + + flags + + ", banditReferences=" + + banditReferences + + ", format=" + + format + + ", environmentName='" + + environmentName + + '\'' + + ", createdAt=" + + createdAt + + '}'; } @Override @@ -41,13 +63,15 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; FlagConfigResponse that = (FlagConfigResponse) o; return Objects.equals(flags, that.flags) - && Objects.equals(banditReferences, that.banditReferences) - && format == that.format; + && Objects.equals(banditReferences, that.banditReferences) + && format == that.format + && Objects.equals(environmentName, that.environmentName) + && Objects.equals(createdAt, that.createdAt); } @Override public int hashCode() { - return Objects.hash(flags, banditReferences, format); + return Objects.hash(flags, banditReferences, format, environmentName, createdAt); } public Map getFlags() { @@ -62,6 +86,14 @@ public Format getFormat() { return format; } + public String getEnvironmentName() { + return environmentName; + } + + public Date getCreatedAt() { + return createdAt; + } + public enum Format { SERVER, CLIENT diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java index 2daabefe..e63aa1fe 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java +++ b/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java @@ -54,6 +54,19 @@ public FlagConfigResponse deserialize(JsonParser jp, DeserializationContext ctxt ? FlagConfigResponse.Format.SERVER : FlagConfigResponse.Format.valueOf(formatNode.asText()); + // Parse environment name from environment object + String environmentName = null; + JsonNode environmentNode = rootNode.get("environment"); + if (environmentNode != null && environmentNode.isObject()) { + JsonNode nameNode = environmentNode.get("name"); + if (nameNode != null) { + environmentName = nameNode.asText(); + } + } + + // Parse createdAt + Date createdAt = parseUtcISODateNode(rootNode.get("createdAt")); + Map flags = new ConcurrentHashMap<>(); flagsNode @@ -80,7 +93,7 @@ public FlagConfigResponse deserialize(JsonParser jp, DeserializationContext ctxt } } - return new FlagConfigResponse(flags, banditReferences, dataFormat); + return new FlagConfigResponse(flags, banditReferences, dataFormat, environmentName, createdAt); } private FlagConfig deserializeFlag(JsonNode jsonNode) { diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java index e2d49960..c51f17d2 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientTest.java @@ -661,10 +661,29 @@ public void testAssignmentLogErrorNonFatal() { doThrow(new RuntimeException("Mock Assignment Logging Error")) .when(mockAssignmentLogger) .logAssignment(any()); - double assignment = - eppoClient.getDoubleAssignment("numeric_flag", "alice", new Attributes(), 0.0); - - assertEquals(3.1415926, assignment, 0.0000001); + AssignmentDetails assignmentDetails = + eppoClient.getDoubleAssignmentDetails("numeric_flag", "alice", new Attributes(), 0.0); + + assertEquals(3.1415926, assignmentDetails.getVariation(), 0.0000001); + + // Verify evaluation details are populated correctly + EvaluationDetails details = assignmentDetails.getEvaluationDetails(); + assertNotNull(details); + assertEquals(FlagEvaluationCode.MATCH, details.getFlagEvaluationCode()); + assertNotNull(details.getEnvironmentName()); + assertEquals("Test", details.getEnvironmentName()); + + // Verify config timestamps + assertNotNull(details.getConfigPublishedAt()); + assertNotNull(details.getConfigFetchedAt()); + // Published at should be Wed Apr 17 15:40:53 EDT 2024 (from test JSON) + Date expectedPublishedAt = + new Date(1713382853716L); // 2024-04-17T19:40:53.716Z; matches flags-v1.json + assertEquals(expectedPublishedAt, details.getConfigPublishedAt()); + // Fetched at should be after published at (it's set when config is built) + assertTrue( + details.getConfigFetchedAt().after(details.getConfigPublishedAt()), + "Config fetched at should be after config published at"); ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); diff --git a/src/test/java/cloud/eppo/FlagEvaluatorTest.java b/src/test/java/cloud/eppo/FlagEvaluatorTest.java index e74a9119..3f895414 100644 --- a/src/test/java/cloud/eppo/FlagEvaluatorTest.java +++ b/src/test/java/cloud/eppo/FlagEvaluatorTest.java @@ -6,6 +6,7 @@ import cloud.eppo.api.Attributes; import cloud.eppo.api.EppoValue; +import cloud.eppo.api.EvaluationDetails; import cloud.eppo.model.ShardRange; import cloud.eppo.ufc.dto.Allocation; import cloud.eppo.ufc.dto.FlagConfig; @@ -37,8 +38,22 @@ public void testDisabledFlag() { List splits = createSplits("a", shards); List allocations = createAllocations("allocation", splits); FlagConfig flag = createFlag("flag", false, variations, allocations); + + // Create test metadata values + String testEnvironmentName = "Production"; + Date testConfigFetchedAt = new Date(1672531200000L); // Jan 1, 2023 + Date testConfigPublishedAt = new Date(1672444800000L); // Dec 31, 2022 + DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", new Attributes(), false); + FlagEvaluator.evaluateFlagWithDetails( + flag, + "flag", + "subjectKey", + new Attributes(), + false, + testEnvironmentName, + testConfigFetchedAt, + testConfigPublishedAt); assertEquals(flag.getKey(), result.getFlagKey()); assertEquals("subjectKey", result.getSubjectKey()); @@ -46,6 +61,13 @@ public void testDisabledFlag() { assertNull(result.getAllocationKey()); assertNull(result.getVariation()); assertFalse(result.doLog()); + + // Verify configuration metadata flows through to evaluation details + EvaluationDetails details = result.getEvaluationDetails(); + assertNotNull(details); + assertEquals("Production", details.getEnvironmentName()); + assertEquals(testConfigFetchedAt, details.getConfigFetchedAt()); + assertEquals(testConfigPublishedAt, details.getConfigPublishedAt()); } @Test @@ -53,7 +75,8 @@ public void testNoAllocations() { Map variations = createVariations("a"); FlagConfig flag = createFlag("flag", true, variations, null); DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", new Attributes(), false); + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "subjectKey", new Attributes(), false, null, null, null); assertEquals(flag.getKey(), result.getFlagKey()); assertEquals("subjectKey", result.getSubjectKey()); @@ -70,8 +93,22 @@ public void testSimpleFlag() { List splits = createSplits("a", shards); List allocations = createAllocations("allocation", splits); FlagConfig flag = createFlag("flag", true, variations, allocations); + + // Create test metadata values + String testEnvironmentName = "Staging"; + Date testConfigFetchedAt = new Date(1672617600000L); // Jan 2, 2023 + Date testConfigPublishedAt = new Date(1672531200000L); // Jan 1, 2023 + DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", new Attributes(), false); + FlagEvaluator.evaluateFlagWithDetails( + flag, + "flag", + "subjectKey", + new Attributes(), + false, + testEnvironmentName, + testConfigFetchedAt, + testConfigPublishedAt); assertEquals(flag.getKey(), result.getFlagKey()); assertEquals("subjectKey", result.getSubjectKey()); @@ -79,6 +116,13 @@ public void testSimpleFlag() { assertEquals("allocation", result.getAllocationKey()); assertEquals("A", result.getVariation().getValue().stringValue()); assertTrue(result.doLog()); + + // Verify configuration metadata flows through to evaluation details + EvaluationDetails details = result.getEvaluationDetails(); + assertNotNull(details); + assertEquals("Staging", details.getEnvironmentName()); + assertEquals(testConfigFetchedAt, details.getConfigFetchedAt()); + assertEquals(testConfigPublishedAt, details.getConfigPublishedAt()); } @Test @@ -98,15 +142,20 @@ public void testIDTargetingCondition() { // Check that subjectKey is evaluated as the "id" attribute DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "alice", new Attributes(), false); + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "alice", new Attributes(), false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "bob", new Attributes(), false); + result = + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "bob", new Attributes(), false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "charlie", new Attributes(), false); + result = + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "charlie", new Attributes(), false, null, null, null); assertNull(result.getVariation()); @@ -114,14 +163,18 @@ public void testIDTargetingCondition() { Attributes aliceAttributes = new Attributes(); aliceAttributes.put("id", "charlie"); - result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "alice", aliceAttributes, false); + result = + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "alice", aliceAttributes, false, null, null, null); assertNull(result.getVariation()); Attributes charlieAttributes = new Attributes(); charlieAttributes.put("id", "alice"); - result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "charlie", charlieAttributes, false); + result = + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "charlie", charlieAttributes, false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); } @@ -134,7 +187,8 @@ public void testCatchAllAllocation() { FlagConfig flag = createFlag("key", true, variations, allocations); DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", new Attributes(), false); + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "subjectKey", new Attributes(), false, null, null, null); assertEquals("default", result.getAllocationKey()); assertEquals("A", result.getVariation().getValue().stringValue()); @@ -156,15 +210,20 @@ public void testMultipleAllocations() { Attributes matchingEmailAttributes = new Attributes(); matchingEmailAttributes.put("email", "eppo@example.com"); DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", matchingEmailAttributes, false); + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "subjectKey", matchingEmailAttributes, false, null, null, null); assertEquals("B", result.getVariation().getValue().stringValue()); Attributes unknownEmailAttributes = new Attributes(); unknownEmailAttributes.put("email", "eppo@test.com"); - result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", unknownEmailAttributes, false); + result = + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "subjectKey", unknownEmailAttributes, false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subjectKey", new Attributes(), false); + result = + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "subjectKey", new Attributes(), false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); } @@ -189,15 +248,20 @@ public void testVariationShardRanges() { FlagConfig flag = createFlag("key", true, variations, allocations); DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subject4", new Attributes(), false); + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "subject4", new Attributes(), false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subject13", new Attributes(), false); + result = + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "subject13", new Attributes(), false, null, null, null); assertEquals("B", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subject14", new Attributes(), false); + result = + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "subject14", new Attributes(), false, null, null, null); assertEquals("C", result.getVariation().getValue().stringValue()); } @@ -220,7 +284,8 @@ public void testAllocationStartAndEndAt() { allocation.setEndAt(endAt); DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subject", new Attributes(), false); + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "subject", new Attributes(), false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); assertTrue(result.doLog()); @@ -229,7 +294,9 @@ public void testAllocationStartAndEndAt() { allocation.setStartAt(new Date(now.getTime() + oneDayInMilliseconds)); allocation.setEndAt(new Date(now.getTime() + 2 * oneDayInMilliseconds)); - result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subject", new Attributes(), false); + result = + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "subject", new Attributes(), false, null, null, null); assertNull(result.getVariation()); assertFalse(result.doLog()); @@ -238,7 +305,9 @@ public void testAllocationStartAndEndAt() { allocation.setStartAt(new Date(now.getTime() - 2 * oneDayInMilliseconds)); allocation.setEndAt(new Date(now.getTime() - oneDayInMilliseconds)); - result = FlagEvaluator.evaluateFlagWithDetails(flag, "flag", "subject", new Attributes(), false); + result = + FlagEvaluator.evaluateFlagWithDetails( + flag, "flag", "subject", new Attributes(), false, null, null, null); assertNull(result.getVariation()); assertFalse(result.doLog()); @@ -334,7 +403,7 @@ public void testObfuscated() { encodedAllocations); DetailedFlagEvaluationResult result = FlagEvaluator.evaluateFlagWithDetails( - obfuscatedFlag, "flag", "subjectKey", matchingEmailAttributes, true); + obfuscatedFlag, "flag", "subjectKey", matchingEmailAttributes, true, null, null, null); // Expect an unobfuscated evaluation result assertEquals("flag", result.getFlagKey()); @@ -348,11 +417,12 @@ public void testObfuscated() { unknownEmailAttributes.put("email", "eppo@test.com"); result = FlagEvaluator.evaluateFlagWithDetails( - obfuscatedFlag, "flag", "subjectKey", unknownEmailAttributes, true); + obfuscatedFlag, "flag", "subjectKey", unknownEmailAttributes, true, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); result = - FlagEvaluator.evaluateFlagWithDetails(obfuscatedFlag, "flag", "subjectKey", new Attributes(), true); + FlagEvaluator.evaluateFlagWithDetails( + obfuscatedFlag, "flag", "subjectKey", new Attributes(), true, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); } @@ -424,7 +494,8 @@ public void testObfuscatedExtraLogging() { // Test with obfuscated config DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails(obfuscatedFlag, "flag", "subject", new Attributes(), true); + FlagEvaluator.evaluateFlagWithDetails( + obfuscatedFlag, "flag", "subject", new Attributes(), true, null, null, null); // Verify that extraLogging is deobfuscated Map extraLogging = result.getExtraLogging(); @@ -434,7 +505,9 @@ public void testObfuscatedExtraLogging() { assertEquals(2, extraLogging.size()); // Test with non-obfuscated config to ensure no deobfuscation happens - result = FlagEvaluator.evaluateFlagWithDetails(obfuscatedFlag, "flag", "subject", new Attributes(), false); + result = + FlagEvaluator.evaluateFlagWithDetails( + obfuscatedFlag, "flag", "subject", new Attributes(), false, null, null, null); // Verify that extraLogging remains obfuscated extraLogging = result.getExtraLogging(); diff --git a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java index 54360033..471dc430 100644 --- a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java +++ b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java @@ -73,7 +73,16 @@ public void getFlagType_shouldReturnCorrectType() { // Create configuration with this flag Map flags = Collections.singletonMap("test-flag", flagConfig); Configuration config = - new Configuration(flags, Collections.emptyMap(), Collections.emptyMap(), false, null, null); + new Configuration( + flags, + Collections.emptyMap(), + Collections.emptyMap(), + false, + null, // environmentName + null, // configFetchedAt + null, // configPublishedAt + null, // flagConfigJson + null); // banditParamsJson // Test successful case assertEquals(VariationType.STRING, config.getFlagType("test-flag")); @@ -103,8 +112,11 @@ public void getFlagType_withObfuscatedConfig_shouldReturnCorrectType() { Collections.emptyMap(), Collections.emptyMap(), true, // obfuscated - null, - null); + null, // environmentName + null, // configFetchedAt + null, // configPublishedAt + null, // flagConfigJson + null); // banditParamsJson // Test successful case with obfuscated config assertEquals(VariationType.NUMERIC, config.getFlagType("test-flag")); diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java index 094200a0..145c0823 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java @@ -127,6 +127,8 @@ private EvaluationDetails deserializeEvaluationDetails(JsonNode node) { return new EvaluationDetails( environmentName, + null, // configFetchedAt - not available in test data + null, // configPublishedAt - not available in test data flagEvaluationCode, flagEvaluationDescription, banditKey, From d75d7f72e0864cb46e0aeb140ac3b3b61cf5bc5c Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Sun, 7 Dec 2025 21:18:24 -0500 Subject: [PATCH 04/11] bandit details tests --- .../cloud/eppo/BaseEppoClientBanditTest.java | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java index 84587ed5..65f1c3da 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java @@ -481,4 +481,208 @@ public void testWithInitialConfiguration() { throw new RuntimeException(e); } } + + @Test + public void testBanditActionDetailsSuccessful() { + String flagKey = "banner_bandit_flag"; + String subjectKey = "bob"; + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("age", 25); + subjectAttributes.put("country", "USA"); + subjectAttributes.put("gender_identity", "female"); + + BanditActions actions = getBrandActions(); + + AssignmentDetails assignmentDetails = + eppoClient.getBanditActionDetails( + flagKey, subjectKey, subjectAttributes, actions, "control"); + + // Verify assignment + assertEquals("banner_bandit", assignmentDetails.getVariation()); + assertEquals("adidas", assignmentDetails.getAction()); + + // Verify evaluation details are populated correctly + EvaluationDetails details = assignmentDetails.getEvaluationDetails(); + assertNotNull(details); + assertEquals(FlagEvaluationCode.MATCH, details.getFlagEvaluationCode()); + assertNotNull(details.getEnvironmentName()); + + // Verify bandit-specific fields + assertEquals("banner_bandit", details.getBanditKey()); + assertEquals("adidas", details.getBanditAction()); + + // Verify config metadata + assertNotNull(details.getConfigPublishedAt()); + assertNotNull(details.getConfigFetchedAt()); + assertTrue( + details.getConfigFetchedAt().after(details.getConfigPublishedAt()), + "Config fetched at should be after config published at"); + + // Verify matched allocation + assertNotNull(details.getMatchedAllocation()); + assertEquals("training", details.getMatchedAllocation().getKey()); + } + + @Test + public void testBanditActionDetailsNoActionsSupplied() { + String flagKey = "banner_bandit_flag"; + String subjectKey = "bob"; + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("age", 25); + subjectAttributes.put("country", "USA"); + subjectAttributes.put("gender_identity", "female"); + + BanditActions actions = new BanditActions(); // Empty actions + + AssignmentDetails assignmentDetails = + eppoClient.getBanditActionDetails( + flagKey, subjectKey, subjectAttributes, actions, "control"); + + // Verify assignment - should get variation but no action + assertEquals("banner_bandit", assignmentDetails.getVariation()); + assertNull(assignmentDetails.getAction()); + + // Verify evaluation details + EvaluationDetails details = assignmentDetails.getEvaluationDetails(); + assertNotNull(details); + assertEquals(FlagEvaluationCode.MATCH, details.getFlagEvaluationCode()); + + // When no actions are supplied, bandit evaluation is skipped + // so both bandit key and action should be null + assertNull(details.getBanditKey()); + assertNull(details.getBanditAction()); + + // Verify config metadata is present + assertNotNull(details.getEnvironmentName()); + assertNotNull(details.getConfigPublishedAt()); + assertNotNull(details.getConfigFetchedAt()); + } + + @Test + public void testBanditActionDetailsNonBanditVariation() { + String flagKey = "banner_bandit_flag"; + String subjectKey = "anthony"; // This subject gets "control" variation which is not a bandit + Attributes subjectAttributes = new Attributes(); + + BanditActions actions = getBrandActions(); + + AssignmentDetails assignmentDetails = + eppoClient.getBanditActionDetails( + flagKey, subjectKey, subjectAttributes, actions, "default"); + + // Verify assignment - should get non-bandit variation with no action + assertEquals("control", assignmentDetails.getVariation()); + assertNull(assignmentDetails.getAction()); + + // Verify evaluation details + EvaluationDetails details = assignmentDetails.getEvaluationDetails(); + assertNotNull(details); + + // Should not have BANDIT_ERROR since this is a valid non-bandit variation + assertNotEquals(FlagEvaluationCode.BANDIT_ERROR, details.getFlagEvaluationCode()); + + // Verify no bandit key or action since this variation is not a bandit + assertNull(details.getBanditKey()); + assertNull(details.getBanditAction()); + + // Verify config metadata is present + assertNotNull(details.getEnvironmentName()); + assertNotNull(details.getConfigPublishedAt()); + assertNotNull(details.getConfigFetchedAt()); + } + + @Test + public void testBanditActionDetailsWithBanditLogError() { + // Even if bandit logging fails, we should still get details back + doThrow(new RuntimeException("Mock Bandit Logging Error")) + .when(mockBanditLogger) + .logBanditAssignment(any()); + + String flagKey = "banner_bandit_flag"; + String subjectKey = "bob"; + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("age", 25); + subjectAttributes.put("country", "USA"); + subjectAttributes.put("gender_identity", "female"); + + BanditActions actions = getBrandActions(); + + AssignmentDetails assignmentDetails = + eppoClient.getBanditActionDetails( + flagKey, subjectKey, subjectAttributes, actions, "control"); + + // Verify assignment still succeeds + assertEquals("banner_bandit", assignmentDetails.getVariation()); + assertEquals("adidas", assignmentDetails.getAction()); + + // Verify evaluation details are populated correctly + EvaluationDetails details = assignmentDetails.getEvaluationDetails(); + assertNotNull(details); + assertEquals(FlagEvaluationCode.MATCH, details.getFlagEvaluationCode()); + + // Verify bandit information is present + assertEquals("banner_bandit", details.getBanditKey()); + assertEquals("adidas", details.getBanditAction()); + + // Verify config metadata + assertNotNull(details.getEnvironmentName()); + assertNotNull(details.getConfigPublishedAt()); + assertNotNull(details.getConfigFetchedAt()); + + // Verify logging was attempted + ArgumentCaptor banditLogCaptor = + ArgumentCaptor.forClass(BanditAssignment.class); + verify(mockBanditLogger, times(1)).logBanditAssignment(banditLogCaptor.capture()); + } + + @Test + public void testBanditActionDetailsMetadataFlow() { + String flagKey = "banner_bandit_flag"; + String subjectKey = "bob"; + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("age", 25); + subjectAttributes.put("country", "USA"); + subjectAttributes.put("gender_identity", "female"); + + BanditActions actions = getBrandActions(); + + Date beforeFetch = new Date(); + AssignmentDetails assignmentDetails = + eppoClient.getBanditActionDetails( + flagKey, subjectKey, subjectAttributes, actions, "control"); + Date afterFetch = new Date(); + + // Verify evaluation details metadata + EvaluationDetails details = assignmentDetails.getEvaluationDetails(); + assertNotNull(details); + + // Verify environment name + assertNotNull(details.getEnvironmentName()); + assertFalse(details.getEnvironmentName().isEmpty()); + + // Verify timestamps are populated + assertNotNull(details.getConfigPublishedAt()); + assertNotNull(details.getConfigFetchedAt()); + + // configPublishedAt should be from the past (from the test JSON) + assertTrue( + details.getConfigPublishedAt().before(beforeFetch), + "Config published at should be before test started"); + + // configFetchedAt should be between test start and now + assertTrue( + details.getConfigFetchedAt().after(details.getConfigPublishedAt()), + "Config fetched at should be after config published at"); + assertTrue( + details.getConfigFetchedAt().before(afterFetch), + "Config fetched at should be before test completed"); + + // Verify variation information + assertNotNull(details.getVariationKey()); + assertNotNull(details.getVariationValue()); + + // Verify matched allocation details + assertNotNull(details.getMatchedAllocation()); + assertNotNull(details.getMatchedAllocation().getKey()); + } } From b4118b07636fd25b49805111f12fd73bccdd0b5a Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 9 Dec 2025 08:07:39 -0500 Subject: [PATCH 05/11] changes from self-review of pull request --- src/main/java/cloud/eppo/BaseEppoClient.java | 177 ++++++-------- .../eppo/DetailedFlagEvaluationResult.java | 15 -- src/main/java/cloud/eppo/FlagEvaluator.java | 224 +++--------------- .../cloud/eppo/api/EvaluationDetails.java | 36 +++ .../cloud/eppo/api/FlagEvaluationCode.java | 5 +- .../cloud/eppo/BaseEppoClientBanditTest.java | 21 +- 6 files changed, 159 insertions(+), 319 deletions(-) diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 5359f30e..10a89eb5 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -14,7 +14,6 @@ import cloud.eppo.ufc.dto.adapters.EppoModule; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import java.util.Timer; @@ -229,8 +228,10 @@ protected EvaluationDetails evaluateAndMaybeLog( FlagConfig flag = config.getFlag(flagKey); if (flag == null) { log.warn("no configuration found for key: {}", flagKey); - return buildDefaultEvaluationDetails( - config, + return EvaluationDetails.buildDefault( + config.getEnvironmentName(), + config.getConfigFetchedAt(), + config.getConfigPublishedAt(), FlagEvaluationCode.FLAG_UNRECOGNIZED_OR_DISABLED, "Unrecognized or disabled flag: " + flagKey, null); @@ -240,8 +241,10 @@ protected EvaluationDetails evaluateAndMaybeLog( if (!flag.isEnabled()) { log.info( "no assigned variation because the experiment or feature flag is disabled: {}", flagKey); - return buildDefaultEvaluationDetails( - config, + return EvaluationDetails.buildDefault( + config.getEnvironmentName(), + config.getConfigFetchedAt(), + config.getConfigPublishedAt(), FlagEvaluationCode.FLAG_UNRECOGNIZED_OR_DISABLED, "Unrecognized or disabled flag: " + flagKey, null); @@ -254,8 +257,10 @@ protected EvaluationDetails evaluateAndMaybeLog( flagKey, flag.getVariationType(), expectedType); - return buildDefaultEvaluationDetails( - config, + return EvaluationDetails.buildDefault( + config.getEnvironmentName(), + config.getConfigFetchedAt(), + config.getConfigPublishedAt(), FlagEvaluationCode.TYPE_MISMATCH, String.format( "Flag \"%s\" has type %s, requested %s", @@ -413,8 +418,10 @@ public AssignmentDetails getBooleanAssignmentDetails( return new AssignmentDetails<>( throwIfNotGraceful(e, defaultValue), null, - buildDefaultEvaluationDetails( - getConfiguration(), + EvaluationDetails.buildDefault( + getConfiguration().getEnvironmentName(), + getConfiguration().getConfigFetchedAt(), + getConfiguration().getConfigPublishedAt(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), EppoValue.valueOf(defaultValue))); @@ -445,8 +452,10 @@ public AssignmentDetails getIntegerAssignmentDetails( return new AssignmentDetails<>( throwIfNotGraceful(e, defaultValue), null, - buildDefaultEvaluationDetails( - getConfiguration(), + EvaluationDetails.buildDefault( + getConfiguration().getEnvironmentName(), + getConfiguration().getConfigFetchedAt(), + getConfiguration().getConfigPublishedAt(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), EppoValue.valueOf(defaultValue))); @@ -477,8 +486,10 @@ public AssignmentDetails getDoubleAssignmentDetails( return new AssignmentDetails<>( throwIfNotGraceful(e, defaultValue), null, - buildDefaultEvaluationDetails( - getConfiguration(), + EvaluationDetails.buildDefault( + getConfiguration().getEnvironmentName(), + getConfiguration().getConfigFetchedAt(), + getConfiguration().getConfigPublishedAt(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), EppoValue.valueOf(defaultValue))); @@ -509,8 +520,10 @@ public AssignmentDetails getStringAssignmentDetails( return new AssignmentDetails<>( throwIfNotGraceful(e, defaultValue), null, - buildDefaultEvaluationDetails( - getConfiguration(), + EvaluationDetails.buildDefault( + getConfiguration().getEnvironmentName(), + getConfiguration().getConfigFetchedAt(), + getConfiguration().getConfigPublishedAt(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), EppoValue.valueOf(defaultValue))); @@ -542,8 +555,10 @@ public AssignmentDetails getJSONAssignmentDetails( return new AssignmentDetails<>( throwIfNotGraceful(e, defaultValue), null, - buildDefaultEvaluationDetails( - getConfiguration(), + EvaluationDetails.buildDefault( + getConfiguration().getEnvironmentName(), + getConfiguration().getConfigFetchedAt(), + getConfiguration().getConfigPublishedAt(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), EppoValue.valueOf(defaultValueString))); @@ -574,8 +589,10 @@ public AssignmentDetails getJSONStringAssignmentDetails( return new AssignmentDetails<>( throwIfNotGraceful(e, defaultValue), null, - buildDefaultEvaluationDetails( - getConfiguration(), + EvaluationDetails.buildDefault( + getConfiguration().getEnvironmentName(), + getConfiguration().getConfigFetchedAt(), + getConfiguration().getConfigPublishedAt(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), EppoValue.valueOf(defaultValue))); @@ -588,68 +605,12 @@ public BanditResult getBanditAction( DiscriminableAttributes subjectAttributes, Actions actions, String defaultValue) { - BanditResult result = new BanditResult(defaultValue, null); - final Configuration config = getConfiguration(); try { - String assignedVariation = - getStringAssignment( - flagKey, subjectKey, subjectAttributes.getAllAttributes(), defaultValue); - - // Update result to reflect that we've been assigned a variation - result = new BanditResult(assignedVariation, null); - - String banditKey = config.banditKeyForVariation(flagKey, assignedVariation); - if (banditKey != null && !actions.isEmpty()) { - BanditParameters banditParameters = config.getBanditParameters(banditKey); - BanditEvaluationResult banditResult = - BanditEvaluator.evaluateBandit( - flagKey, subjectKey, subjectAttributes, actions, banditParameters.getModelData()); - - // Update result to reflect that we've been assigned an action - result = new BanditResult(assignedVariation, banditResult.getActionKey()); - - if (banditLogger != null) { - try { - BanditAssignment banditAssignment = - new BanditAssignment( - flagKey, - banditKey, - subjectKey, - banditResult.getActionKey(), - banditResult.getActionWeight(), - banditResult.getOptimalityGap(), - banditParameters.getModelVersion(), - subjectAttributes.getNumericAttributes(), - subjectAttributes.getCategoricalAttributes(), - banditResult.getActionAttributes().getNumericAttributes(), - banditResult.getActionAttributes().getCategoricalAttributes(), - buildLogMetaData(config.isConfigObfuscated())); - - // Log, only if there is no cache hit. - boolean logBanditAssignment = true; - AssignmentCacheEntry cacheEntry = - AssignmentCacheEntry.fromBanditAssignment(banditAssignment); - if (banditAssignmentCache != null) { - if (banditAssignmentCache.hasEntry(cacheEntry)) { - logBanditAssignment = false; - } - } - - if (logBanditAssignment) { - banditLogger.logBanditAssignment(banditAssignment); - - if (banditAssignmentCache != null) { - banditAssignmentCache.put(cacheEntry); - } - } - } catch (Exception e) { - log.warn("Error logging bandit assignment: {}", e.getMessage(), e); - } - } - } - return result; + AssignmentDetails details = + getBanditActionDetails(flagKey, subjectKey, subjectAttributes, actions, defaultValue); + return new BanditResult(details.getVariation(), details.getAction()); } catch (Exception e) { - return throwIfNotGraceful(e, result); + return throwIfNotGraceful(e, new BanditResult(defaultValue, null)); } } @@ -675,7 +636,30 @@ public AssignmentDetails getBanditActionDetails( // If we got a variation, check for bandit String banditKey = config.banditKeyForVariation(flagKey, assignedVariation); - if (banditKey != null && !actions.isEmpty()) { + + // If variation is a bandit but no actions supplied, return variation with null action + // This matches Python/JS SDK behavior: "if no actions are given, return the variation with no + // action" + if (banditKey != null && actions.isEmpty()) { + EvaluationDetails noActionsDetails = + new EvaluationDetails( + flagDetails.getEvaluationDetails().getEnvironmentName(), + flagDetails.getEvaluationDetails().getConfigFetchedAt(), + flagDetails.getEvaluationDetails().getConfigPublishedAt(), + FlagEvaluationCode.NO_ACTIONS_SUPPLIED_FOR_BANDIT, + "No actions supplied for bandit evaluation", + banditKey, + null, // no action selected + flagDetails.getEvaluationDetails().getVariationKey(), + flagDetails.getEvaluationDetails().getVariationValue(), + flagDetails.getEvaluationDetails().getMatchedRule(), + flagDetails.getEvaluationDetails().getMatchedAllocation(), + flagDetails.getEvaluationDetails().getUnmatchedAllocations(), + flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); + return new AssignmentDetails<>(assignedVariation, null, noActionsDetails); + } + + if (banditKey != null) { try { BanditParameters banditParameters = config.getBanditParameters(banditKey); if (banditParameters == null) { @@ -744,12 +728,19 @@ public AssignmentDetails getBanditActionDetails( return new AssignmentDetails<>(assignedVariation, assignedAction, updatedDetails); } catch (Exception banditError) { - // Bandit evaluation failed - return flag details with BANDIT_ERROR code + // Bandit evaluation failed - respect graceful mode setting log.warn( "Bandit evaluation failed for flag {}: {}", flagKey, banditError.getMessage(), banditError); + + // If graceful mode is off, throw the exception + if (!isGracefulMode) { + throw new RuntimeException(banditError); + } + + // In graceful mode, return flag details with BANDIT_ERROR code EvaluationDetails banditErrorDetails = new EvaluationDetails( flagDetails.getEvaluationDetails().getEnvironmentName(), @@ -777,8 +768,10 @@ public AssignmentDetails getBanditActionDetails( new AssignmentDetails<>( defaultValue, null, - buildDefaultEvaluationDetails( - config, + EvaluationDetails.buildDefault( + config.getEnvironmentName(), + config.getConfigFetchedAt(), + config.getConfigPublishedAt(), FlagEvaluationCode.ASSIGNMENT_ERROR, e.getMessage(), EppoValue.valueOf(defaultValue))); @@ -786,24 +779,6 @@ public AssignmentDetails getBanditActionDetails( } } - private EvaluationDetails buildDefaultEvaluationDetails( - Configuration config, FlagEvaluationCode code, String description, EppoValue variationValue) { - return new EvaluationDetails( - config.getEnvironmentName() != null ? config.getEnvironmentName() : "Test", - config.getConfigFetchedAt(), - config.getConfigPublishedAt(), - code, - description, - null, // banditKey - null, // banditAction - null, // variationKey - variationValue, - null, // matchedRule - null, // matchedAllocation - new ArrayList<>(), // unmatchedAllocations - new ArrayList<>()); // unevaluatedAllocations - } - private Map buildLogMetaData(boolean isConfigObfuscated) { HashMap metaData = new HashMap<>(); metaData.put("obfuscated", Boolean.valueOf(isConfigObfuscated).toString()); diff --git a/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java b/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java index c1d9668a..47038b73 100644 --- a/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java +++ b/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java @@ -175,20 +175,5 @@ public DetailedFlagEvaluationResult build() { doLog, details); } - - private static Object getEppoValueAsObject(EppoValue value) { - if (value.isNull()) { - return null; - } else if (value.isBoolean()) { - return value.booleanValue(); - } else if (value.isNumeric()) { - return value.doubleValue(); - } else if (value.isString()) { - return value.stringValue(); - } else if (value.isStringArray()) { - return value.stringArrayValue(); - } - return null; - } } } diff --git a/src/main/java/cloud/eppo/FlagEvaluator.java b/src/main/java/cloud/eppo/FlagEvaluator.java index c19e714c..b6120d0c 100644 --- a/src/main/java/cloud/eppo/FlagEvaluator.java +++ b/src/main/java/cloud/eppo/FlagEvaluator.java @@ -14,13 +14,10 @@ import cloud.eppo.model.ShardRange; import cloud.eppo.ufc.dto.Allocation; import cloud.eppo.ufc.dto.FlagConfig; -import cloud.eppo.ufc.dto.OperatorType; import cloud.eppo.ufc.dto.Shard; import cloud.eppo.ufc.dto.Split; import cloud.eppo.ufc.dto.TargetingRule; import cloud.eppo.ufc.dto.Variation; -import java.util.ArrayList; -import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.LinkedList; @@ -31,41 +28,6 @@ public class FlagEvaluator { - private static boolean allShardsMatch( - Split split, String subjectKey, int totalShards, boolean isObfuscated) { - if (split.getShards() == null || split.getShards().isEmpty()) { - // Default to matching if no explicit shards - return true; - } - - for (Shard shard : split.getShards()) { - if (!matchesShard(shard, subjectKey, totalShards, isObfuscated)) { - return false; - } - } - - // If here, matchesShard() was true for each shard - return true; - } - - private static boolean matchesShard( - Shard shard, String subjectKey, int totalShards, boolean isObfuscated) { - String salt = shard.getSalt(); - if (isObfuscated) { - salt = base64Decode(salt); - } - String hashKey = salt + "-" + subjectKey; - int assignedShard = getShard(hashKey, totalShards); - for (ShardRange range : shard.getRanges()) { - if (assignedShard >= range.getStart() && assignedShard < range.getEnd()) { - return true; - } - } - - // If here, the shard was not in any of the shard's ranges - return false; - } - /** * Evaluates a flag and returns detailed evaluation information including allocation statuses, * matched rules, and evaluation codes. This is useful for debugging and understanding why a @@ -234,6 +196,7 @@ public static DetailedFlagEvaluationResult evaluateFlagWithDetails( String deobfuscatedValue = base64Decode(entry.getValue()); deobfuscatedExtraLogging.put(deobfuscatedKey, deobfuscatedValue); } catch (Exception e) { + // If deobfuscation fails, keep the original key-value pair deobfuscatedExtraLogging.put(entry.getKey(), entry.getValue()); } } @@ -244,22 +207,6 @@ public static DetailedFlagEvaluationResult evaluateFlagWithDetails( // Build matched rule details if applicable MatchedRule matchedRule = null; if (matchedTargetingRule != null) { - // Build reverse lookup map for deobfuscating ONE_OF values - Map md5ToOriginalValue = null; - if (isConfigObfuscated) { - md5ToOriginalValue = new HashMap<>(); - for (Map.Entry entry : subjectAttributesToEvaluate.entrySet()) { - if (entry.getValue() != null && !entry.getValue().isNull()) { - // Hash the attribute value (as string) to create reverse lookup - String valueAsString = castAttributeValueToString(entry.getValue()); - if (valueAsString != null) { - md5ToOriginalValue.put(getMD5Hex(valueAsString), valueAsString); - } - } - } - } - - final Map finalMd5ToOriginalValue = md5ToOriginalValue; Set conditions = matchedTargetingRule.getConditions().stream() .map( @@ -277,17 +224,9 @@ public static DetailedFlagEvaluationResult evaluateFlagWithDetails( } } - // Deobfuscate the condition value if needed - EppoValue value = tc.getValue(); - if (isConfigObfuscated && value != null) { - Object deobfuscatedValue = - deobfuscateConditionValue( - tc.getValue(), tc.getOperator(), finalMd5ToOriginalValue); - // Convert deobfuscated Object back to EppoValue - value = objectToEppoValue(deobfuscatedValue); - } - - return new RuleCondition(attribute, tc.getOperator().value, value); + // Condition values are already handled by RuleEvaluator during evaluation + // For display purposes, we keep the raw value + return new RuleCondition(attribute, tc.getOperator().value, tc.getValue()); }) .collect(Collectors.toSet()); matchedRule = new MatchedRule(conditions); @@ -359,143 +298,38 @@ public static DetailedFlagEvaluationResult evaluateFlagWithDetails( return builder.build(); } - private static Object getEppoValueAsObject(EppoValue value) { - if (value.isNull()) { - return null; - } else if (value.isBoolean()) { - return value.booleanValue(); - } else if (value.isNumeric()) { - return value.doubleValue(); - } else if (value.isString()) { - return value.stringValue(); - } else if (value.isStringArray()) { - return value.stringArrayValue(); + private static boolean allShardsMatch( + Split split, String subjectKey, int totalShards, boolean isObfuscated) { + if (split.getShards() == null || split.getShards().isEmpty()) { + // Default to matching if no explicit shards + return true; } - return null; - } - /** - * Deobfuscates a condition value based on the operator type. Different operators obfuscate values - * differently: - IS_NULL: MD5 hash of "true" or "false" - Inequality operators (GTE, GT, LTE, - * LT): base64 encoded numbers or semver strings - MATCHES, NOT_MATCHES: base64 encoded regex - * patterns - ONE_OF, NOT_ONE_OF: array of MD5 hashes that need to be reverse-looked up - */ - private static Object deobfuscateConditionValue( - EppoValue value, OperatorType operator, Map md5ToOriginalValue) { - if (value.isNull()) { - return null; + for (Shard shard : split.getShards()) { + if (!matchesShard(shard, subjectKey, totalShards, isObfuscated)) { + return false; + } } - switch (operator) { - case IS_NULL: - // Check if it's MD5 of "true" or "false" - if (value.isString()) { - String hash = value.stringValue(); - if (getMD5Hex("true").equals(hash)) { - return true; - } else if (getMD5Hex("false").equals(hash)) { - return false; - } - } - return value.booleanValue(); - - case GREATER_THAN_OR_EQUAL_TO: - case GREATER_THAN: - case LESS_THAN_OR_EQUAL_TO: - case LESS_THAN: - // Decode base64 encoded numeric or semver values - if (value.isString()) { - try { - String decoded = base64Decode(value.stringValue()); - // Try to parse as number first - try { - return Double.parseDouble(decoded); - } catch (NumberFormatException e) { - // Return as string (likely a semver) - return decoded; - } - } catch (Exception e) { - // If decode fails, return original - return value.stringValue(); - } - } - return getEppoValueAsObject(value); - - case MATCHES: - case NOT_MATCHES: - // Decode base64 encoded regex patterns - if (value.isString()) { - try { - return base64Decode(value.stringValue()); - } catch (Exception e) { - return value.stringValue(); - } - } - return value.stringValue(); - - case ONE_OF: - case NOT_ONE_OF: - // Array values are MD5 hashes - try to reverse them using the subject attributes - if (value.isStringArray() && md5ToOriginalValue != null) { - List deobfuscatedValues = new ArrayList<>(); - for (String hash : value.stringArrayValue()) { - String originalValue = md5ToOriginalValue.get(hash); - if (originalValue != null) { - deobfuscatedValues.add(originalValue); - } else { - // Keep the hash if we can't reverse it - deobfuscatedValues.add(hash); - } - } - return deobfuscatedValues; - } - return getEppoValueAsObject(value); - - default: - return getEppoValueAsObject(value); - } + // If here, matchesShard() was true for each shard + return true; } - /** - * Casts an EppoValue to a string representation for use in hash lookups. Uses the same logic as - * RuleEvaluator.castAttributeForListComparison() - */ - private static String castAttributeValueToString(EppoValue attributeValue) { - if (attributeValue.isBoolean()) { - return Boolean.valueOf(attributeValue.booleanValue()).toString(); - } else if (attributeValue.isNumeric()) { - double doubleValue = attributeValue.doubleValue(); - int intValue = (int) attributeValue.doubleValue(); - return doubleValue == intValue ? String.valueOf(intValue) : String.valueOf(doubleValue); - } else if (attributeValue.isString()) { - return attributeValue.stringValue(); - } else if (attributeValue.isStringArray()) { - return Collections.singletonList(attributeValue.stringArrayValue()).toString(); - } else if (attributeValue.isNull()) { - return ""; - } else { - return null; + private static boolean matchesShard( + Shard shard, String subjectKey, int totalShards, boolean isObfuscated) { + String salt = shard.getSalt(); + if (isObfuscated) { + salt = base64Decode(salt); } - } - - /** Converts an Object back to EppoValue after deobfuscation. */ - private static EppoValue objectToEppoValue(Object value) { - if (value == null) { - return EppoValue.nullValue(); - } else if (value instanceof Boolean) { - return EppoValue.valueOf((Boolean) value); - } else if (value instanceof Double) { - return EppoValue.valueOf((Double) value); - } else if (value instanceof Integer) { - return EppoValue.valueOf(((Integer) value).doubleValue()); - } else if (value instanceof String) { - return EppoValue.valueOf((String) value); - } else if (value instanceof List) { - @SuppressWarnings("unchecked") - List list = (List) value; - return EppoValue.valueOf(list); - } else { - return EppoValue.valueOf(value.toString()); + String hashKey = salt + "-" + subjectKey; + int assignedShard = getShard(hashKey, totalShards); + for (ShardRange range : shard.getRanges()) { + if (assignedShard >= range.getStart() && assignedShard < range.getEnd()) { + return true; + } } + + // If here, the shard was not in any of the shard's ranges + return false; } } diff --git a/src/main/java/cloud/eppo/api/EvaluationDetails.java b/src/main/java/cloud/eppo/api/EvaluationDetails.java index 23f86a65..af6eb8bc 100644 --- a/src/main/java/cloud/eppo/api/EvaluationDetails.java +++ b/src/main/java/cloud/eppo/api/EvaluationDetails.java @@ -1,5 +1,6 @@ package cloud.eppo.api; +import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -107,4 +108,39 @@ public List getUnevaluatedAllocations() { public boolean evaluationSuccessful() { return !flagEvaluationCode.isError(); } + + /** + * Builds a default EvaluationDetails for error cases or when detailed evaluation information is + * not available. + * + * @param environmentName The environment name from configuration, or null + * @param configFetchedAt The timestamp when configuration was fetched, or null + * @param configPublishedAt The timestamp when configuration was published, or null + * @param code The flag evaluation code + * @param description A description of why this evaluation result was returned + * @param variationValue The variation value being returned + * @return An EvaluationDetails with minimal information populated + */ + public static EvaluationDetails buildDefault( + String environmentName, + Date configFetchedAt, + Date configPublishedAt, + FlagEvaluationCode code, + String description, + EppoValue variationValue) { + return new EvaluationDetails( + environmentName != null ? environmentName : "Unknown", + configFetchedAt, + configPublishedAt, + code, + description, + null, // banditKey + null, // banditAction + null, // variationKey + variationValue, + null, // matchedRule + null, // matchedAllocation + new ArrayList<>(), // unmatchedAllocations + new ArrayList<>()); // unevaluatedAllocations + } } diff --git a/src/main/java/cloud/eppo/api/FlagEvaluationCode.java b/src/main/java/cloud/eppo/api/FlagEvaluationCode.java index 45ce3abe..1509e806 100644 --- a/src/main/java/cloud/eppo/api/FlagEvaluationCode.java +++ b/src/main/java/cloud/eppo/api/FlagEvaluationCode.java @@ -22,7 +22,10 @@ public enum FlagEvaluationCode { DEFAULT_ALLOCATION_NULL("DEFAULT_ALLOCATION_NULL", true), /** Flag evaluation succeeded but bandit evaluation failed. */ - BANDIT_ERROR("BANDIT_ERROR", true); + BANDIT_ERROR("BANDIT_ERROR", true), + + /** No actions were supplied for bandit evaluation. */ + NO_ACTIONS_SUPPLIED_FOR_BANDIT("NO_ACTIONS_SUPPLIED_FOR_BANDIT", true); private final String code; private final boolean isError; diff --git a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java index 65f1c3da..78c5412c 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java @@ -373,11 +373,11 @@ public void testNoBanditLogsWhenNoActions() { BanditResult banditResult = eppoClient.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "control"); - // Verify assignment + // Verify assignment - returns bandit variation with null action assertEquals("banner_bandit", banditResult.getVariation()); assertNull(banditResult.getAction()); - // The variation assignment should have been logged + // The variation assignment should have been logged (happens during flag evaluation) ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); @@ -538,20 +538,27 @@ public void testBanditActionDetailsNoActionsSupplied() { eppoClient.getBanditActionDetails( flagKey, subjectKey, subjectAttributes, actions, "control"); - // Verify assignment - should get variation but no action + // Verify assignment - should get bandit variation with null action assertEquals("banner_bandit", assignmentDetails.getVariation()); assertNull(assignmentDetails.getAction()); // Verify evaluation details EvaluationDetails details = assignmentDetails.getEvaluationDetails(); assertNotNull(details); - assertEquals(FlagEvaluationCode.MATCH, details.getFlagEvaluationCode()); + assertEquals( + FlagEvaluationCode.NO_ACTIONS_SUPPLIED_FOR_BANDIT, details.getFlagEvaluationCode()); + assertEquals( + "No actions supplied for bandit evaluation", details.getFlagEvaluationDescription()); - // When no actions are supplied, bandit evaluation is skipped - // so both bandit key and action should be null - assertNull(details.getBanditKey()); + // banditKey should be set (we know which bandit would have been used) + // but banditAction should be null (no action selected) + assertEquals("banner_bandit", details.getBanditKey()); assertNull(details.getBanditAction()); + // Verify variation key is set + assertNotNull(details.getVariationKey()); + assertNotNull(details.getVariationValue()); + // Verify config metadata is present assertNotNull(details.getEnvironmentName()); assertNotNull(details.getConfigPublishedAt()); From 0a1a7d6e018ab65fe7aebd70cbc93b69c0555e02 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 9 Dec 2025 09:19:23 -0500 Subject: [PATCH 06/11] clean up builder --- src/main/java/cloud/eppo/BaseEppoClient.java | 82 +++----- .../eppo/DetailedFlagEvaluationResult.java | 64 ++----- .../cloud/eppo/api/EvaluationDetails.java | 177 +++++++++++++++--- 3 files changed, 195 insertions(+), 128 deletions(-) diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 10a89eb5..a6f3832c 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -301,22 +301,15 @@ protected EvaluationDetails evaluateAndMaybeLog( "Variation (%s) is configured for type %s, but is set to incompatible value (%s)", variationKey, expectedType, assignedValue.doubleValue()); - return new EvaluationDetails( - evaluationDetails.getEnvironmentName(), - evaluationDetails.getConfigFetchedAt(), - evaluationDetails.getConfigPublishedAt(), - FlagEvaluationCode - .ASSIGNMENT_ERROR, // We use ASSIGNMENT_ERROR for value mismatch as it's a + return EvaluationDetails.builder(evaluationDetails) + .flagEvaluationCode( + FlagEvaluationCode + .ASSIGNMENT_ERROR) // We use ASSIGNMENT_ERROR for value mismatch as it's a // misconfiguration of the flag itself - errorDescription, - evaluationDetails.getBanditKey(), - evaluationDetails.getBanditAction(), - variationKey, - assignedValue, - evaluationDetails.getMatchedRule(), - evaluationDetails.getMatchedAllocation(), - evaluationDetails.getUnmatchedAllocations(), - evaluationDetails.getUnevaluatedAllocations()); + .flagEvaluationDescription(errorDescription) + .variationKey(variationKey) + .variationValue(assignedValue) + .build(); } // Log assignment if applicable @@ -642,20 +635,12 @@ public AssignmentDetails getBanditActionDetails( // action" if (banditKey != null && actions.isEmpty()) { EvaluationDetails noActionsDetails = - new EvaluationDetails( - flagDetails.getEvaluationDetails().getEnvironmentName(), - flagDetails.getEvaluationDetails().getConfigFetchedAt(), - flagDetails.getEvaluationDetails().getConfigPublishedAt(), - FlagEvaluationCode.NO_ACTIONS_SUPPLIED_FOR_BANDIT, - "No actions supplied for bandit evaluation", - banditKey, - null, // no action selected - flagDetails.getEvaluationDetails().getVariationKey(), - flagDetails.getEvaluationDetails().getVariationValue(), - flagDetails.getEvaluationDetails().getMatchedRule(), - flagDetails.getEvaluationDetails().getMatchedAllocation(), - flagDetails.getEvaluationDetails().getUnmatchedAllocations(), - flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); + EvaluationDetails.builder(flagDetails.getEvaluationDetails()) + .flagEvaluationCode(FlagEvaluationCode.NO_ACTIONS_SUPPLIED_FOR_BANDIT) + .flagEvaluationDescription("No actions supplied for bandit evaluation") + .banditKey(banditKey) + .banditAction(null) + .build(); return new AssignmentDetails<>(assignedVariation, null, noActionsDetails); } @@ -711,20 +696,10 @@ public AssignmentDetails getBanditActionDetails( // Update evaluation details to include bandit information EvaluationDetails updatedDetails = - new EvaluationDetails( - flagDetails.getEvaluationDetails().getEnvironmentName(), - flagDetails.getEvaluationDetails().getConfigFetchedAt(), - flagDetails.getEvaluationDetails().getConfigPublishedAt(), - flagDetails.getEvaluationDetails().getFlagEvaluationCode(), - flagDetails.getEvaluationDetails().getFlagEvaluationDescription(), - banditKey, - assignedAction, - flagDetails.getEvaluationDetails().getVariationKey(), - flagDetails.getEvaluationDetails().getVariationValue(), - flagDetails.getEvaluationDetails().getMatchedRule(), - flagDetails.getEvaluationDetails().getMatchedAllocation(), - flagDetails.getEvaluationDetails().getUnmatchedAllocations(), - flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); + EvaluationDetails.builder(flagDetails.getEvaluationDetails()) + .banditKey(banditKey) + .banditAction(assignedAction) + .build(); return new AssignmentDetails<>(assignedVariation, assignedAction, updatedDetails); } catch (Exception banditError) { @@ -742,20 +717,13 @@ public AssignmentDetails getBanditActionDetails( // In graceful mode, return flag details with BANDIT_ERROR code EvaluationDetails banditErrorDetails = - new EvaluationDetails( - flagDetails.getEvaluationDetails().getEnvironmentName(), - flagDetails.getEvaluationDetails().getConfigFetchedAt(), - flagDetails.getEvaluationDetails().getConfigPublishedAt(), - FlagEvaluationCode.BANDIT_ERROR, - "Bandit evaluation failed: " + banditError.getMessage(), - banditKey, - null, // no action assigned due to error - flagDetails.getEvaluationDetails().getVariationKey(), - flagDetails.getEvaluationDetails().getVariationValue(), - flagDetails.getEvaluationDetails().getMatchedRule(), - flagDetails.getEvaluationDetails().getMatchedAllocation(), - flagDetails.getEvaluationDetails().getUnmatchedAllocations(), - flagDetails.getEvaluationDetails().getUnevaluatedAllocations()); + EvaluationDetails.builder(flagDetails.getEvaluationDetails()) + .flagEvaluationCode(FlagEvaluationCode.BANDIT_ERROR) + .flagEvaluationDescription( + "Bandit evaluation failed: " + banditError.getMessage()) + .banditKey(banditKey) + .banditAction(null) + .build(); return new AssignmentDetails<>(assignedVariation, null, banditErrorDetails); } } diff --git a/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java b/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java index 47038b73..0cfb9c87 100644 --- a/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java +++ b/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java @@ -2,9 +2,7 @@ import cloud.eppo.api.*; import cloud.eppo.ufc.dto.Variation; -import java.util.ArrayList; import java.util.Date; -import java.util.List; import java.util.Map; /** @@ -41,18 +39,8 @@ public static class Builder { private Map extraLogging; private boolean doLog; - // Evaluation details fields - private String environmentName = "Test"; // Default for now - private FlagEvaluationCode flagEvaluationCode; - private String flagEvaluationDescription; - private String banditKey; - private String banditAction; - private MatchedRule matchedRule; - private AllocationDetails matchedAllocation; - private final List unmatchedAllocations = new ArrayList<>(); - private final List unevaluatedAllocations = new ArrayList<>(); - private Date configFetchedAt; - private Date configPublishedAt; + // Delegate to EvaluationDetails.Builder for evaluation details + private final EvaluationDetails.Builder detailsBuilder = EvaluationDetails.builder(); public Builder flagKey(String flagKey) { this.flagKey = flagKey; @@ -90,80 +78,66 @@ public Builder doLog(boolean doLog) { } public Builder environmentName(String environmentName) { - this.environmentName = environmentName; + detailsBuilder.environmentName(environmentName); return this; } public Builder flagEvaluationCode(FlagEvaluationCode code) { - this.flagEvaluationCode = code; + detailsBuilder.flagEvaluationCode(code); return this; } public Builder flagEvaluationDescription(String description) { - this.flagEvaluationDescription = description; + detailsBuilder.flagEvaluationDescription(description); return this; } public Builder banditKey(String banditKey) { - this.banditKey = banditKey; + detailsBuilder.banditKey(banditKey); return this; } public Builder banditAction(String banditAction) { - this.banditAction = banditAction; + detailsBuilder.banditAction(banditAction); return this; } public Builder matchedRule(MatchedRule matchedRule) { - this.matchedRule = matchedRule; + detailsBuilder.matchedRule(matchedRule); return this; } public Builder matchedAllocation(AllocationDetails matchedAllocation) { - this.matchedAllocation = matchedAllocation; + detailsBuilder.matchedAllocation(matchedAllocation); return this; } public Builder addUnmatchedAllocation(AllocationDetails allocation) { - this.unmatchedAllocations.add(allocation); + detailsBuilder.addUnmatchedAllocation(allocation); return this; } public Builder addUnevaluatedAllocation(AllocationDetails allocation) { - this.unevaluatedAllocations.add(allocation); + detailsBuilder.addUnevaluatedAllocation(allocation); return this; } public Builder configFetchedAt(Date configFetchedAt) { - this.configFetchedAt = configFetchedAt; + detailsBuilder.configFetchedAt(configFetchedAt); return this; } public Builder configPublishedAt(Date configPublishedAt) { - this.configPublishedAt = configPublishedAt; + detailsBuilder.configPublishedAt(configPublishedAt); return this; } public DetailedFlagEvaluationResult build() { - // Build evaluation details - String variationKey = variation != null ? variation.getKey() : null; - EppoValue variationValue = variation != null ? variation.getValue() : null; - - EvaluationDetails details = - new EvaluationDetails( - environmentName, - configFetchedAt, - configPublishedAt, - flagEvaluationCode, - flagEvaluationDescription, - banditKey, - banditAction, - variationKey, - variationValue, - matchedRule, - matchedAllocation, - new ArrayList<>(unmatchedAllocations), - new ArrayList<>(unevaluatedAllocations)); + // Set variation details before building + if (variation != null) { + detailsBuilder.variationKey(variation.getKey()); + detailsBuilder.variationValue(variation.getValue()); + } return new DetailedFlagEvaluationResult( flagKey, @@ -173,7 +147,7 @@ public DetailedFlagEvaluationResult build() { variation, extraLogging, doLog, - details); + detailsBuilder.build()); } } } diff --git a/src/main/java/cloud/eppo/api/EvaluationDetails.java b/src/main/java/cloud/eppo/api/EvaluationDetails.java index af6eb8bc..0d500de0 100644 --- a/src/main/java/cloud/eppo/api/EvaluationDetails.java +++ b/src/main/java/cloud/eppo/api/EvaluationDetails.java @@ -109,38 +109,163 @@ public boolean evaluationSuccessful() { return !flagEvaluationCode.isError(); } + /** Creates a new Builder for constructing EvaluationDetails. */ + public static Builder builder() { + return new Builder(); + } + /** - * Builds a default EvaluationDetails for error cases or when detailed evaluation information is - * not available. - * - * @param environmentName The environment name from configuration, or null - * @param configFetchedAt The timestamp when configuration was fetched, or null - * @param configPublishedAt The timestamp when configuration was published, or null - * @param code The flag evaluation code - * @param description A description of why this evaluation result was returned - * @param variationValue The variation value being returned - * @return An EvaluationDetails with minimal information populated + * Creates a default EvaluationDetails for error conditions or when no flag was matched. This is a + * convenience factory method for common error scenarios. */ public static EvaluationDetails buildDefault( String environmentName, Date configFetchedAt, Date configPublishedAt, - FlagEvaluationCode code, - String description, + FlagEvaluationCode flagEvaluationCode, + String flagEvaluationDescription, EppoValue variationValue) { - return new EvaluationDetails( - environmentName != null ? environmentName : "Unknown", - configFetchedAt, - configPublishedAt, - code, - description, - null, // banditKey - null, // banditAction - null, // variationKey - variationValue, - null, // matchedRule - null, // matchedAllocation - new ArrayList<>(), // unmatchedAllocations - new ArrayList<>()); // unevaluatedAllocations + return builder() + .environmentName(environmentName) + .configFetchedAt(configFetchedAt) + .configPublishedAt(configPublishedAt) + .flagEvaluationCode(flagEvaluationCode) + .flagEvaluationDescription(flagEvaluationDescription) + .variationValue(variationValue) + .build(); + } + + /** + * Creates a new Builder initialized with values from an existing EvaluationDetails. Useful for + * creating a modified copy. + */ + public static Builder builder(EvaluationDetails copyFrom) { + return new Builder() + .environmentName(copyFrom.environmentName) + .configFetchedAt(copyFrom.configFetchedAt) + .configPublishedAt(copyFrom.configPublishedAt) + .flagEvaluationCode(copyFrom.flagEvaluationCode) + .flagEvaluationDescription(copyFrom.flagEvaluationDescription) + .banditKey(copyFrom.banditKey) + .banditAction(copyFrom.banditAction) + .variationKey(copyFrom.variationKey) + .variationValue(copyFrom.variationValue) + .matchedRule(copyFrom.matchedRule) + .matchedAllocation(copyFrom.matchedAllocation) + .unmatchedAllocations(copyFrom.unmatchedAllocations) + .unevaluatedAllocations(copyFrom.unevaluatedAllocations); + } + + /** Builder for constructing EvaluationDetails instances. */ + public static class Builder { + private String environmentName = "Unknown"; + private Date configFetchedAt; + private Date configPublishedAt; + private FlagEvaluationCode flagEvaluationCode; + private String flagEvaluationDescription; + private String banditKey; + private String banditAction; + private String variationKey; + private EppoValue variationValue; + private MatchedRule matchedRule; + private AllocationDetails matchedAllocation; + private List unmatchedAllocations = new ArrayList<>(); + private List unevaluatedAllocations = new ArrayList<>(); + + public Builder environmentName(String environmentName) { + this.environmentName = environmentName != null ? environmentName : "Unknown"; + return this; + } + + public Builder configFetchedAt(Date configFetchedAt) { + this.configFetchedAt = configFetchedAt; + return this; + } + + public Builder configPublishedAt(Date configPublishedAt) { + this.configPublishedAt = configPublishedAt; + return this; + } + + public Builder flagEvaluationCode(FlagEvaluationCode flagEvaluationCode) { + this.flagEvaluationCode = flagEvaluationCode; + return this; + } + + public Builder flagEvaluationDescription(String flagEvaluationDescription) { + this.flagEvaluationDescription = flagEvaluationDescription; + return this; + } + + public Builder banditKey(String banditKey) { + this.banditKey = banditKey; + return this; + } + + public Builder banditAction(String banditAction) { + this.banditAction = banditAction; + return this; + } + + public Builder variationKey(String variationKey) { + this.variationKey = variationKey; + return this; + } + + public Builder variationValue(EppoValue variationValue) { + this.variationValue = variationValue; + return this; + } + + public Builder matchedRule(MatchedRule matchedRule) { + this.matchedRule = matchedRule; + return this; + } + + public Builder matchedAllocation(AllocationDetails matchedAllocation) { + this.matchedAllocation = matchedAllocation; + return this; + } + + public Builder unmatchedAllocations(List unmatchedAllocations) { + this.unmatchedAllocations = + unmatchedAllocations != null ? new ArrayList<>(unmatchedAllocations) : new ArrayList<>(); + return this; + } + + public Builder addUnmatchedAllocation(AllocationDetails allocation) { + this.unmatchedAllocations.add(allocation); + return this; + } + + public Builder unevaluatedAllocations(List unevaluatedAllocations) { + this.unevaluatedAllocations = + unevaluatedAllocations != null + ? new ArrayList<>(unevaluatedAllocations) + : new ArrayList<>(); + return this; + } + + public Builder addUnevaluatedAllocation(AllocationDetails allocation) { + this.unevaluatedAllocations.add(allocation); + return this; + } + + public EvaluationDetails build() { + return new EvaluationDetails( + environmentName, + configFetchedAt, + configPublishedAt, + flagEvaluationCode, + flagEvaluationDescription, + banditKey, + banditAction, + variationKey, + variationValue, + matchedRule, + matchedAllocation, + unmatchedAllocations, + unevaluatedAllocations); + } } } From 790d5cf5b4076fcabe9b6d1d6320eaa705dc3540 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 9 Dec 2025 09:57:37 -0500 Subject: [PATCH 07/11] changes to unevaluated allocation index --- src/main/java/cloud/eppo/FlagEvaluator.java | 2 +- src/main/java/cloud/eppo/api/EppoValue.java | 2 +- .../eppo/helpers/AssignmentTestCase.java | 27 ++++++++++++------- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/main/java/cloud/eppo/FlagEvaluator.java b/src/main/java/cloud/eppo/FlagEvaluator.java index b6120d0c..015c05b8 100644 --- a/src/main/java/cloud/eppo/FlagEvaluator.java +++ b/src/main/java/cloud/eppo/FlagEvaluator.java @@ -280,7 +280,7 @@ public static DetailedFlagEvaluationResult evaluateFlagWithDetails( ? base64Decode(unevaluatedAllocation.getKey()) : unevaluatedAllocation.getKey(); builder.addUnevaluatedAllocation( - new AllocationDetails(unevaluatedKey, AllocationEvaluationCode.UNEVALUATED, i + 2)); + new AllocationDetails(unevaluatedKey, AllocationEvaluationCode.UNEVALUATED, i + 1)); } break; diff --git a/src/main/java/cloud/eppo/api/EppoValue.java b/src/main/java/cloud/eppo/api/EppoValue.java index 87c03fd3..1ff12ec6 100644 --- a/src/main/java/cloud/eppo/api/EppoValue.java +++ b/src/main/java/cloud/eppo/api/EppoValue.java @@ -113,7 +113,7 @@ public static T unwrap(EppoValue value, VariationType expectedType) { case BOOLEAN: return (T) Boolean.valueOf(value.booleanValue()); case INTEGER: - return (T) Integer.valueOf((int) value.doubleValue()); + return (T) Integer.valueOf(Double.valueOf(value.doubleValue()).intValue()); case NUMERIC: return (T) Double.valueOf(value.doubleValue()); case STRING: diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java index 5ba2c625..ab6cf08f 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java @@ -260,17 +260,26 @@ private static void assertAssignmentDetails( String.format("Matched allocation mismatch for flag %s, subject %s", flagKey, subjectKey)); // Compare allocation lists - assertEquals( - expectedDetails.getUnmatchedAllocations().size(), - actualDetails.getUnmatchedAllocations().size(), + assertAllocationListsEqual( + expectedDetails.getUnmatchedAllocations(), + actualDetails.getUnmatchedAllocations(), + String.format("Unmatched allocations mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertAllocationListsEqual( + expectedDetails.getUnevaluatedAllocations(), + actualDetails.getUnevaluatedAllocations(), String.format( - "Unmatched allocations count mismatch for flag %s, subject %s", flagKey, subjectKey)); + "Unevaluated allocations mismatch for flag %s, subject %s", flagKey, subjectKey)); + } - assertEquals( - expectedDetails.getUnevaluatedAllocations().size(), - actualDetails.getUnevaluatedAllocations().size(), - String.format( - "Unevaluated allocations count mismatch for flag %s, subject %s", flagKey, subjectKey)); + private static void assertAllocationListsEqual( + List expected, List actual, String message) { + assertEquals(expected.size(), actual.size(), message + " (count)"); + + for (int i = 0; i < expected.size(); i++) { + assertAllocationDetailsEqual( + expected.get(i), actual.get(i), message + " (index " + i + ")"); + } } private static void assertVariationValuesEqual( From 8a80708b6644e2a3746deb44139bf8dc30fbec36 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 9 Dec 2025 10:16:37 -0500 Subject: [PATCH 08/11] appease linter --- src/test/java/cloud/eppo/helpers/AssignmentTestCase.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java index ab6cf08f..69bebe78 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java @@ -263,7 +263,8 @@ private static void assertAssignmentDetails( assertAllocationListsEqual( expectedDetails.getUnmatchedAllocations(), actualDetails.getUnmatchedAllocations(), - String.format("Unmatched allocations mismatch for flag %s, subject %s", flagKey, subjectKey)); + String.format( + "Unmatched allocations mismatch for flag %s, subject %s", flagKey, subjectKey)); assertAllocationListsEqual( expectedDetails.getUnevaluatedAllocations(), @@ -277,8 +278,7 @@ private static void assertAllocationListsEqual( assertEquals(expected.size(), actual.size(), message + " (count)"); for (int i = 0; i < expected.size(); i++) { - assertAllocationDetailsEqual( - expected.get(i), actual.get(i), message + " (index " + i + ")"); + assertAllocationDetailsEqual(expected.get(i), actual.get(i), message + " (index " + i + ")"); } } From 0e7ffdbc01ad1e66a146b0a9b8c0518fd01279ad Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 9 Dec 2025 19:52:00 -0500 Subject: [PATCH 09/11] expand FlagEvaluatorTest to include details --- .../java/cloud/eppo/FlagEvaluatorTest.java | 162 +++++++++++++++++- 1 file changed, 153 insertions(+), 9 deletions(-) diff --git a/src/test/java/cloud/eppo/FlagEvaluatorTest.java b/src/test/java/cloud/eppo/FlagEvaluatorTest.java index 3f895414..f6e2c0e4 100644 --- a/src/test/java/cloud/eppo/FlagEvaluatorTest.java +++ b/src/test/java/cloud/eppo/FlagEvaluatorTest.java @@ -4,9 +4,12 @@ import static cloud.eppo.Utils.getMD5Hex; import static org.junit.jupiter.api.Assertions.*; +import cloud.eppo.api.AllocationDetails; +import cloud.eppo.api.AllocationEvaluationCode; import cloud.eppo.api.Attributes; import cloud.eppo.api.EppoValue; import cloud.eppo.api.EvaluationDetails; +import cloud.eppo.api.FlagEvaluationCode; import cloud.eppo.model.ShardRange; import cloud.eppo.ufc.dto.Allocation; import cloud.eppo.ufc.dto.FlagConfig; @@ -68,6 +71,25 @@ public void testDisabledFlag() { assertEquals("Production", details.getEnvironmentName()); assertEquals(testConfigFetchedAt, details.getConfigFetchedAt()); assertEquals(testConfigPublishedAt, details.getConfigPublishedAt()); + + // Verify evaluation details for disabled flag + assertEquals(FlagEvaluationCode.FLAG_UNRECOGNIZED_OR_DISABLED, details.getFlagEvaluationCode()); + assertEquals("Unrecognized or disabled flag: flag", details.getFlagEvaluationDescription()); + assertNull(details.getVariationKey()); + assertNull(details.getVariationValue()); + assertNull(details.getBanditKey()); + assertNull(details.getBanditAction()); + assertNull(details.getMatchedRule()); + assertNull(details.getMatchedAllocation()); + assertTrue(details.getUnmatchedAllocations().isEmpty()); + + // Disabled flag should have all allocations as unevaluated + assertEquals(1, details.getUnevaluatedAllocations().size()); + AllocationDetails unevaluatedAllocation = details.getUnevaluatedAllocations().get(0); + assertEquals("allocation", unevaluatedAllocation.getKey()); + assertEquals( + AllocationEvaluationCode.UNEVALUATED, unevaluatedAllocation.getAllocationEvaluationCode()); + assertEquals(1, unevaluatedAllocation.getOrderPosition()); } @Test @@ -76,7 +98,7 @@ public void testNoAllocations() { FlagConfig flag = createFlag("flag", true, variations, null); DetailedFlagEvaluationResult result = FlagEvaluator.evaluateFlagWithDetails( - flag, "flag", "subjectKey", new Attributes(), false, null, null, null); + flag, "flag", "subjectKey", new Attributes(), false, "Test", null, null); assertEquals(flag.getKey(), result.getFlagKey()); assertEquals("subjectKey", result.getSubjectKey()); @@ -84,6 +106,21 @@ public void testNoAllocations() { assertNull(result.getAllocationKey()); assertNull(result.getVariation()); assertFalse(result.doLog()); + + // Verify evaluation details for no allocations + EvaluationDetails details = result.getEvaluationDetails(); + assertNotNull(details); + assertEquals("Test", details.getEnvironmentName()); + assertEquals(FlagEvaluationCode.DEFAULT_ALLOCATION_NULL, details.getFlagEvaluationCode()); + assertEquals( + "No allocations matched. Falling back to \"Default Allocation\", serving NULL", + details.getFlagEvaluationDescription()); + assertNull(details.getVariationKey()); + assertNull(details.getVariationValue()); + assertNull(details.getMatchedRule()); + assertNull(details.getMatchedAllocation()); + assertTrue(details.getUnmatchedAllocations().isEmpty()); + assertTrue(details.getUnevaluatedAllocations().isEmpty()); } @Test @@ -123,6 +160,27 @@ public void testSimpleFlag() { assertEquals("Staging", details.getEnvironmentName()); assertEquals(testConfigFetchedAt, details.getConfigFetchedAt()); assertEquals(testConfigPublishedAt, details.getConfigPublishedAt()); + + // Verify evaluation details for matched flag + assertEquals(FlagEvaluationCode.MATCH, details.getFlagEvaluationCode()); + assertTrue(details.getFlagEvaluationDescription().contains("allocation")); + assertEquals("a", details.getVariationKey()); + assertEquals("A", details.getVariationValue().stringValue()); + assertNull(details.getBanditKey()); + assertNull(details.getBanditAction()); + assertNull(details.getMatchedRule()); // No rules, just traffic split + + // Verify matched allocation + assertNotNull(details.getMatchedAllocation()); + assertEquals("allocation", details.getMatchedAllocation().getKey()); + assertEquals( + AllocationEvaluationCode.MATCH, + details.getMatchedAllocation().getAllocationEvaluationCode()); + assertEquals(1, details.getMatchedAllocation().getOrderPosition()); + + // No unmatched or unevaluated allocations for single allocation flag + assertTrue(details.getUnmatchedAllocations().isEmpty()); + assertTrue(details.getUnevaluatedAllocations().isEmpty()); } @Test @@ -207,24 +265,79 @@ public void testMultipleAllocations() { allocations.addAll(createAllocations("default", defaultSplits)); FlagConfig flag = createFlag("key", true, variations, allocations); + // Test 1: Subject matches first allocation's rules Attributes matchingEmailAttributes = new Attributes(); matchingEmailAttributes.put("email", "eppo@example.com"); DetailedFlagEvaluationResult result = FlagEvaluator.evaluateFlagWithDetails( - flag, "flag", "subjectKey", matchingEmailAttributes, false, null, null, null); + flag, "flag", "subjectKey", matchingEmailAttributes, false, "Test", null, null); assertEquals("B", result.getVariation().getValue().stringValue()); + // Verify details when first allocation matches + EvaluationDetails details = result.getEvaluationDetails(); + assertEquals(FlagEvaluationCode.MATCH, details.getFlagEvaluationCode()); + assertEquals("b", details.getVariationKey()); + assertNotNull(details.getMatchedRule()); + assertEquals(1, details.getMatchedRule().getConditions().size()); + + // Matched allocation should be "first" at position 1 + assertNotNull(details.getMatchedAllocation()); + assertEquals("first", details.getMatchedAllocation().getKey()); + assertEquals( + AllocationEvaluationCode.MATCH, + details.getMatchedAllocation().getAllocationEvaluationCode()); + assertEquals(1, details.getMatchedAllocation().getOrderPosition()); + + // "default" allocation should be unevaluated at position 2 + assertTrue(details.getUnmatchedAllocations().isEmpty()); + assertEquals(1, details.getUnevaluatedAllocations().size()); + assertEquals("default", details.getUnevaluatedAllocations().get(0).getKey()); + assertEquals( + AllocationEvaluationCode.UNEVALUATED, + details.getUnevaluatedAllocations().get(0).getAllocationEvaluationCode()); + assertEquals(2, details.getUnevaluatedAllocations().get(0).getOrderPosition()); + + // Test 2: Subject doesn't match first allocation's rules, falls through to default Attributes unknownEmailAttributes = new Attributes(); unknownEmailAttributes.put("email", "eppo@test.com"); result = FlagEvaluator.evaluateFlagWithDetails( - flag, "flag", "subjectKey", unknownEmailAttributes, false, null, null, null); + flag, "flag", "subjectKey", unknownEmailAttributes, false, "Test", null, null); assertEquals("A", result.getVariation().getValue().stringValue()); + // Verify details when first allocation doesn't match + details = result.getEvaluationDetails(); + assertEquals(FlagEvaluationCode.MATCH, details.getFlagEvaluationCode()); + assertEquals("a", details.getVariationKey()); + assertNull(details.getMatchedRule()); // default has no rules + + // Matched allocation should be "default" at position 2 + assertNotNull(details.getMatchedAllocation()); + assertEquals("default", details.getMatchedAllocation().getKey()); + assertEquals( + AllocationEvaluationCode.MATCH, + details.getMatchedAllocation().getAllocationEvaluationCode()); + assertEquals(2, details.getMatchedAllocation().getOrderPosition()); + + // "first" allocation should be unmatched (FAILING_RULE) at position 1 + assertEquals(1, details.getUnmatchedAllocations().size()); + assertEquals("first", details.getUnmatchedAllocations().get(0).getKey()); + assertEquals( + AllocationEvaluationCode.FAILING_RULE, + details.getUnmatchedAllocations().get(0).getAllocationEvaluationCode()); + assertEquals(1, details.getUnmatchedAllocations().get(0).getOrderPosition()); + assertTrue(details.getUnevaluatedAllocations().isEmpty()); + + // Test 3: No attributes - also falls through to default result = FlagEvaluator.evaluateFlagWithDetails( - flag, "flag", "subjectKey", new Attributes(), false, null, null, null); + flag, "flag", "subjectKey", new Attributes(), false, "Test", null, null); assertEquals("A", result.getVariation().getValue().stringValue()); + + details = result.getEvaluationDetails(); + assertEquals("default", details.getMatchedAllocation().getKey()); + assertEquals(1, details.getUnmatchedAllocations().size()); + assertEquals("first", details.getUnmatchedAllocations().get(0).getKey()); } @Test @@ -285,32 +398,63 @@ public void testAllocationStartAndEndAt() { DetailedFlagEvaluationResult result = FlagEvaluator.evaluateFlagWithDetails( - flag, "flag", "subject", new Attributes(), false, null, null, null); + flag, "flag", "subject", new Attributes(), false, "Test", null, null); assertEquals("A", result.getVariation().getValue().stringValue()); assertTrue(result.doLog()); - // Make both start startAt and endAt in the future + // Verify details for active allocation + EvaluationDetails details = result.getEvaluationDetails(); + assertEquals(FlagEvaluationCode.MATCH, details.getFlagEvaluationCode()); + assertNotNull(details.getMatchedAllocation()); + assertEquals("allocation", details.getMatchedAllocation().getKey()); + assertEquals( + AllocationEvaluationCode.MATCH, + details.getMatchedAllocation().getAllocationEvaluationCode()); + + // Make both startAt and endAt in the future (allocation not yet active) allocation.setStartAt(new Date(now.getTime() + oneDayInMilliseconds)); allocation.setEndAt(new Date(now.getTime() + 2 * oneDayInMilliseconds)); result = FlagEvaluator.evaluateFlagWithDetails( - flag, "flag", "subject", new Attributes(), false, null, null, null); + flag, "flag", "subject", new Attributes(), false, "Test", null, null); assertNull(result.getVariation()); assertFalse(result.doLog()); - // Make both startAt and endAt in the past + // Verify details for not-yet-active allocation + details = result.getEvaluationDetails(); + assertEquals(FlagEvaluationCode.DEFAULT_ALLOCATION_NULL, details.getFlagEvaluationCode()); + assertNull(details.getMatchedAllocation()); + assertEquals(1, details.getUnmatchedAllocations().size()); + assertEquals("allocation", details.getUnmatchedAllocations().get(0).getKey()); + assertEquals( + AllocationEvaluationCode.BEFORE_START_TIME, + details.getUnmatchedAllocations().get(0).getAllocationEvaluationCode()); + assertEquals(1, details.getUnmatchedAllocations().get(0).getOrderPosition()); + + // Make both startAt and endAt in the past (allocation expired) allocation.setStartAt(new Date(now.getTime() - 2 * oneDayInMilliseconds)); allocation.setEndAt(new Date(now.getTime() - oneDayInMilliseconds)); result = FlagEvaluator.evaluateFlagWithDetails( - flag, "flag", "subject", new Attributes(), false, null, null, null); + flag, "flag", "subject", new Attributes(), false, "Test", null, null); assertNull(result.getVariation()); assertFalse(result.doLog()); + + // Verify details for expired allocation + details = result.getEvaluationDetails(); + assertEquals(FlagEvaluationCode.DEFAULT_ALLOCATION_NULL, details.getFlagEvaluationCode()); + assertNull(details.getMatchedAllocation()); + assertEquals(1, details.getUnmatchedAllocations().size()); + assertEquals("allocation", details.getUnmatchedAllocations().get(0).getKey()); + assertEquals( + AllocationEvaluationCode.AFTER_END_TIME, + details.getUnmatchedAllocations().get(0).getAllocationEvaluationCode()); + assertEquals(1, details.getUnmatchedAllocations().get(0).getOrderPosition()); } @Test From ef7997213a1f9388ee0f0af3331eeb8c7626c040 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Wed, 10 Dec 2025 08:42:59 -0500 Subject: [PATCH 10/11] make an instance method --- src/main/java/cloud/eppo/BaseEppoClient.java | 4 +-- src/main/java/cloud/eppo/api/EppoValue.java | 17 +++++------ .../java/cloud/eppo/api/EppoValueTest.java | 30 +++++++++---------- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index a6f3832c..6e1966e2 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -206,7 +206,7 @@ protected AssignmentDetails getTypedAssignmentWithDetails( T resultValue = details.evaluationSuccessful() - ? EppoValue.unwrap(details.getVariationValue(), expectedType) + ? details.getVariationValue().unwrap(expectedType) : defaultValue; return new AssignmentDetails<>(resultValue, null, details); } @@ -378,7 +378,7 @@ private boolean valueTypeMatchesExpected(VariationType expectedType, EppoValue v typeMatch = value.isString() // Eppo leaves JSON as a JSON string; to verify it's valid we attempt to parse - && EppoValue.unwrap(value, VariationType.JSON) != null; + && value.unwrap(VariationType.JSON) != null; break; default: throw new IllegalArgumentException("Unexpected type for type checking: " + expectedType); diff --git a/src/main/java/cloud/eppo/api/EppoValue.java b/src/main/java/cloud/eppo/api/EppoValue.java index 1ff12ec6..c3355d9d 100644 --- a/src/main/java/cloud/eppo/api/EppoValue.java +++ b/src/main/java/cloud/eppo/api/EppoValue.java @@ -100,26 +100,25 @@ public EppoValueType getType() { } /** - * Unwraps an EppoValue to the appropriate Java type based on the variation type. + * Unwraps this EppoValue to the appropriate Java type based on the variation type. * - * @param value the EppoValue to unwrap * @param expectedType the expected variation type - * @param the target type + * @param the target type (Boolean, Integer, Double, String, or JsonNode) * @return the unwrapped value */ @SuppressWarnings("unchecked") - public static T unwrap(EppoValue value, VariationType expectedType) { + public T unwrap(VariationType expectedType) { switch (expectedType) { case BOOLEAN: - return (T) Boolean.valueOf(value.booleanValue()); + return (T) Boolean.valueOf(booleanValue()); case INTEGER: - return (T) Integer.valueOf(Double.valueOf(value.doubleValue()).intValue()); + return (T) Integer.valueOf(Double.valueOf(doubleValue()).intValue()); case NUMERIC: - return (T) Double.valueOf(value.doubleValue()); + return (T) Double.valueOf(doubleValue()); case STRING: - return (T) value.stringValue(); + return (T) stringValue(); case JSON: - String jsonString = value.stringValue(); + String jsonString = stringValue(); try { ObjectMapper mapper = new ObjectMapper(); return (T) mapper.readTree(jsonString); diff --git a/src/test/java/cloud/eppo/api/EppoValueTest.java b/src/test/java/cloud/eppo/api/EppoValueTest.java index c9b633ca..791e624a 100644 --- a/src/test/java/cloud/eppo/api/EppoValueTest.java +++ b/src/test/java/cloud/eppo/api/EppoValueTest.java @@ -73,44 +73,44 @@ public void testToStringConsistencyAcrossTypes() { @Test public void testUnwrapBoolean() { EppoValue boolValue = EppoValue.valueOf(true); - Boolean result = EppoValue.unwrap(boolValue, VariationType.BOOLEAN); + Boolean result = boolValue.unwrap(VariationType.BOOLEAN); assertEquals(Boolean.TRUE, result); EppoValue falseValue = EppoValue.valueOf(false); - Boolean falseResult = EppoValue.unwrap(falseValue, VariationType.BOOLEAN); + Boolean falseResult = falseValue.unwrap(VariationType.BOOLEAN); assertEquals(Boolean.FALSE, falseResult); } @Test public void testUnwrapInteger() { EppoValue numValue = EppoValue.valueOf(42.0); - Integer result = EppoValue.unwrap(numValue, VariationType.INTEGER); + Integer result = numValue.unwrap(VariationType.INTEGER); assertEquals(Integer.valueOf(42), result); EppoValue negativeValue = EppoValue.valueOf(-17.0); - Integer negativeResult = EppoValue.unwrap(negativeValue, VariationType.INTEGER); + Integer negativeResult = negativeValue.unwrap(VariationType.INTEGER); assertEquals(Integer.valueOf(-17), negativeResult); } @Test public void testUnwrapNumeric() { EppoValue numValue = EppoValue.valueOf(123.456); - Double result = EppoValue.unwrap(numValue, VariationType.NUMERIC); + Double result = numValue.unwrap(VariationType.NUMERIC); assertEquals(Double.valueOf(123.456), result); EppoValue intValue = EppoValue.valueOf(100.0); - Double intResult = EppoValue.unwrap(intValue, VariationType.NUMERIC); + Double intResult = intValue.unwrap(VariationType.NUMERIC); assertEquals(Double.valueOf(100.0), intResult); } @Test public void testUnwrapString() { EppoValue strValue = EppoValue.valueOf("hello world"); - String result = EppoValue.unwrap(strValue, VariationType.STRING); + String result = strValue.unwrap(VariationType.STRING); assertEquals("hello world", result); EppoValue emptyValue = EppoValue.valueOf(""); - String emptyResult = EppoValue.unwrap(emptyValue, VariationType.STRING); + String emptyResult = emptyValue.unwrap(VariationType.STRING); assertEquals("", emptyResult); } @@ -118,7 +118,7 @@ public void testUnwrapString() { public void testUnwrapJsonValid() { String jsonString = "{\"foo\":\"bar\",\"count\":42}"; EppoValue jsonValue = EppoValue.valueOf(jsonString); - JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + JsonNode result = jsonValue.unwrap(VariationType.JSON); assertTrue(result.isObject()); assertEquals("bar", result.get("foo").asText()); @@ -129,7 +129,7 @@ public void testUnwrapJsonValid() { public void testUnwrapJsonArray() { String jsonArrayString = "[1,2,3,4,5]"; EppoValue jsonValue = EppoValue.valueOf(jsonArrayString); - JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + JsonNode result = jsonValue.unwrap(VariationType.JSON); assertTrue(result.isArray()); assertEquals(5, result.size()); @@ -141,7 +141,7 @@ public void testUnwrapJsonArray() { public void testUnwrapJsonWithSpecialCharacters() { String jsonString = "{\"a\":\"kümmert\",\"b\":\"schön\"}"; EppoValue jsonValue = EppoValue.valueOf(jsonString); - JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + JsonNode result = jsonValue.unwrap(VariationType.JSON); assertTrue(result.isObject()); assertEquals("kümmert", result.get("a").asText()); @@ -152,7 +152,7 @@ public void testUnwrapJsonWithSpecialCharacters() { public void testUnwrapJsonWithEmojis() { String jsonString = "{\"a\":\"🤗\",\"b\":\"🌸\"}"; EppoValue jsonValue = EppoValue.valueOf(jsonString); - JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + JsonNode result = jsonValue.unwrap(VariationType.JSON); assertTrue(result.isObject()); assertEquals("🤗", result.get("a").asText()); @@ -163,7 +163,7 @@ public void testUnwrapJsonWithEmojis() { public void testUnwrapJsonWithWhitespace() { String jsonString = "{ \"key\": \"value\", \"number\": 123 }"; EppoValue jsonValue = EppoValue.valueOf(jsonString); - JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + JsonNode result = jsonValue.unwrap(VariationType.JSON); assertTrue(result.isObject()); assertEquals("value", result.get("key").asText()); @@ -174,7 +174,7 @@ public void testUnwrapJsonWithWhitespace() { public void testUnwrapJsonInvalid() { String invalidJson = "not valid json {"; EppoValue jsonValue = EppoValue.valueOf(invalidJson); - JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + JsonNode result = jsonValue.unwrap(VariationType.JSON); assertNull(result, "Invalid JSON should return null"); } @@ -183,7 +183,7 @@ public void testUnwrapJsonInvalid() { public void testUnwrapJsonEmpty() { String emptyJson = "{}"; EppoValue jsonValue = EppoValue.valueOf(emptyJson); - JsonNode result = EppoValue.unwrap(jsonValue, VariationType.JSON); + JsonNode result = jsonValue.unwrap(VariationType.JSON); assertTrue(result.isObject()); assertEquals(0, result.size()); From a1331428dbfda3b8365f23da185a9a86bb90ffba Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Wed, 10 Dec 2025 09:30:11 -0500 Subject: [PATCH 11/11] repurpose EvaluationResult --- src/main/java/cloud/eppo/BaseEppoClient.java | 18 +- .../eppo/DetailedFlagEvaluationResult.java | 153 -------------- .../java/cloud/eppo/FlagEvaluationResult.java | 188 ++++++++++++++++-- src/main/java/cloud/eppo/FlagEvaluator.java | 6 +- .../java/cloud/eppo/FlagEvaluatorTest.java | 66 +++--- 5 files changed, 215 insertions(+), 216 deletions(-) delete mode 100644 src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 6e1966e2..5a4e15c4 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -269,8 +269,8 @@ protected EvaluationDetails evaluateAndMaybeLog( } // Evaluate flag with details - DetailedFlagEvaluationResult detailedResult = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluationResult evaluationResult = + FlagEvaluator.evaluateFlag( flag, flagKey, subjectKey, @@ -279,10 +279,10 @@ protected EvaluationDetails evaluateAndMaybeLog( config.getEnvironmentName(), config.getConfigFetchedAt(), config.getConfigPublishedAt()); - EvaluationDetails evaluationDetails = detailedResult.getEvaluationDetails(); + EvaluationDetails evaluationDetails = evaluationResult.getEvaluationDetails(); EppoValue assignedValue = - detailedResult.getVariation() != null ? detailedResult.getVariation().getValue() : null; + evaluationResult.getVariation() != null ? evaluationResult.getVariation().getValue() : null; // Check if value type matches expected if (assignedValue != null && !valueTypeMatchesExpected(expectedType, assignedValue)) { @@ -295,7 +295,7 @@ protected EvaluationDetails evaluateAndMaybeLog( // Update evaluation details with error code but keep the matched allocation and variation // info String variationKey = - detailedResult.getVariation() != null ? detailedResult.getVariation().getKey() : null; + evaluationResult.getVariation() != null ? evaluationResult.getVariation().getKey() : null; String errorDescription = String.format( "Variation (%s) is configured for type %s, but is set to incompatible value (%s)", @@ -313,16 +313,16 @@ protected EvaluationDetails evaluateAndMaybeLog( } // Log assignment if applicable - if (assignedValue != null && assignmentLogger != null && detailedResult.doLog()) { + if (assignedValue != null && assignmentLogger != null && evaluationResult.doLog()) { try { - String allocationKey = detailedResult.getAllocationKey(); + String allocationKey = evaluationResult.getAllocationKey(); String experimentKey = flagKey + '-' + allocationKey; // Our experiment key is derived by hyphenating the flag key and // allocation key - String variationKey = detailedResult.getVariation().getKey(); - Map extraLogging = detailedResult.getExtraLogging(); + String variationKey = evaluationResult.getVariation().getKey(); + Map extraLogging = evaluationResult.getExtraLogging(); Map metaData = buildLogMetaData(config.isConfigObfuscated()); Assignment assignment = diff --git a/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java b/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java deleted file mode 100644 index 0cfb9c87..00000000 --- a/src/main/java/cloud/eppo/DetailedFlagEvaluationResult.java +++ /dev/null @@ -1,153 +0,0 @@ -package cloud.eppo; - -import cloud.eppo.api.*; -import cloud.eppo.ufc.dto.Variation; -import java.util.Date; -import java.util.Map; - -/** - * Extended flag evaluation result that includes detailed evaluation information for debugging and - * understanding flag assignments. - */ -public class DetailedFlagEvaluationResult extends FlagEvaluationResult { - private final EvaluationDetails evaluationDetails; - - public DetailedFlagEvaluationResult( - String flagKey, - String subjectKey, - Attributes subjectAttributes, - String allocationKey, - Variation variation, - Map extraLogging, - boolean doLog, - EvaluationDetails evaluationDetails) { - super(flagKey, subjectKey, subjectAttributes, allocationKey, variation, extraLogging, doLog); - this.evaluationDetails = evaluationDetails; - } - - public EvaluationDetails getEvaluationDetails() { - return evaluationDetails; - } - - /** Builder to construct detailed evaluation results during flag evaluation. */ - public static class Builder { - private String flagKey; - private String subjectKey; - private Attributes subjectAttributes; - private String allocationKey; - private Variation variation; - private Map extraLogging; - private boolean doLog; - - // Delegate to EvaluationDetails.Builder for evaluation details - private final EvaluationDetails.Builder detailsBuilder = EvaluationDetails.builder(); - - public Builder flagKey(String flagKey) { - this.flagKey = flagKey; - return this; - } - - public Builder subjectKey(String subjectKey) { - this.subjectKey = subjectKey; - return this; - } - - public Builder subjectAttributes(Attributes subjectAttributes) { - this.subjectAttributes = subjectAttributes; - return this; - } - - public Builder allocationKey(String allocationKey) { - this.allocationKey = allocationKey; - return this; - } - - public Builder variation(Variation variation) { - this.variation = variation; - return this; - } - - public Builder extraLogging(Map extraLogging) { - this.extraLogging = extraLogging; - return this; - } - - public Builder doLog(boolean doLog) { - this.doLog = doLog; - return this; - } - - public Builder environmentName(String environmentName) { - detailsBuilder.environmentName(environmentName); - return this; - } - - public Builder flagEvaluationCode(FlagEvaluationCode code) { - detailsBuilder.flagEvaluationCode(code); - return this; - } - - public Builder flagEvaluationDescription(String description) { - detailsBuilder.flagEvaluationDescription(description); - return this; - } - - public Builder banditKey(String banditKey) { - detailsBuilder.banditKey(banditKey); - return this; - } - - public Builder banditAction(String banditAction) { - detailsBuilder.banditAction(banditAction); - return this; - } - - public Builder matchedRule(MatchedRule matchedRule) { - detailsBuilder.matchedRule(matchedRule); - return this; - } - - public Builder matchedAllocation(AllocationDetails matchedAllocation) { - detailsBuilder.matchedAllocation(matchedAllocation); - return this; - } - - public Builder addUnmatchedAllocation(AllocationDetails allocation) { - detailsBuilder.addUnmatchedAllocation(allocation); - return this; - } - - public Builder addUnevaluatedAllocation(AllocationDetails allocation) { - detailsBuilder.addUnevaluatedAllocation(allocation); - return this; - } - - public Builder configFetchedAt(Date configFetchedAt) { - detailsBuilder.configFetchedAt(configFetchedAt); - return this; - } - - public Builder configPublishedAt(Date configPublishedAt) { - detailsBuilder.configPublishedAt(configPublishedAt); - return this; - } - - public DetailedFlagEvaluationResult build() { - // Set variation details before building - if (variation != null) { - detailsBuilder.variationKey(variation.getKey()); - detailsBuilder.variationValue(variation.getValue()); - } - - return new DetailedFlagEvaluationResult( - flagKey, - subjectKey, - subjectAttributes, - allocationKey, - variation, - extraLogging, - doLog, - detailsBuilder.build()); - } - } -} diff --git a/src/main/java/cloud/eppo/FlagEvaluationResult.java b/src/main/java/cloud/eppo/FlagEvaluationResult.java index 18d25235..23f4029e 100644 --- a/src/main/java/cloud/eppo/FlagEvaluationResult.java +++ b/src/main/java/cloud/eppo/FlagEvaluationResult.java @@ -1,10 +1,12 @@ package cloud.eppo; -import cloud.eppo.api.Attributes; +import cloud.eppo.api.*; import cloud.eppo.ufc.dto.Variation; +import java.util.Date; import java.util.Map; import java.util.Objects; +/** Flag evaluation result that includes detailed evaluation information. */ public class FlagEvaluationResult { private final String flagKey; @@ -14,6 +16,7 @@ public class FlagEvaluationResult { private final Variation variation; private final Map extraLogging; private final boolean doLog; + private final EvaluationDetails evaluationDetails; public FlagEvaluationResult( String flagKey, @@ -22,7 +25,8 @@ public FlagEvaluationResult( String allocationKey, Variation variation, Map extraLogging, - boolean doLog) { + boolean doLog, + EvaluationDetails evaluationDetails) { this.flagKey = flagKey; this.subjectKey = subjectKey; this.subjectAttributes = subjectAttributes; @@ -30,19 +34,32 @@ public FlagEvaluationResult( this.variation = variation; this.extraLogging = extraLogging; this.doLog = doLog; + this.evaluationDetails = evaluationDetails; } @Override public String toString() { - return "FlagEvaluationResult{" + - "flagKey='" + flagKey + '\'' + - ", subjectKey='" + subjectKey + '\'' + - ", subjectAttributes=" + subjectAttributes + - ", allocationKey='" + allocationKey + '\'' + - ", variation=" + variation + - ", extraLogging=" + extraLogging + - ", doLog=" + doLog + - '}'; + return "FlagEvaluationResult{" + + "flagKey='" + + flagKey + + '\'' + + ", subjectKey='" + + subjectKey + + '\'' + + ", subjectAttributes=" + + subjectAttributes + + ", allocationKey='" + + allocationKey + + '\'' + + ", variation=" + + variation + + ", extraLogging=" + + extraLogging + + ", doLog=" + + doLog + + ", evaluationDetails=" + + evaluationDetails + + '}'; } @Override @@ -50,17 +67,26 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; FlagEvaluationResult that = (FlagEvaluationResult) o; return doLog == that.doLog - && Objects.equals(flagKey, that.flagKey) - && Objects.equals(subjectKey, that.subjectKey) - && Objects.equals(subjectAttributes, that.subjectAttributes) - && Objects.equals(allocationKey, that.allocationKey) - && Objects.equals(variation, that.variation) - && Objects.equals(extraLogging, that.extraLogging); + && Objects.equals(flagKey, that.flagKey) + && Objects.equals(subjectKey, that.subjectKey) + && Objects.equals(subjectAttributes, that.subjectAttributes) + && Objects.equals(allocationKey, that.allocationKey) + && Objects.equals(variation, that.variation) + && Objects.equals(extraLogging, that.extraLogging) + && Objects.equals(evaluationDetails, that.evaluationDetails); } @Override public int hashCode() { - return Objects.hash(flagKey, subjectKey, subjectAttributes, allocationKey, variation, extraLogging, doLog); + return Objects.hash( + flagKey, + subjectKey, + subjectAttributes, + allocationKey, + variation, + extraLogging, + doLog, + evaluationDetails); } public String getFlagKey() { @@ -90,4 +116,130 @@ public Map getExtraLogging() { public boolean doLog() { return doLog; } + + public EvaluationDetails getEvaluationDetails() { + return evaluationDetails; + } + + /** Builder to construct flag evaluation results during flag evaluation. */ + public static class Builder { + private String flagKey; + private String subjectKey; + private Attributes subjectAttributes; + private String allocationKey; + private Variation variation; + private Map extraLogging; + private boolean doLog; + + // Delegate to EvaluationDetails.Builder for evaluation details + private final EvaluationDetails.Builder detailsBuilder = EvaluationDetails.builder(); + + public Builder flagKey(String flagKey) { + this.flagKey = flagKey; + return this; + } + + public Builder subjectKey(String subjectKey) { + this.subjectKey = subjectKey; + return this; + } + + public Builder subjectAttributes(Attributes subjectAttributes) { + this.subjectAttributes = subjectAttributes; + return this; + } + + public Builder allocationKey(String allocationKey) { + this.allocationKey = allocationKey; + return this; + } + + public Builder variation(Variation variation) { + this.variation = variation; + return this; + } + + public Builder extraLogging(Map extraLogging) { + this.extraLogging = extraLogging; + return this; + } + + public Builder doLog(boolean doLog) { + this.doLog = doLog; + return this; + } + + public Builder environmentName(String environmentName) { + detailsBuilder.environmentName(environmentName); + return this; + } + + public Builder flagEvaluationCode(FlagEvaluationCode code) { + detailsBuilder.flagEvaluationCode(code); + return this; + } + + public Builder flagEvaluationDescription(String description) { + detailsBuilder.flagEvaluationDescription(description); + return this; + } + + public Builder banditKey(String banditKey) { + detailsBuilder.banditKey(banditKey); + return this; + } + + public Builder banditAction(String banditAction) { + detailsBuilder.banditAction(banditAction); + return this; + } + + public Builder matchedRule(MatchedRule matchedRule) { + detailsBuilder.matchedRule(matchedRule); + return this; + } + + public Builder matchedAllocation(AllocationDetails matchedAllocation) { + detailsBuilder.matchedAllocation(matchedAllocation); + return this; + } + + public Builder addUnmatchedAllocation(AllocationDetails allocation) { + detailsBuilder.addUnmatchedAllocation(allocation); + return this; + } + + public Builder addUnevaluatedAllocation(AllocationDetails allocation) { + detailsBuilder.addUnevaluatedAllocation(allocation); + return this; + } + + public Builder configFetchedAt(Date configFetchedAt) { + detailsBuilder.configFetchedAt(configFetchedAt); + return this; + } + + public Builder configPublishedAt(Date configPublishedAt) { + detailsBuilder.configPublishedAt(configPublishedAt); + return this; + } + + public FlagEvaluationResult build() { + // Set variation details before building + if (variation != null) { + detailsBuilder.variationKey(variation.getKey()); + detailsBuilder.variationValue(variation.getValue()); + } + + return new FlagEvaluationResult( + flagKey, + subjectKey, + subjectAttributes, + allocationKey, + variation, + extraLogging, + doLog, + detailsBuilder.build()); + } + } } diff --git a/src/main/java/cloud/eppo/FlagEvaluator.java b/src/main/java/cloud/eppo/FlagEvaluator.java index 015c05b8..c2986467 100644 --- a/src/main/java/cloud/eppo/FlagEvaluator.java +++ b/src/main/java/cloud/eppo/FlagEvaluator.java @@ -33,7 +33,7 @@ public class FlagEvaluator { * matched rules, and evaluation codes. This is useful for debugging and understanding why a * particular variation was assigned. */ - public static DetailedFlagEvaluationResult evaluateFlagWithDetails( + public static FlagEvaluationResult evaluateFlag( FlagConfig flag, String flagKey, String subjectKey, @@ -44,8 +44,8 @@ public static DetailedFlagEvaluationResult evaluateFlagWithDetails( Date configPublishedAt) { Date now = new Date(); - DetailedFlagEvaluationResult.Builder builder = - new DetailedFlagEvaluationResult.Builder() + FlagEvaluationResult.Builder builder = + new FlagEvaluationResult.Builder() .flagKey(flagKey) .subjectKey(subjectKey) .subjectAttributes(subjectAttributes) diff --git a/src/test/java/cloud/eppo/FlagEvaluatorTest.java b/src/test/java/cloud/eppo/FlagEvaluatorTest.java index f6e2c0e4..224d5fbb 100644 --- a/src/test/java/cloud/eppo/FlagEvaluatorTest.java +++ b/src/test/java/cloud/eppo/FlagEvaluatorTest.java @@ -47,8 +47,8 @@ public void testDisabledFlag() { Date testConfigFetchedAt = new Date(1672531200000L); // Jan 1, 2023 Date testConfigPublishedAt = new Date(1672444800000L); // Dec 31, 2022 - DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag( flag, "flag", "subjectKey", @@ -96,8 +96,8 @@ public void testDisabledFlag() { public void testNoAllocations() { Map variations = createVariations("a"); FlagConfig flag = createFlag("flag", true, variations, null); - DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag( flag, "flag", "subjectKey", new Attributes(), false, "Test", null, null); assertEquals(flag.getKey(), result.getFlagKey()); @@ -136,8 +136,8 @@ public void testSimpleFlag() { Date testConfigFetchedAt = new Date(1672617600000L); // Jan 2, 2023 Date testConfigPublishedAt = new Date(1672531200000L); // Jan 1, 2023 - DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag( flag, "flag", "subjectKey", @@ -199,20 +199,20 @@ public void testIDTargetingCondition() { // Check that subjectKey is evaluated as the "id" attribute - DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag( flag, "flag", "alice", new Attributes(), false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluator.evaluateFlag( flag, "flag", "bob", new Attributes(), false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluator.evaluateFlag( flag, "flag", "charlie", new Attributes(), false, null, null, null); assertNull(result.getVariation()); @@ -222,7 +222,7 @@ public void testIDTargetingCondition() { Attributes aliceAttributes = new Attributes(); aliceAttributes.put("id", "charlie"); result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluator.evaluateFlag( flag, "flag", "alice", aliceAttributes, false, null, null, null); assertNull(result.getVariation()); @@ -231,7 +231,7 @@ public void testIDTargetingCondition() { charlieAttributes.put("id", "alice"); result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluator.evaluateFlag( flag, "flag", "charlie", charlieAttributes, false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); @@ -244,8 +244,8 @@ public void testCatchAllAllocation() { List allocations = createAllocations("default", splits); FlagConfig flag = createFlag("key", true, variations, allocations); - DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag( flag, "flag", "subjectKey", new Attributes(), false, null, null, null); assertEquals("default", result.getAllocationKey()); @@ -268,8 +268,8 @@ public void testMultipleAllocations() { // Test 1: Subject matches first allocation's rules Attributes matchingEmailAttributes = new Attributes(); matchingEmailAttributes.put("email", "eppo@example.com"); - DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag( flag, "flag", "subjectKey", matchingEmailAttributes, false, "Test", null, null); assertEquals("B", result.getVariation().getValue().stringValue()); @@ -301,7 +301,7 @@ public void testMultipleAllocations() { Attributes unknownEmailAttributes = new Attributes(); unknownEmailAttributes.put("email", "eppo@test.com"); result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluator.evaluateFlag( flag, "flag", "subjectKey", unknownEmailAttributes, false, "Test", null, null); assertEquals("A", result.getVariation().getValue().stringValue()); @@ -330,7 +330,7 @@ public void testMultipleAllocations() { // Test 3: No attributes - also falls through to default result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluator.evaluateFlag( flag, "flag", "subjectKey", new Attributes(), false, "Test", null, null); assertEquals("A", result.getVariation().getValue().stringValue()); @@ -360,20 +360,20 @@ public void testVariationShardRanges() { FlagConfig flag = createFlag("key", true, variations, allocations); - DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag( flag, "flag", "subject4", new Attributes(), false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluator.evaluateFlag( flag, "flag", "subject13", new Attributes(), false, null, null, null); assertEquals("B", result.getVariation().getValue().stringValue()); result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluator.evaluateFlag( flag, "flag", "subject14", new Attributes(), false, null, null, null); assertEquals("C", result.getVariation().getValue().stringValue()); @@ -396,8 +396,8 @@ public void testAllocationStartAndEndAt() { allocation.setStartAt(startAt); allocation.setEndAt(endAt); - DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag( flag, "flag", "subject", new Attributes(), false, "Test", null, null); assertEquals("A", result.getVariation().getValue().stringValue()); @@ -417,7 +417,7 @@ public void testAllocationStartAndEndAt() { allocation.setEndAt(new Date(now.getTime() + 2 * oneDayInMilliseconds)); result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluator.evaluateFlag( flag, "flag", "subject", new Attributes(), false, "Test", null, null); assertNull(result.getVariation()); @@ -439,7 +439,7 @@ public void testAllocationStartAndEndAt() { allocation.setEndAt(new Date(now.getTime() - oneDayInMilliseconds)); result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluator.evaluateFlag( flag, "flag", "subject", new Attributes(), false, "Test", null, null); assertNull(result.getVariation()); @@ -545,8 +545,8 @@ public void testObfuscated() { flag.getVariationType(), encodedVariations, encodedAllocations); - DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag( obfuscatedFlag, "flag", "subjectKey", matchingEmailAttributes, true, null, null, null); // Expect an unobfuscated evaluation result @@ -560,12 +560,12 @@ public void testObfuscated() { Attributes unknownEmailAttributes = new Attributes(); unknownEmailAttributes.put("email", "eppo@test.com"); result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluator.evaluateFlag( obfuscatedFlag, "flag", "subjectKey", unknownEmailAttributes, true, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluator.evaluateFlag( obfuscatedFlag, "flag", "subjectKey", new Attributes(), true, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); } @@ -637,8 +637,8 @@ public void testObfuscatedExtraLogging() { encodedAllocations); // Test with obfuscated config - DetailedFlagEvaluationResult result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag( obfuscatedFlag, "flag", "subject", new Attributes(), true, null, null, null); // Verify that extraLogging is deobfuscated @@ -650,7 +650,7 @@ public void testObfuscatedExtraLogging() { // Test with non-obfuscated config to ensure no deobfuscation happens result = - FlagEvaluator.evaluateFlagWithDetails( + FlagEvaluator.evaluateFlag( obfuscatedFlag, "flag", "subject", new Attributes(), false, null, null, null); // Verify that extraLogging remains obfuscated