diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java index 50357b0..b5fd38a 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -665,6 +665,14 @@ private void trackLocalExposure(Map context, String flagKey, Str Object distinctIdObj = context.get("distinct_id"); if (distinctIdObj == null) { + // Local eval succeeds when the flag's Variant Assignment Key is + // something other than distinct_id (e.g., device_id), but the + // exposure event still needs distinct_id to attribute the user. + // Surface the drop instead of silently returning so callers can + // see they need to include distinct_id in the context. + logger.log(Level.WARNING, + "Cannot track exposure event for flag ''{0}'' without a distinct_id in the context", + flagKey); return; } diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java index 44bae7d..5882276 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -888,6 +888,58 @@ public void testDoNotTrackExposureWhenDistinctIdIsMissing() { assertEquals(0, eventSender.getEvents().size()); } + // SDK-84: when the flag is bucketed on a non-distinct_id key (e.g., + // device_id) and the context has that key but not distinct_id, local + // eval succeeds — but exposure still needs distinct_id to attribute the + // user. Surface the drop via a warning log instead of silently returning. + @Test + public void testWarnsWhenExposureDroppedDueToMissingDistinctId() throws Exception { + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + List rollouts = Arrays.asList(new Rollout(1.0f)); + // Flag is bucketed on device_id, not distinct_id. + String response = buildFlagsResponse("device-flag", "device_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + // Capture log records emitted from LocalFlagsProvider. + java.util.logging.Logger logger = java.util.logging.Logger.getLogger(LocalFlagsProvider.class.getName()); + java.util.List records = new java.util.ArrayList<>(); + java.util.logging.Handler handler = new java.util.logging.Handler() { + public void publish(java.util.logging.LogRecord record) { records.add(record); } + public void flush() {} + public void close() {} + }; + handler.setLevel(java.util.logging.Level.ALL); + java.util.logging.Level originalLevel = logger.getLevel(); + logger.addHandler(handler); + logger.setLevel(java.util.logging.Level.ALL); + + try { + Map context = new HashMap<>(); + context.put("device_id", "device-abc"); + + Object result = provider.getVariantValue("device-flag", fallbackVariantValue, context); + + // Eval still succeeds — caller gets the real variant value. + assertEquals("value-a", result); + // ...but exposure is dropped because distinct_id is missing. + assertEquals(0, eventSender.getEvents().size()); + // And we now log a warning so the drop isn't silent. Use a + // formatter so we can match against the fully-resolved + // template (the raw LogRecord.getMessage() still contains {0}). + java.util.logging.SimpleFormatter formatter = new java.util.logging.SimpleFormatter(); + boolean warned = records.stream() + .filter(r -> r.getLevel().intValue() >= java.util.logging.Level.WARNING.intValue()) + .map(formatter::formatMessage) + .anyMatch(m -> m.contains("device-flag") && m.contains("distinct_id")); + assertTrue("expected a warning about the dropped exposure event", warned); + } finally { + logger.removeHandler(handler); + logger.setLevel(originalLevel); + } + } + // #endregion // #region Readiness Tests