diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 6a206c0e..5a4e15c4 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -12,7 +12,6 @@ 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.HashMap; @@ -190,56 +189,131 @@ 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() + ? details.getVariationValue().unwrap(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 EvaluationDetails.buildDefault( + config.getEnvironmentName(), + config.getConfigFetchedAt(), + config.getConfigPublishedAt(), + 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 EvaluationDetails.buildDefault( + config.getEnvironmentName(), + config.getConfigFetchedAt(), + config.getConfigPublishedAt(), + 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 EvaluationDetails.buildDefault( + config.getEnvironmentName(), + config.getConfigFetchedAt(), + config.getConfigPublishedAt(), + FlagEvaluationCode.TYPE_MISMATCH, + String.format( + "Flag \"%s\" has type %s, requested %s", + flagKey, flag.getVariationType(), expectedType), + null); } + // Evaluate flag with details FlagEvaluationResult evaluationResult = FlagEvaluator.evaluateFlag( - flag, flagKey, subjectKey, subjectAttributes, config.isConfigObfuscated()); + flag, + flagKey, + subjectKey, + subjectAttributes, + config.isConfigObfuscated(), + config.getEnvironmentName(), + config.getConfigFetchedAt(), + config.getConfigPublishedAt()); + EvaluationDetails evaluationDetails = evaluationResult.getEvaluationDetails(); + EppoValue assignedValue = evaluationResult.getVariation() != null ? evaluationResult.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; + + // Update evaluation details with error code but keep the matched allocation and variation + // info + String variationKey = + 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)", + variationKey, expectedType, assignedValue.doubleValue()); + + return EvaluationDetails.builder(evaluationDetails) + .flagEvaluationCode( + FlagEvaluationCode + .ASSIGNMENT_ERROR) // We use ASSIGNMENT_ERROR for value mismatch as it's a + // misconfiguration of the flag itself + .flagEvaluationDescription(errorDescription) + .variationKey(variationKey) + .variationValue(assignedValue) + .build(); } + // Log assignment if applicable if (assignedValue != null && assignmentLogger != null && evaluationResult.doLog()) { - try { String allocationKey = evaluationResult.getAllocationKey(); String experimentKey = @@ -278,7 +352,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 +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 - && parseJsonString(value.stringValue()) != null; + && value.unwrap(VariationType.JSON) != null; break; default: throw new IllegalArgumentException("Unexpected type for type checking: " + expectedType); @@ -318,17 +393,31 @@ 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, + EvaluationDetails.buildDefault( + getConfiguration().getEnvironmentName(), + getConfiguration().getConfigFetchedAt(), + getConfiguration().getConfigPublishedAt(), + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); } } @@ -338,17 +427,31 @@ 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, + EvaluationDetails.buildDefault( + getConfiguration().getEnvironmentName(), + getConfiguration().getConfigFetchedAt(), + getConfiguration().getConfigPublishedAt(), + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); } } @@ -358,17 +461,31 @@ 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, + EvaluationDetails.buildDefault( + getConfiguration().getEnvironmentName(), + getConfiguration().getConfigFetchedAt(), + getConfiguration().getConfigPublishedAt(), + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); } } @@ -378,105 +495,100 @@ 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, + EvaluationDetails.buildDefault( + getConfiguration().getEnvironmentName(), + getConfiguration().getConfigFetchedAt(), + getConfiguration().getConfigPublishedAt(), + 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, + EvaluationDetails.buildDefault( + getConfiguration().getEnvironmentName(), + getConfiguration().getConfigFetchedAt(), + getConfiguration().getConfigPublishedAt(), + 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, + EvaluationDetails.buildDefault( + getConfiguration().getEnvironmentName(), + getConfiguration().getConfigFetchedAt(), + getConfiguration().getConfigPublishedAt(), + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); } } @@ -486,68 +598,152 @@ public BanditResult getBanditAction( DiscriminableAttributes subjectAttributes, Actions actions, String defaultValue) { - BanditResult result = new BanditResult(defaultValue, null); + try { + AssignmentDetails details = + getBanditActionDetails(flagKey, subjectKey, subjectAttributes, actions, defaultValue); + return new BanditResult(details.getVariation(), details.getAction()); + } catch (Exception e) { + return throwIfNotGraceful(e, new BanditResult(defaultValue, null)); + } + } + + /** + * 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 { - String assignedVariation = - getStringAssignment( + // Get detailed flag assignment + AssignmentDetails flagDetails = + getStringAssignmentDetails( flagKey, subjectKey, subjectAttributes.getAllAttributes(), defaultValue); - // Update result to reflect that we've been assigned a variation - result = new BanditResult(assignedVariation, null); + 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()) { - 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 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 = + 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); + } + if (banditKey != null) { + 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) { - banditAssignmentCache.put(cacheEntry); + 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); } - } catch (Exception e) { - log.warn("Error logging bandit assignment: {}", e.getMessage(), e); } + + // Update evaluation details to include bandit information + EvaluationDetails updatedDetails = + EvaluationDetails.builder(flagDetails.getEvaluationDetails()) + .banditKey(banditKey) + .banditAction(assignedAction) + .build(); + + return new AssignmentDetails<>(assignedVariation, assignedAction, updatedDetails); + } catch (Exception banditError) { + // 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 = + 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); } } - return result; + + // No bandit - return flag details as-is + return flagDetails; } catch (Exception e) { - return throwIfNotGraceful(e, result); + BanditResult defaultResult = new BanditResult(defaultValue, null); + AssignmentDetails errorDetails = + new AssignmentDetails<>( + defaultValue, + null, + EvaluationDetails.buildDefault( + config.getEnvironmentName(), + config.getConfigFetchedAt(), + config.getConfigPublishedAt(), + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); + return throwIfNotGraceful(e, errorDetails); } } 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 0a2e78f7..c2986467 100644 --- a/src/main/java/cloud/eppo/FlagEvaluator.java +++ b/src/main/java/cloud/eppo/FlagEvaluator.java @@ -1,98 +1,168 @@ 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.Shard; import cloud.eppo.ufc.dto.Split; +import cloud.eppo.ufc.dto.TargetingRule; import cloud.eppo.ufc.dto.Variation; 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 { + /** + * 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 FlagEvaluationResult evaluateFlag( FlagConfig flag, String flagKey, String subjectKey, Attributes subjectAttributes, - boolean isConfigObfuscated) { + boolean isConfigObfuscated, + String environmentName, + Date configFetchedAt, + Date configPublishedAt) { Date now = new Date(); - Variation variation = null; - String allocationKey = null; - Map extraLogging = new HashMap<>(); - boolean doLog = false; + FlagEvaluationResult.Builder builder = + new FlagEvaluationResult.Builder() + .flagKey(flagKey) + .subjectKey(subjectKey) + .subjectAttributes(subjectAttributes) + .extraLogging(new HashMap<>()) + .environmentName(environmentName != null ? environmentName : "Unknown") + .configFetchedAt(configFetchedAt) + .configPublishedAt(configPublishedAt); + + // 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,27 +186,116 @@ 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) { - // If deobfuscation fails, keep the original key-value pair - deobfuscatedExtraLogging.put(entry.getKey(), entry.getValue()); + // 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()); + } } + extraLogging = deobfuscatedExtraLogging; + } + } + + // Build matched rule details if applicable + MatchedRule matchedRule = null; + if (matchedTargetingRule != null) { + 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; + } + } + } + + // 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); + } + + // 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); } - extraLogging = deobfuscatedExtraLogging; + } 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 + 1)); } + + break; + } + + // 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 new FlagEvaluationResult( - flagKey, subjectKey, subjectAttributes, allocationKey, variation, extraLogging, doLog); + return builder.build(); } private static boolean allShardsMatch( 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/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/EppoValue.java b/src/main/java/cloud/eppo/api/EppoValue.java index aee8bae5..c3355d9d 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,36 @@ public EppoValueType getType() { return type; } + /** + * Unwraps this EppoValue to the appropriate Java type based on the variation type. + * + * @param expectedType the expected variation type + * @param the target type (Boolean, Integer, Double, String, or JsonNode) + * @return the unwrapped value + */ + @SuppressWarnings("unchecked") + public T unwrap(VariationType expectedType) { + switch (expectedType) { + case BOOLEAN: + return (T) Boolean.valueOf(booleanValue()); + case INTEGER: + return (T) Integer.valueOf(Double.valueOf(doubleValue()).intValue()); + case NUMERIC: + return (T) Double.valueOf(doubleValue()); + case STRING: + return (T) stringValue(); + case JSON: + String jsonString = 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..0d500de0 --- /dev/null +++ b/src/main/java/cloud/eppo/api/EvaluationDetails.java @@ -0,0 +1,271 @@ +package cloud.eppo.api; + +import java.util.ArrayList; +import java.util.Date; +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; + private final Date configFetchedAt; + private final Date configPublishedAt; + + public EvaluationDetails( + String environmentName, + Date configFetchedAt, + Date configPublishedAt, + FlagEvaluationCode flagEvaluationCode, + String flagEvaluationDescription, + String banditKey, + String banditAction, + String variationKey, + EppoValue variationValue, + MatchedRule matchedRule, + AllocationDetails matchedAllocation, + List unmatchedAllocations, + List unevaluatedAllocations) { + this.environmentName = environmentName; + this.configFetchedAt = configFetchedAt; + this.configPublishedAt = configPublishedAt; + 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 Date getConfigFetchedAt() { + return configFetchedAt; + } + + public Date getConfigPublishedAt() { + return configPublishedAt; + } + + 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(); + } + + /** Creates a new Builder for constructing EvaluationDetails. */ + public static Builder builder() { + return new Builder(); + } + + /** + * 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 flagEvaluationCode, + String flagEvaluationDescription, + EppoValue variationValue) { + 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); + } + } +} 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..1509e806 --- /dev/null +++ b/src/main/java/cloud/eppo/api/FlagEvaluationCode.java @@ -0,0 +1,74 @@ +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), + + /** 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; + + 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/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 7c48a50f..e63aa1fe 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()) { @@ -55,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 @@ -81,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/BaseEppoClientBanditTest.java b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java index 84587ed5..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()); @@ -481,4 +481,215 @@ 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 bandit variation with null action + assertEquals("banner_bandit", assignmentDetails.getVariation()); + assertNull(assignmentDetails.getAction()); + + // Verify evaluation details + EvaluationDetails details = assignmentDetails.getEvaluationDetails(); + assertNotNull(details); + assertEquals( + FlagEvaluationCode.NO_ACTIONS_SUPPLIED_FOR_BANDIT, details.getFlagEvaluationCode()); + assertEquals( + "No actions supplied for bandit evaluation", details.getFlagEvaluationDescription()); + + // 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()); + 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()); + } } diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java index 4c805440..c51f17d2 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, @@ -654,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 c4c7c212..224d5fbb 100644 --- a/src/test/java/cloud/eppo/FlagEvaluatorTest.java +++ b/src/test/java/cloud/eppo/FlagEvaluatorTest.java @@ -4,8 +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; @@ -37,8 +41,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 + FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + FlagEvaluator.evaluateFlag( + flag, + "flag", + "subjectKey", + new Attributes(), + false, + testEnvironmentName, + testConfigFetchedAt, + testConfigPublishedAt); assertEquals(flag.getKey(), result.getFlagKey()); assertEquals("subjectKey", result.getSubjectKey()); @@ -46,6 +64,32 @@ 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()); + + // 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 @@ -53,7 +97,8 @@ public void testNoAllocations() { Map variations = createVariations("a"); FlagConfig flag = createFlag("flag", true, variations, null); FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + FlagEvaluator.evaluateFlag( + flag, "flag", "subjectKey", new Attributes(), false, "Test", null, null); assertEquals(flag.getKey(), result.getFlagKey()); assertEquals("subjectKey", result.getSubjectKey()); @@ -61,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 @@ -70,8 +130,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 + FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + FlagEvaluator.evaluateFlag( + flag, + "flag", + "subjectKey", + new Attributes(), + false, + testEnvironmentName, + testConfigFetchedAt, + testConfigPublishedAt); assertEquals(flag.getKey(), result.getFlagKey()); assertEquals("subjectKey", result.getSubjectKey()); @@ -79,6 +153,34 @@ 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()); + + // 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 @@ -98,15 +200,20 @@ public void testIDTargetingCondition() { // Check that subjectKey is evaluated as the "id" attribute FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "alice", new Attributes(), false); + FlagEvaluator.evaluateFlag( + flag, "flag", "alice", new Attributes(), false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlag(flag, "flag", "bob", new Attributes(), false); + result = + FlagEvaluator.evaluateFlag( + flag, "flag", "bob", new Attributes(), false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlag(flag, "flag", "charlie", new Attributes(), false); + result = + FlagEvaluator.evaluateFlag( + flag, "flag", "charlie", new Attributes(), false, null, null, null); assertNull(result.getVariation()); @@ -114,14 +221,18 @@ public void testIDTargetingCondition() { Attributes aliceAttributes = new Attributes(); aliceAttributes.put("id", "charlie"); - result = FlagEvaluator.evaluateFlag(flag, "flag", "alice", aliceAttributes, false); + result = + FlagEvaluator.evaluateFlag( + flag, "flag", "alice", aliceAttributes, false, null, null, null); assertNull(result.getVariation()); Attributes charlieAttributes = new Attributes(); charlieAttributes.put("id", "alice"); - result = FlagEvaluator.evaluateFlag(flag, "flag", "charlie", charlieAttributes, false); + result = + FlagEvaluator.evaluateFlag( + flag, "flag", "charlie", charlieAttributes, false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); } @@ -134,7 +245,8 @@ public void testCatchAllAllocation() { FlagConfig flag = createFlag("key", true, variations, allocations); FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + FlagEvaluator.evaluateFlag( + flag, "flag", "subjectKey", new Attributes(), false, null, null, null); assertEquals("default", result.getAllocationKey()); assertEquals("A", result.getVariation().getValue().stringValue()); @@ -153,19 +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"); FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", matchingEmailAttributes, false); + FlagEvaluator.evaluateFlag( + 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.evaluateFlag(flag, "flag", "subjectKey", unknownEmailAttributes, false); + result = + FlagEvaluator.evaluateFlag( + flag, "flag", "subjectKey", unknownEmailAttributes, false, "Test", null, null); assertEquals("A", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + // 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.evaluateFlag( + 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 @@ -189,15 +361,20 @@ public void testVariationShardRanges() { FlagConfig flag = createFlag("key", true, variations, allocations); FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subject4", new Attributes(), false); + FlagEvaluator.evaluateFlag( + flag, "flag", "subject4", new Attributes(), false, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlag(flag, "flag", "subject13", new Attributes(), false); + result = + FlagEvaluator.evaluateFlag( + flag, "flag", "subject13", new Attributes(), false, null, null, null); assertEquals("B", result.getVariation().getValue().stringValue()); - result = FlagEvaluator.evaluateFlag(flag, "flag", "subject14", new Attributes(), false); + result = + FlagEvaluator.evaluateFlag( + flag, "flag", "subject14", new Attributes(), false, null, null, null); assertEquals("C", result.getVariation().getValue().stringValue()); } @@ -220,28 +397,64 @@ public void testAllocationStartAndEndAt() { allocation.setEndAt(endAt); FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(flag, "flag", "subject", new Attributes(), false); + FlagEvaluator.evaluateFlag( + 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.evaluateFlag(flag, "flag", "subject", new Attributes(), false); + result = + FlagEvaluator.evaluateFlag( + 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.evaluateFlag(flag, "flag", "subject", new Attributes(), false); + result = + FlagEvaluator.evaluateFlag( + 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 @@ -334,7 +547,7 @@ public void testObfuscated() { encodedAllocations); FlagEvaluationResult result = FlagEvaluator.evaluateFlag( - obfuscatedFlag, "flag", "subjectKey", matchingEmailAttributes, true); + obfuscatedFlag, "flag", "subjectKey", matchingEmailAttributes, true, null, null, null); // Expect an unobfuscated evaluation result assertEquals("flag", result.getFlagKey()); @@ -348,11 +561,12 @@ public void testObfuscated() { unknownEmailAttributes.put("email", "eppo@test.com"); result = FlagEvaluator.evaluateFlag( - obfuscatedFlag, "flag", "subjectKey", unknownEmailAttributes, true); + obfuscatedFlag, "flag", "subjectKey", unknownEmailAttributes, true, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); result = - FlagEvaluator.evaluateFlag(obfuscatedFlag, "flag", "subjectKey", new Attributes(), true); + FlagEvaluator.evaluateFlag( + obfuscatedFlag, "flag", "subjectKey", new Attributes(), true, null, null, null); assertEquals("A", result.getVariation().getValue().stringValue()); } @@ -424,7 +638,8 @@ public void testObfuscatedExtraLogging() { // Test with obfuscated config FlagEvaluationResult result = - FlagEvaluator.evaluateFlag(obfuscatedFlag, "flag", "subject", new Attributes(), true); + FlagEvaluator.evaluateFlag( + obfuscatedFlag, "flag", "subject", new Attributes(), true, null, null, null); // Verify that extraLogging is deobfuscated Map extraLogging = result.getExtraLogging(); @@ -434,7 +649,9 @@ 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.evaluateFlag( + 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/api/EppoValueTest.java b/src/test/java/cloud/eppo/api/EppoValueTest.java index f2830ad2..791e624a 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 = boolValue.unwrap(VariationType.BOOLEAN); + assertEquals(Boolean.TRUE, result); + + EppoValue falseValue = EppoValue.valueOf(false); + Boolean falseResult = falseValue.unwrap(VariationType.BOOLEAN); + assertEquals(Boolean.FALSE, falseResult); + } + + @Test + public void testUnwrapInteger() { + EppoValue numValue = EppoValue.valueOf(42.0); + Integer result = numValue.unwrap(VariationType.INTEGER); + assertEquals(Integer.valueOf(42), result); + + EppoValue negativeValue = EppoValue.valueOf(-17.0); + Integer negativeResult = negativeValue.unwrap(VariationType.INTEGER); + assertEquals(Integer.valueOf(-17), negativeResult); + } + + @Test + public void testUnwrapNumeric() { + EppoValue numValue = EppoValue.valueOf(123.456); + Double result = numValue.unwrap(VariationType.NUMERIC); + assertEquals(Double.valueOf(123.456), result); + + EppoValue intValue = EppoValue.valueOf(100.0); + Double intResult = intValue.unwrap(VariationType.NUMERIC); + assertEquals(Double.valueOf(100.0), intResult); + } + + @Test + public void testUnwrapString() { + EppoValue strValue = EppoValue.valueOf("hello world"); + String result = strValue.unwrap(VariationType.STRING); + assertEquals("hello world", result); + + EppoValue emptyValue = EppoValue.valueOf(""); + String emptyResult = emptyValue.unwrap(VariationType.STRING); + assertEquals("", emptyResult); + } + + @Test + public void testUnwrapJsonValid() { + String jsonString = "{\"foo\":\"bar\",\"count\":42}"; + EppoValue jsonValue = EppoValue.valueOf(jsonString); + JsonNode result = jsonValue.unwrap(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 = jsonValue.unwrap(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 = jsonValue.unwrap(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 = jsonValue.unwrap(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 = jsonValue.unwrap(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 = jsonValue.unwrap(VariationType.JSON); + + assertNull(result, "Invalid JSON should return null"); + } + + @Test + public void testUnwrapJsonEmpty() { + String emptyJson = "{}"; + EppoValue jsonValue = EppoValue.valueOf(emptyJson); + JsonNode result = jsonValue.unwrap(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..69bebe78 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,191 @@ 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 + assertAllocationListsEqual( + expectedDetails.getUnmatchedAllocations(), + actualDetails.getUnmatchedAllocations(), + String.format( + "Unmatched allocations mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertAllocationListsEqual( + expectedDetails.getUnevaluatedAllocations(), + actualDetails.getUnevaluatedAllocations(), + String.format( + "Unevaluated allocations 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( + 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..145c0823 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,138 @@ 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, + null, // configFetchedAt - not available in test data + null, // configPublishedAt - not available in test data + 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; + } }