diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 08533a6..9ce51d4 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -24,6 +24,7 @@ public class BaseEppoClient { private static final Logger log = LoggerFactory.getLogger(BaseEppoClient.class); + protected final ConfigurationRequestor requestor; private final IConfigurationStore configurationStore; private final AssignmentLogger assignmentLogger; @@ -592,68 +593,151 @@ 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); + 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/test/java/cloud/eppo/BaseEppoClientBanditTest.java b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java index 84587ed..a45a3ae 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java @@ -131,7 +131,6 @@ public static Stream getBanditTestData() { return BanditTestCase.getBanditTestData(); } - @SuppressWarnings("ExtractMethodRecommender") @Test public void testBanditLogsAction() { String flagKey = "banner_bandit_flag"; @@ -228,13 +227,13 @@ public void testBanditLogCached() { ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); assertEquals("training", assignmentLogCaptor.getValue().getAllocation()); - assertEquals(assignmentLogCaptor.getValue().getVariation(), "banner_bandit"); + assertEquals("banner_bandit", assignmentLogCaptor.getValue().getVariation()); ArgumentCaptor banditLogCaptor = ArgumentCaptor.forClass(BanditAssignment.class); verify(mockBanditLogger, times(1)).logBanditAssignment(banditLogCaptor.capture()); - assertEquals(banditLogCaptor.getValue().getBandit(), "banner_bandit"); - assertEquals(banditLogCaptor.getValue().getAction(), "adidas"); + assertEquals("banner_bandit", banditLogCaptor.getValue().getBandit()); + assertEquals("adidas", banditLogCaptor.getValue().getAction()); BanditResult duplicateBanditResult = eppoClient.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "control"); @@ -291,8 +290,8 @@ public void testBanditLogCacheExpires() throws InterruptedException { ArgumentCaptor banditLogCaptor = ArgumentCaptor.forClass(BanditAssignment.class); verify(mockBanditLogger, times(1)).logBanditAssignment(banditLogCaptor.capture()); - assertEquals(banditLogCaptor.getValue().getBandit(), "banner_bandit"); - assertEquals(banditLogCaptor.getValue().getAction(), "adidas"); + assertEquals("banner_bandit", banditLogCaptor.getValue().getBandit()); + assertEquals("adidas", banditLogCaptor.getValue().getAction()); // 2. Get the bandit action again right away to ensure it was cached eppoClient.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "control"); @@ -373,11 +372,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 +480,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()); + } }