From b3e331a3fa238555aace2816b8a5c1a21f7ead29 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 29 Jun 2026 13:21:09 -0400 Subject: [PATCH 1/2] fix(analytics): surface dropped exposure when distinct_id missing from context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a flag uses a non-default Variant Assignment Key (e.g. device_id), local evaluation succeeds via that key independently of whether distinct_id is present. Exposure tracking, however, requires distinct_id to attribute the event to the user. Previously the SDK silently returned from track_exposure_event with no signal to the caller — the flag value came back correctly but analytics were silently dropped. Surface the drop through the configured error_handler so callers can see they need to include distinct_id in the context alongside the bucketing key. Behavior when distinct_id IS present is unchanged. Linear: SDK-84 Co-Authored-By: Claude Opus 4.7 --- .../provider/LocalFlagsProvider.java | 7 +++ .../provider/LocalFlagsProviderTest.java | 46 +++++++++++++++++++ 2 files changed, 53 insertions(+) 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..4f32663 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,13 @@ 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 '" + flagKey + "' without a distinct_id in the context"); 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..344c42a 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,52 @@ 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); + 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. + boolean warned = records.stream() + .filter(r -> r.getLevel().intValue() >= java.util.logging.Level.WARNING.intValue()) + .anyMatch(r -> r.getMessage().contains("device-flag") && r.getMessage().contains("distinct_id")); + assertTrue("expected a warning about the dropped exposure event", warned); + } finally { + logger.removeHandler(handler); + } + } + // #endregion // #region Readiness Tests From 8a8ae1f7bbec80bae7c299ee2101887ca99eeaf6 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Thu, 2 Jul 2026 12:26:29 -0400 Subject: [PATCH 2/2] fix(flags): parameterize JUL warning + restore logger level in test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the dropped-exposure warning from string concatenation to a parameterized template so the flag key is only formatted when the WARNING level is actually enabled. The corresponding test set the LocalFlagsProvider logger level to Level.ALL outside the try block and never restored it in finally, leaking that level to any test that ran afterward in the same JVM. Capture the original level and restore it. Also switch the log assertion to format the LogRecord via SimpleFormatter — with parameterized logging LogRecord.getMessage() returns the raw "{0}" template, not the interpolated string. --- .../featureflags/provider/LocalFlagsProvider.java | 3 ++- .../featureflags/provider/LocalFlagsProviderTest.java | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) 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 4f32663..b5fd38a 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -671,7 +671,8 @@ private void trackLocalExposure(Map context, String flagKey, Str // 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 '" + flagKey + "' without a distinct_id in the context"); + "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 344c42a..5882276 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -911,6 +911,7 @@ 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); @@ -924,13 +925,18 @@ public void close() {} 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. + // 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()) - .anyMatch(r -> r.getMessage().contains("device-flag") && r.getMessage().contains("distinct_id")); + .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); } }