From ab4feac9fbf1a4588cd68cb68a6dc9ed8f40211f Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 4 Aug 2026 16:36:56 -0700 Subject: [PATCH 01/29] feat: add configurable flag exposure deduplication Apps that evaluate a flag on every render or inside a loop report an exposure for each call, even though the evaluation resolves to the same result every time. This produces a high volume of redundant events with no added analytical value. Adds two LDConfig.Builder options, both leaving existing behavior unchanged by default: - flagExposureDedupeWindowMillis (default 0, which disables dedupe) - flagExposureDedupeMaxSize (default 2000) With a window configured, an exposure is recorded at most once per window per unique result, keyed on flag key, variation, flag version, and the fully qualified context key. Suppression covers the full feature event and the summary event together, so evaluation counts reported to LaunchDarkly drop along with the event volume. identify resets the cache even when the context is unchanged, so that identify stays a reliable way for an app to mark a new phase of a session. The options live on the top-level builder rather than the events subcomponent to keep the configuration surface aligned with the iOS SDK. Co-authored-by: Cursor --- .../sdk/android/LDClientEventTest.java | 92 ++++++++++++++ .../sdk/android/ExposureDeduper.java | 96 +++++++++++++++ .../launchdarkly/sdk/android/LDClient.java | 63 +++++++--- .../launchdarkly/sdk/android/LDConfig.java | 84 +++++++++++++ .../sdk/android/ExposureDeduperTest.java | 113 ++++++++++++++++++ .../sdk/android/LDConfigTest.java | 16 +++ 6 files changed, 450 insertions(+), 14 deletions(-) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ExposureDeduper.java create mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ExposureDeduperTest.java diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java index 5dea097e..7f0c004d 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java @@ -239,6 +239,98 @@ public void flagEvaluationWithPrereqProducesPrereqEvents() throws IOException, I } } + @Test + public void repeatedEvaluationsAreReportedWhenDedupeIsDisabledByDefault() throws IOException, InterruptedException { + try (MockWebServer mockEventsServer = new MockWebServer()) { + mockEventsServer.start(); + mockEventsServer.enqueue(new MockResponse()); + + Flag flag = new FlagBuilder("flagA").version(1) + .variation(1).value(LDValue.of(true)).trackEvents(true).build(); + PersistentDataStore store = new InMemoryPersistentDataStore(); + TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flag); + LDConfig ldConfig = baseConfigBuilder(mockEventsServer) + .persistentDataStore(store).build(); + + try (LDClient client = LDClient.init(application, ldConfig, ldContext, 0)) { + for (int i = 0; i < 3; i++) { + assertTrue(client.boolVariation("flagA", false)); + } + client.blockingFlush(); + + // identify, three feature events, then the summary. + LDValue[] events = getEventsFromLastRequest(mockEventsServer, 5); + assertSummaryEvent(events[4]); + assertEquals(LDValue.of(3), events[4].get("features").get("flagA").get("counters").get(0).get("count")); + } + } + } + + @Test + public void repeatedEvaluationsAreDeduplicatedWithinTheConfiguredWindow() throws IOException, InterruptedException { + try (MockWebServer mockEventsServer = new MockWebServer()) { + mockEventsServer.start(); + mockEventsServer.enqueue(new MockResponse()); + + Flag flag = new FlagBuilder("flagA").version(1) + .variation(1).value(LDValue.of(true)).trackEvents(true).build(); + PersistentDataStore store = new InMemoryPersistentDataStore(); + TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flag); + LDConfig ldConfig = baseConfigBuilder(mockEventsServer) + .persistentDataStore(store) + .flagExposureDedupeWindowMillis(60_000) + .build(); + + try (LDClient client = LDClient.init(application, ldConfig, ldContext, 0)) { + for (int i = 0; i < 3; i++) { + assertTrue(client.boolVariation("flagA", false)); + } + client.blockingFlush(); + + // The repeats are suppressed, so only one feature event and one summary count remain. + LDValue[] events = getEventsFromLastRequest(mockEventsServer, 3); + assertFeatureEvent(events[1], ldContext); + assertSummaryEvent(events[2]); + assertEquals(LDValue.of(1), events[2].get("features").get("flagA").get("counters").get(0).get("count")); + } + } + } + + @Test + public void identifyResetsFlagExposureDedupeCache() throws IOException, InterruptedException { + try (MockWebServer mockEventsServer = new MockWebServer()) { + mockEventsServer.start(); + mockEventsServer.enqueue(new MockResponse()); + + Flag flag = new FlagBuilder("flagA").version(1) + .variation(1).value(LDValue.of(true)).build(); + PersistentDataStore store = new InMemoryPersistentDataStore(); + TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flag); + LDConfig ldConfig = baseConfigBuilder(mockEventsServer) + .persistentDataStore(store) + .flagExposureDedupeWindowMillis(60_000) + .build(); + + try (LDClient client = LDClient.init(application, ldConfig, ldContext, 0)) { + assertTrue(client.boolVariation("flagA", false)); + assertTrue(client.boolVariation("flagA", false)); + + // Identifying to the unchanged context still clears the cache, so the evaluation + // after it is reported rather than suppressed. + client.identify(ldContext).get(); + assertTrue(client.boolVariation("flagA", false)); + client.blockingFlush(); + + LDValue[] events = getEventsFromLastRequest(mockEventsServer, 3); + LDValue summaryEvent = events[2]; + assertSummaryEvent(summaryEvent); + assertEquals(LDValue.of(2), summaryEvent.get("features").get("flagA").get("counters").get(0).get("count")); + } + } catch (java.util.concurrent.ExecutionException e) { + fail("identify failed: " + e); + } + } + // Cycle-detection tests exercise CSPE 1.2.5, 1.2.5.1, and 1.2.5.2. Prior to the cycle guard, // any of these configurations would cause a StackOverflowError on the first variation() call. // The tests set up a cyclic prerequisite graph via the persistent store, evaluate one flag on diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ExposureDeduper.java new file mode 100644 index 00000000..872da027 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ExposureDeduper.java @@ -0,0 +1,96 @@ +package com.launchdarkly.sdk.android; + +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Tracks recently recorded feature flag exposures so that repeated evaluations resolving to the same + * result do not report a new evaluation event within a configured time window. + *

+ * Each unique exposure key is only recorded once per window. The number of tracked keys is bounded; + * when the cap is exceeded the least recently recorded keys are evicted. + *

+ * This class is thread-safe. Evaluations may be made from any thread, so the check of the window and + * the update of it are performed together under a single lock. + */ +final class ExposureDeduper { + private final long windowMillis; + private final int maxSize; + + // Insertion-ordered so that iteration visits the least recently recorded key first. Guarded by + // the instance lock, as is every access below. + private final LinkedHashMap lastRecordedAt = new LinkedHashMap<>(); + + /** + * @param windowMillis the dedupe window in milliseconds; zero or negative disables deduplication, + * so every exposure is recorded + * @param maxSize the maximum number of exposure keys to track; zero or negative falls back to + * {@link LDConfig#DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE} + */ + ExposureDeduper(int windowMillis, int maxSize) { + this.windowMillis = windowMillis; + this.maxSize = maxSize > 0 ? maxSize : LDConfig.DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE; + } + + boolean isEnabled() { + return windowMillis > 0; + } + + /** + * Returns whether an exposure for the given key should be recorded, and if so starts a new dedupe + * window for it. + * + * @param key a stable key identifying the evaluation result + * @param nowMillis the current time in milliseconds since the epoch + * @return true if the exposure should be recorded, false if it should be suppressed + */ + synchronized boolean shouldRecord(String key, long nowMillis) { + if (!isEnabled()) { + return true; + } + + Long last = lastRecordedAt.get(key); + if (last != null && last > nowMillis - windowMillis) { + return false; + } + + // Remove before putting so the key moves to the most recent end of the iteration order. + lastRecordedAt.remove(key); + lastRecordedAt.put(key, nowMillis); + + if (lastRecordedAt.size() > maxSize) { + evict(nowMillis); + } + return true; + } + + /** + * Clears all recorded exposures. Called when the evaluation context changes. + */ + synchronized void reset() { + lastRecordedAt.clear(); + } + + private void evict(long nowMillis) { + // Keys whose window has already elapsed no longer change the outcome of shouldRecord, so + // reclaim those first. They sort before any live key, so this stops at the first live one. + long cutoff = nowMillis - windowMillis; + for (Iterator> it = lastRecordedAt.entrySet().iterator(); it.hasNext(); ) { + if (it.next().getValue() > cutoff) { + break; + } + it.remove(); + } + + // Evict a batch rather than a single key, so that a workload tracking more live keys than + // maxSize doesn't pay for an eviction on every subsequent exposure. + int dropCount = lastRecordedAt.size() - maxSize + maxSize / 4; + for (Iterator> it = lastRecordedAt.entrySet().iterator(); + dropCount > 0 && it.hasNext(); + dropCount--) { + it.next(); + it.remove(); + } + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index c74e75cb..7bbf4968 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -73,6 +73,7 @@ public class LDClient implements LDClientInterface, Closeable { private final ConnectivityManager connectivityManager; private final LDLogger logger; private final HookRunner hookRunner; + private final ExposureDeduper exposureDeduper; private List plugins; // If 15 seconds or more is passed as a timeout to init, we will log a warning. private static final int EXCESSIVE_INIT_WAIT_SECONDS = 15; @@ -440,6 +441,11 @@ protected LDClient( ); hookRunner = new HookRunner(logger, config.hooks.getHooks()); + + exposureDeduper = new ExposureDeduper( + config.getFlagExposureDedupeWindowMillis(), + config.getFlagExposureDedupeMaxSize() + ); } @Override @@ -499,6 +505,11 @@ private void identifyInternal(@NonNull LDContext context, clientContextImpl = clientContextImpl.setEvaluationContext(context); + // Exposures recorded before this point describe an earlier point in the app's lifecycle, so + // let them be reported again. This happens even when the context is unchanged, so that + // identify is a reliable way for an app to mark a new phase of a session. + exposureDeduper.reset(); + // Load cached flags for the new context so they're available in case initialization // times out or otherwise fails. This does not short-circuit initialization — the data // source still performs its network request regardless. @@ -688,9 +699,11 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ if (flag == null) { logger.info("Unknown feature flag \"{}\"; returning default value", key); - eventProcessor.recordEvaluationEvent(context, key, - EventProcessor.NO_VERSION, EvaluationDetail.NO_VARIATION, defaultValue, - null, defaultValue, false, null); + if (shouldRecordExposure(context, key, EvaluationDetail.NO_VARIATION, EventProcessor.NO_VERSION)) { + eventProcessor.recordEvaluationEvent(context, key, + EventProcessor.NO_VERSION, EvaluationDetail.NO_VARIATION, defaultValue, + null, defaultValue, false, null); + } result = EvaluationDetail.fromValue(defaultValue, EvaluationDetail.NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); } else { if (flag.getPrerequisites() != null) { @@ -731,23 +744,45 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ } else { result = EvaluationDetail.fromValue(value, variation, flag.getReason()); } - eventProcessor.recordEvaluationEvent( - context, - key, - flag.getVersionForEvents(), - flag.getVariation() == null ? -1 : flag.getVariation().intValue(), - value, - flag.isTrackReason() | needsReason ? result.getReason() : null, - defaultValue, - flag.isTrackEvents(), - flag.getDebugEventsUntilDate() - ); + if (shouldRecordExposure(context, key, variation, flag.getVersionForEvents())) { + eventProcessor.recordEvaluationEvent( + context, + key, + flag.getVersionForEvents(), + flag.getVariation() == null ? -1 : flag.getVariation().intValue(), + value, + flag.isTrackReason() | needsReason ? result.getReason() : null, + defaultValue, + flag.isTrackEvents(), + flag.getDebugEventsUntilDate() + ); + } } logger.debug("returning variation: {} flagKey: {} context key: {}", result, key, context.getKey()); return result; } + /** + * Returns whether this evaluation should be reported to LaunchDarkly, and if so starts a new + * dedupe window for it. + *

+ * The variation and version pair is the same identity LaunchDarkly uses to bucket evaluations in + * summary events, so two evaluations sharing that pair report identical data. Because the + * evaluation reason is carried on the versioned flag payload, a change in reason implies a change + * in version and so is covered without being part of the key. + */ + private boolean shouldRecordExposure(LDContext context, String flagKey, int variation, int flagVersion) { + if (!exposureDeduper.isEnabled()) { + // Building the key allocates, so it is skipped entirely while deduplication is off, which + // is the default. + return true; + } + String dedupeKey = flagKey + '\n' + variation + '\n' + flagVersion + '\n' + + context.getFullyQualifiedKey(); + return exposureDeduper.shouldRecord(dedupeKey, System.currentTimeMillis()); + } + /** * Closes the client. This should only be called at the end of a client's lifecycle. * diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java index 9056f9dc..d562964a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java @@ -62,6 +62,17 @@ public class LDConfig { static final String primaryEnvironmentName = "default"; + /** + * The default value for {@link LDConfig.Builder#flagExposureDedupeWindowMillis(int)}: 0, meaning that + * deduplication is disabled. + */ + public static final int DEFAULT_FLAG_EXPOSURE_DEDUPE_WINDOW_MILLIS = 0; + + /** + * The default value for {@link LDConfig.Builder#flagExposureDedupeMaxSize(int)}: 2000. + */ + public static final int DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE = 2_000; + static final int DEFAULT_MAX_CACHED_CONTEXTS = 5; static final int DEFAULT_CONNECTION_TIMEOUT_MILLIS = 10_000; // 10 seconds @@ -87,6 +98,8 @@ public class LDConfig { private final String loggerName; private final int maxCachedContexts; private final boolean offline; + private final int flagExposureDedupeWindowMillis; + private final int flagExposureDedupeMaxSize; private final long connectionModeStateDebounceMs; private final PersistentDataStore persistentDataStore; // configurable for testing only @@ -106,6 +119,8 @@ public class LDConfig { int maxCachedContexts, boolean generateAnonymousKeys, boolean autoEnvAttributes, + int flagExposureDedupeWindowMillis, + int flagExposureDedupeMaxSize, long connectionModeStateDebounceMs, PersistentDataStore persistentDataStore, LDLogAdapter logAdapter, @@ -126,6 +141,8 @@ public class LDConfig { this.maxCachedContexts = maxCachedContexts; this.generateAnonymousKeys = generateAnonymousKeys; this.autoEnvAttributes = autoEnvAttributes; + this.flagExposureDedupeWindowMillis = flagExposureDedupeWindowMillis; + this.flagExposureDedupeMaxSize = flagExposureDedupeMaxSize; this.connectionModeStateDebounceMs = connectionModeStateDebounceMs; this.persistentDataStore = persistentDataStore; this.logAdapter = logAdapter; @@ -195,6 +212,20 @@ int getMaxCachedContexts() { return maxCachedContexts; } + /** + * @return the feature flag exposure deduplication window in milliseconds, or 0 if deduplication is disabled + */ + public int getFlagExposureDedupeWindowMillis() { + return flagExposureDedupeWindowMillis; + } + + /** + * @return the maximum number of feature flag exposure keys tracked for deduplication at once + */ + public int getFlagExposureDedupeMaxSize() { + return flagExposureDedupeMaxSize; + } + /** * @return true if keys should be generated for anonymous contexts, false otherwise */ @@ -264,6 +295,9 @@ public enum AutoEnvAttributes { private int maxCachedContexts = DEFAULT_MAX_CACHED_CONTEXTS; + private int flagExposureDedupeWindowMillis = DEFAULT_FLAG_EXPOSURE_DEDUPE_WINDOW_MILLIS; + private int flagExposureDedupeMaxSize = DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE; + private boolean offline = false; private boolean disableBackgroundUpdating = false; private boolean diagnosticOptOut = false; @@ -629,6 +663,54 @@ public Builder maxCachedContexts(int maxCachedContexts) { return this; } + /** + * Sets the time window, in milliseconds, during which repeated feature flag evaluations that + * resolve to the same result are deduplicated. + *

+ * Within the window, only a single evaluation is reported per unique combination of flag key, + * variation, flag version, and evaluation context. This is useful for reducing analytics event + * volume caused by frequent re-evaluations, for example a flag that is read on every redraw of + * a view. + *

+ * Deduplicated evaluations are omitted from both the full feature events used by + * experimentation and the debugger, and the summary events that drive flag evaluation counts. + * Enabling this therefore reduces the evaluation counts LaunchDarkly reports for your flags. + *

+ * The cache of recorded exposures is cleared by {@link LDClient#identify(LDContext)}, so the + * first evaluation after an identify is always reported. + *

+ * If not specified, the default is {@link #DEFAULT_FLAG_EXPOSURE_DEDUPE_WINDOW_MILLIS} (0), + * which disables deduplication so that every evaluation is reported. + * + * @param flagExposureDedupeWindowMillis the dedupe window in milliseconds; zero or negative + * disables deduplication + * @return the builder + * @see #flagExposureDedupeMaxSize(int) + */ + public Builder flagExposureDedupeWindowMillis(int flagExposureDedupeWindowMillis) { + this.flagExposureDedupeWindowMillis = flagExposureDedupeWindowMillis; + return this; + } + + /** + * Sets the maximum number of unique feature flag exposure keys tracked for deduplication at + * once. + *

+ * When the limit is exceeded, the least recently recorded keys are evicted to bound memory + * usage. This only matters when {@link #flagExposureDedupeWindowMillis(int)} is enabled. + *

+ * If not specified, the default is {@link #DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE} (2000). + * + * @param flagExposureDedupeMaxSize the maximum number of keys to track; zero or negative + * values are ignored and the default is used instead + * @return the builder + * @see #flagExposureDedupeWindowMillis(int) + */ + public Builder flagExposureDedupeMaxSize(int flagExposureDedupeMaxSize) { + this.flagExposureDedupeMaxSize = flagExposureDedupeMaxSize; + return this; + } + /** * Set to {@code true} to make the SDK provide unique keys for anonymous contexts. *

@@ -839,6 +921,8 @@ public LDConfig build() { maxCachedContexts, generateAnonymousKeys, autoEnvAttributes, + flagExposureDedupeWindowMillis, + flagExposureDedupeMaxSize, connectionModeStateDebounceMs, persistentDataStore, actualLogAdapter, diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ExposureDeduperTest.java new file mode 100644 index 00000000..d037dac6 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ExposureDeduperTest.java @@ -0,0 +1,113 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class ExposureDeduperTest { + @Test + public void disabledForNonPositiveWindow() { + for (int window : new int[] { 0, -1 }) { + ExposureDeduper deduper = new ExposureDeduper(window, 10); + assertFalse(deduper.isEnabled()); + assertTrue(deduper.shouldRecord("a", 0)); + assertTrue(deduper.shouldRecord("a", 0)); + } + } + + @Test + public void suppressesRepeatsWithinWindow() { + ExposureDeduper deduper = new ExposureDeduper(100, 10); + assertTrue(deduper.shouldRecord("a", 1000)); + assertFalse(deduper.shouldRecord("a", 1000)); + assertFalse(deduper.shouldRecord("a", 1099)); + } + + @Test + public void recordsAgainOnceWindowElapses() { + ExposureDeduper deduper = new ExposureDeduper(100, 10); + assertTrue(deduper.shouldRecord("a", 1000)); + assertTrue(deduper.shouldRecord("a", 1100)); + // Recording restarts the window rather than extending the original one. + assertFalse(deduper.shouldRecord("a", 1150)); + assertTrue(deduper.shouldRecord("a", 1200)); + } + + @Test + public void tracksKeysIndependently() { + ExposureDeduper deduper = new ExposureDeduper(100, 10); + assertTrue(deduper.shouldRecord("a", 1000)); + assertTrue(deduper.shouldRecord("b", 1000)); + assertFalse(deduper.shouldRecord("a", 1000)); + assertFalse(deduper.shouldRecord("b", 1000)); + } + + @Test + public void recordsAgainAfterReset() { + ExposureDeduper deduper = new ExposureDeduper(100, 10); + assertTrue(deduper.shouldRecord("a", 1000)); + deduper.reset(); + assertTrue(deduper.shouldRecord("a", 1000)); + } + + @Test + public void evictsLeastRecentlyRecordedKeysPastCap() { + ExposureDeduper deduper = new ExposureDeduper(10_000, 4); + for (int i = 0; i < 5; i++) { + assertTrue(deduper.shouldRecord("key-" + i, 1000 + i)); + } + // "key-0" was recorded first, so it is the one dropped and can be recorded again, while the + // most recently recorded key is still being tracked. + assertTrue(deduper.shouldRecord("key-0", 1010)); + assertFalse(deduper.shouldRecord("key-4", 1010)); + } + + @Test + public void reRecordingMovesKeyToMostRecentEndOfEvictionOrder() { + ExposureDeduper deduper = new ExposureDeduper(100, 2); + assertTrue(deduper.shouldRecord("a", 1000)); + assertTrue(deduper.shouldRecord("b", 1000)); + // "a" is re-recorded once its window elapses, which makes "b" the oldest tracked key. + assertTrue(deduper.shouldRecord("a", 1100)); + assertTrue(deduper.shouldRecord("c", 1100)); + assertFalse(deduper.shouldRecord("a", 1100)); + } + + @Test + public void fallsBackToDefaultCapForNonPositiveMaxSize() { + ExposureDeduper deduper = new ExposureDeduper(10_000, 0); + for (int i = 0; i < LDConfig.DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE; i++) { + assertTrue(deduper.shouldRecord("key-" + i, 1000)); + } + assertFalse(deduper.shouldRecord("key-0", 1000)); + } + + @Test + public void recordsOnceWhenSameKeyIsCheckedConcurrently() throws Exception { + ExposureDeduper deduper = new ExposureDeduper(60_000, 100); + int threadCount = 10; + Thread[] threads = new Thread[threadCount]; + boolean[] recorded = new boolean[threadCount]; + + for (int i = 0; i < threadCount; i++) { + final int index = i; + threads[i] = new Thread(() -> recorded[index] = deduper.shouldRecord("a", 1000)); + } + for (Thread thread : threads) { + thread.start(); + } + for (Thread thread : threads) { + thread.join(); + } + + int recordedCount = 0; + for (boolean value : recorded) { + if (value) { + recordedCount++; + } + } + assertEquals(1, recordedCount); + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java index 5db26461..df9adaff 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java @@ -40,6 +40,22 @@ public void testBuilderDefaults() { assertFalse(config.getDiagnosticOptOut()); assertEquals(0, config.hooks.getHooks().size()); + + assertEquals(LDConfig.DEFAULT_FLAG_EXPOSURE_DEDUPE_WINDOW_MILLIS, + config.getFlagExposureDedupeWindowMillis()); + assertEquals(LDConfig.DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE, + config.getFlagExposureDedupeMaxSize()); + } + + @Test + public void testBuilderFlagExposureDedupe() { + LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled) + .flagExposureDedupeWindowMillis(5_000) + .flagExposureDedupeMaxSize(50) + .build(); + + assertEquals(5_000, config.getFlagExposureDedupeWindowMillis()); + assertEquals(50, config.getFlagExposureDedupeMaxSize()); } @Test From 13713b0203132a023e69ad92dcb70f2c8e573541 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 4 Aug 2026 17:34:47 -0700 Subject: [PATCH 02/29] fix: do not evict live dedupe keys when reclaiming expired ones suffices evict applied the batch drop unconditionally, even after reclaiming expired keys had already brought the map back within maxSize. Because dropCount is size - maxSize + maxSize / 4, it stayed positive whenever size was above roughly three quarters of maxSize, so keys still inside their window were discarded and the next identical evaluation was reported instead of suppressed. Return early once the map is within the cap, matching the guard the iOS implementation already had. The existing eviction tests missed this because they use a maxSize of 2 and 4, where integer division makes the maxSize / 4 term zero and the over-eager drop disappears. The regression test uses 8. Co-authored-by: Cursor --- .../sdk/android/ExposureDeduper.java | 6 ++++++ .../sdk/android/ExposureDeduperTest.java | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ExposureDeduper.java index 872da027..056a05df 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ExposureDeduper.java @@ -83,6 +83,12 @@ private void evict(long nowMillis) { it.remove(); } + if (lastRecordedAt.size() <= maxSize) { + // Reclaiming expired keys was enough. Dropping live keys past this point would report + // their next identical evaluation again. + return; + } + // Evict a batch rather than a single key, so that a workload tracking more live keys than // maxSize doesn't pay for an eviction on every subsequent exposure. int dropCount = lastRecordedAt.size() - maxSize + maxSize / 4; diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ExposureDeduperTest.java index d037dac6..684a2945 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ExposureDeduperTest.java @@ -75,6 +75,25 @@ public void reRecordingMovesKeyToMostRecentEndOfEvictionOrder() { assertFalse(deduper.shouldRecord("a", 1100)); } + @Test + public void keepsLiveKeysWhenReclaimingExpiredOnesIsEnough() { + // maxSize is 8 so that the batch term (maxSize / 4) is non-zero, which is what makes an + // over-eager batch drop observable. + ExposureDeduper deduper = new ExposureDeduper(100, 8); + for (int i = 0; i < 2; i++) { + assertTrue(deduper.shouldRecord("expired-" + i, 1000)); + } + // The 7th of these exceeds the cap and triggers eviction. Reclaiming the two keys whose + // window has elapsed brings the map back within the cap on its own, so every one of these + // keys is still tracked and none of them should be reported again. + for (int i = 0; i < 7; i++) { + assertTrue(deduper.shouldRecord("live-" + i, 1150)); + } + for (int i = 0; i < 7; i++) { + assertFalse(deduper.shouldRecord("live-" + i, 1150)); + } + } + @Test public void fallsBackToDefaultCapForNonPositiveMaxSize() { ExposureDeduper deduper = new ExposureDeduper(10_000, 0); From 011b44d7d8d71ac9460b469e3a1493a400ad3f0e Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 4 Aug 2026 18:00:07 -0700 Subject: [PATCH 03/29] refactor: name the dedupe options after evaluation exposures "Flag" carries no information in a flag SDK, where every value being deduplicated is a flag, and the SDK already calls the thing being recorded an evaluation: recordEvaluationEvent, EvaluationDetail, evaluation events. Renames the builder options to evaluationExposureDedupeWindowMillis and evaluationExposureDedupeMaxSize with matching getters and DEFAULT_EVALUATION_EXPOSURE_* constants, and ExposureDeduper to EvaluationExposureDeduper along with its file and test. Prose that says "feature flag" is left alone, since that is the established wording throughout these doc comments. Co-authored-by: Cursor --- .../sdk/android/LDClientEventTest.java | 6 +- ...er.java => EvaluationExposureDeduper.java} | 10 +-- .../launchdarkly/sdk/android/LDClient.java | 14 ++-- .../launchdarkly/sdk/android/LDConfig.java | 64 +++++++++---------- ...ava => EvaluationExposureDeduperTest.java} | 24 +++---- .../sdk/android/LDConfigTest.java | 18 +++--- 6 files changed, 68 insertions(+), 68 deletions(-) rename launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/{ExposureDeduper.java => EvaluationExposureDeduper.java} (91%) rename launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/{ExposureDeduperTest.java => EvaluationExposureDeduperTest.java} (81%) diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java index 7f0c004d..e2a02076 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java @@ -278,7 +278,7 @@ public void repeatedEvaluationsAreDeduplicatedWithinTheConfiguredWindow() throws TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flag); LDConfig ldConfig = baseConfigBuilder(mockEventsServer) .persistentDataStore(store) - .flagExposureDedupeWindowMillis(60_000) + .evaluationExposureDedupeWindowMillis(60_000) .build(); try (LDClient client = LDClient.init(application, ldConfig, ldContext, 0)) { @@ -297,7 +297,7 @@ public void repeatedEvaluationsAreDeduplicatedWithinTheConfiguredWindow() throws } @Test - public void identifyResetsFlagExposureDedupeCache() throws IOException, InterruptedException { + public void identifyResetsEvaluationExposureDedupeCache() throws IOException, InterruptedException { try (MockWebServer mockEventsServer = new MockWebServer()) { mockEventsServer.start(); mockEventsServer.enqueue(new MockResponse()); @@ -308,7 +308,7 @@ public void identifyResetsFlagExposureDedupeCache() throws IOException, Interrup TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flag); LDConfig ldConfig = baseConfigBuilder(mockEventsServer) .persistentDataStore(store) - .flagExposureDedupeWindowMillis(60_000) + .evaluationExposureDedupeWindowMillis(60_000) .build(); try (LDClient client = LDClient.init(application, ldConfig, ldContext, 0)) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureDeduper.java similarity index 91% rename from launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ExposureDeduper.java rename to launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureDeduper.java index 056a05df..8ddbeadc 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureDeduper.java @@ -5,7 +5,7 @@ import java.util.Map; /** - * Tracks recently recorded feature flag exposures so that repeated evaluations resolving to the same + * Tracks recently recorded evaluation exposures so that repeated evaluations resolving to the same * result do not report a new evaluation event within a configured time window. *

* Each unique exposure key is only recorded once per window. The number of tracked keys is bounded; @@ -14,7 +14,7 @@ * This class is thread-safe. Evaluations may be made from any thread, so the check of the window and * the update of it are performed together under a single lock. */ -final class ExposureDeduper { +final class EvaluationExposureDeduper { private final long windowMillis; private final int maxSize; @@ -26,11 +26,11 @@ final class ExposureDeduper { * @param windowMillis the dedupe window in milliseconds; zero or negative disables deduplication, * so every exposure is recorded * @param maxSize the maximum number of exposure keys to track; zero or negative falls back to - * {@link LDConfig#DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE} + * {@link LDConfig#DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE} */ - ExposureDeduper(int windowMillis, int maxSize) { + EvaluationExposureDeduper(int windowMillis, int maxSize) { this.windowMillis = windowMillis; - this.maxSize = maxSize > 0 ? maxSize : LDConfig.DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE; + this.maxSize = maxSize > 0 ? maxSize : LDConfig.DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE; } boolean isEnabled() { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index 7bbf4968..b5020ae2 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -73,7 +73,7 @@ public class LDClient implements LDClientInterface, Closeable { private final ConnectivityManager connectivityManager; private final LDLogger logger; private final HookRunner hookRunner; - private final ExposureDeduper exposureDeduper; + private final EvaluationExposureDeduper evaluationExposureDeduper; private List plugins; // If 15 seconds or more is passed as a timeout to init, we will log a warning. private static final int EXCESSIVE_INIT_WAIT_SECONDS = 15; @@ -442,9 +442,9 @@ protected LDClient( hookRunner = new HookRunner(logger, config.hooks.getHooks()); - exposureDeduper = new ExposureDeduper( - config.getFlagExposureDedupeWindowMillis(), - config.getFlagExposureDedupeMaxSize() + evaluationExposureDeduper = new EvaluationExposureDeduper( + config.getEvaluationExposureDedupeWindowMillis(), + config.getEvaluationExposureDedupeMaxSize() ); } @@ -508,7 +508,7 @@ private void identifyInternal(@NonNull LDContext context, // Exposures recorded before this point describe an earlier point in the app's lifecycle, so // let them be reported again. This happens even when the context is unchanged, so that // identify is a reliable way for an app to mark a new phase of a session. - exposureDeduper.reset(); + evaluationExposureDeduper.reset(); // Load cached flags for the new context so they're available in case initialization // times out or otherwise fails. This does not short-circuit initialization — the data @@ -773,14 +773,14 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ * in version and so is covered without being part of the key. */ private boolean shouldRecordExposure(LDContext context, String flagKey, int variation, int flagVersion) { - if (!exposureDeduper.isEnabled()) { + if (!evaluationExposureDeduper.isEnabled()) { // Building the key allocates, so it is skipped entirely while deduplication is off, which // is the default. return true; } String dedupeKey = flagKey + '\n' + variation + '\n' + flagVersion + '\n' + context.getFullyQualifiedKey(); - return exposureDeduper.shouldRecord(dedupeKey, System.currentTimeMillis()); + return evaluationExposureDeduper.shouldRecord(dedupeKey, System.currentTimeMillis()); } /** diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java index d562964a..f0224938 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java @@ -63,15 +63,15 @@ public class LDConfig { static final String primaryEnvironmentName = "default"; /** - * The default value for {@link LDConfig.Builder#flagExposureDedupeWindowMillis(int)}: 0, meaning that + * The default value for {@link LDConfig.Builder#evaluationExposureDedupeWindowMillis(int)}: 0, meaning that * deduplication is disabled. */ - public static final int DEFAULT_FLAG_EXPOSURE_DEDUPE_WINDOW_MILLIS = 0; + public static final int DEFAULT_EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS = 0; /** - * The default value for {@link LDConfig.Builder#flagExposureDedupeMaxSize(int)}: 2000. + * The default value for {@link LDConfig.Builder#evaluationExposureDedupeMaxSize(int)}: 2000. */ - public static final int DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE = 2_000; + public static final int DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE = 2_000; static final int DEFAULT_MAX_CACHED_CONTEXTS = 5; static final int DEFAULT_CONNECTION_TIMEOUT_MILLIS = 10_000; // 10 seconds @@ -98,8 +98,8 @@ public class LDConfig { private final String loggerName; private final int maxCachedContexts; private final boolean offline; - private final int flagExposureDedupeWindowMillis; - private final int flagExposureDedupeMaxSize; + private final int evaluationExposureDedupeWindowMillis; + private final int evaluationExposureDedupeMaxSize; private final long connectionModeStateDebounceMs; private final PersistentDataStore persistentDataStore; // configurable for testing only @@ -119,8 +119,8 @@ public class LDConfig { int maxCachedContexts, boolean generateAnonymousKeys, boolean autoEnvAttributes, - int flagExposureDedupeWindowMillis, - int flagExposureDedupeMaxSize, + int evaluationExposureDedupeWindowMillis, + int evaluationExposureDedupeMaxSize, long connectionModeStateDebounceMs, PersistentDataStore persistentDataStore, LDLogAdapter logAdapter, @@ -141,8 +141,8 @@ public class LDConfig { this.maxCachedContexts = maxCachedContexts; this.generateAnonymousKeys = generateAnonymousKeys; this.autoEnvAttributes = autoEnvAttributes; - this.flagExposureDedupeWindowMillis = flagExposureDedupeWindowMillis; - this.flagExposureDedupeMaxSize = flagExposureDedupeMaxSize; + this.evaluationExposureDedupeWindowMillis = evaluationExposureDedupeWindowMillis; + this.evaluationExposureDedupeMaxSize = evaluationExposureDedupeMaxSize; this.connectionModeStateDebounceMs = connectionModeStateDebounceMs; this.persistentDataStore = persistentDataStore; this.logAdapter = logAdapter; @@ -213,17 +213,17 @@ int getMaxCachedContexts() { } /** - * @return the feature flag exposure deduplication window in milliseconds, or 0 if deduplication is disabled + * @return the evaluation exposure deduplication window in milliseconds, or 0 if deduplication is disabled */ - public int getFlagExposureDedupeWindowMillis() { - return flagExposureDedupeWindowMillis; + public int getEvaluationExposureDedupeWindowMillis() { + return evaluationExposureDedupeWindowMillis; } /** - * @return the maximum number of feature flag exposure keys tracked for deduplication at once + * @return the maximum number of evaluation exposure keys tracked for deduplication at once */ - public int getFlagExposureDedupeMaxSize() { - return flagExposureDedupeMaxSize; + public int getEvaluationExposureDedupeMaxSize() { + return evaluationExposureDedupeMaxSize; } /** @@ -295,8 +295,8 @@ public enum AutoEnvAttributes { private int maxCachedContexts = DEFAULT_MAX_CACHED_CONTEXTS; - private int flagExposureDedupeWindowMillis = DEFAULT_FLAG_EXPOSURE_DEDUPE_WINDOW_MILLIS; - private int flagExposureDedupeMaxSize = DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE; + private int evaluationExposureDedupeWindowMillis = DEFAULT_EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS; + private int evaluationExposureDedupeMaxSize = DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE; private boolean offline = false; private boolean disableBackgroundUpdating = false; @@ -679,35 +679,35 @@ public Builder maxCachedContexts(int maxCachedContexts) { * The cache of recorded exposures is cleared by {@link LDClient#identify(LDContext)}, so the * first evaluation after an identify is always reported. *

- * If not specified, the default is {@link #DEFAULT_FLAG_EXPOSURE_DEDUPE_WINDOW_MILLIS} (0), + * If not specified, the default is {@link #DEFAULT_EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS} (0), * which disables deduplication so that every evaluation is reported. * - * @param flagExposureDedupeWindowMillis the dedupe window in milliseconds; zero or negative + * @param evaluationExposureDedupeWindowMillis the dedupe window in milliseconds; zero or negative * disables deduplication * @return the builder - * @see #flagExposureDedupeMaxSize(int) + * @see #evaluationExposureDedupeMaxSize(int) */ - public Builder flagExposureDedupeWindowMillis(int flagExposureDedupeWindowMillis) { - this.flagExposureDedupeWindowMillis = flagExposureDedupeWindowMillis; + public Builder evaluationExposureDedupeWindowMillis(int evaluationExposureDedupeWindowMillis) { + this.evaluationExposureDedupeWindowMillis = evaluationExposureDedupeWindowMillis; return this; } /** - * Sets the maximum number of unique feature flag exposure keys tracked for deduplication at + * Sets the maximum number of unique evaluation exposure keys tracked for deduplication at * once. *

* When the limit is exceeded, the least recently recorded keys are evicted to bound memory - * usage. This only matters when {@link #flagExposureDedupeWindowMillis(int)} is enabled. + * usage. This only matters when {@link #evaluationExposureDedupeWindowMillis(int)} is enabled. *

- * If not specified, the default is {@link #DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE} (2000). + * If not specified, the default is {@link #DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE} (2000). * - * @param flagExposureDedupeMaxSize the maximum number of keys to track; zero or negative + * @param evaluationExposureDedupeMaxSize the maximum number of keys to track; zero or negative * values are ignored and the default is used instead * @return the builder - * @see #flagExposureDedupeWindowMillis(int) + * @see #evaluationExposureDedupeWindowMillis(int) */ - public Builder flagExposureDedupeMaxSize(int flagExposureDedupeMaxSize) { - this.flagExposureDedupeMaxSize = flagExposureDedupeMaxSize; + public Builder evaluationExposureDedupeMaxSize(int evaluationExposureDedupeMaxSize) { + this.evaluationExposureDedupeMaxSize = evaluationExposureDedupeMaxSize; return this; } @@ -921,8 +921,8 @@ public LDConfig build() { maxCachedContexts, generateAnonymousKeys, autoEnvAttributes, - flagExposureDedupeWindowMillis, - flagExposureDedupeMaxSize, + evaluationExposureDedupeWindowMillis, + evaluationExposureDedupeMaxSize, connectionModeStateDebounceMs, persistentDataStore, actualLogAdapter, diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java similarity index 81% rename from launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ExposureDeduperTest.java rename to launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java index 684a2945..7d5e4929 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java @@ -6,11 +6,11 @@ import org.junit.Test; -public class ExposureDeduperTest { +public class EvaluationExposureDeduperTest { @Test public void disabledForNonPositiveWindow() { for (int window : new int[] { 0, -1 }) { - ExposureDeduper deduper = new ExposureDeduper(window, 10); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(window, 10); assertFalse(deduper.isEnabled()); assertTrue(deduper.shouldRecord("a", 0)); assertTrue(deduper.shouldRecord("a", 0)); @@ -19,7 +19,7 @@ public void disabledForNonPositiveWindow() { @Test public void suppressesRepeatsWithinWindow() { - ExposureDeduper deduper = new ExposureDeduper(100, 10); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); assertTrue(deduper.shouldRecord("a", 1000)); assertFalse(deduper.shouldRecord("a", 1000)); assertFalse(deduper.shouldRecord("a", 1099)); @@ -27,7 +27,7 @@ public void suppressesRepeatsWithinWindow() { @Test public void recordsAgainOnceWindowElapses() { - ExposureDeduper deduper = new ExposureDeduper(100, 10); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); assertTrue(deduper.shouldRecord("a", 1000)); assertTrue(deduper.shouldRecord("a", 1100)); // Recording restarts the window rather than extending the original one. @@ -37,7 +37,7 @@ public void recordsAgainOnceWindowElapses() { @Test public void tracksKeysIndependently() { - ExposureDeduper deduper = new ExposureDeduper(100, 10); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); assertTrue(deduper.shouldRecord("a", 1000)); assertTrue(deduper.shouldRecord("b", 1000)); assertFalse(deduper.shouldRecord("a", 1000)); @@ -46,7 +46,7 @@ public void tracksKeysIndependently() { @Test public void recordsAgainAfterReset() { - ExposureDeduper deduper = new ExposureDeduper(100, 10); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); assertTrue(deduper.shouldRecord("a", 1000)); deduper.reset(); assertTrue(deduper.shouldRecord("a", 1000)); @@ -54,7 +54,7 @@ public void recordsAgainAfterReset() { @Test public void evictsLeastRecentlyRecordedKeysPastCap() { - ExposureDeduper deduper = new ExposureDeduper(10_000, 4); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(10_000, 4); for (int i = 0; i < 5; i++) { assertTrue(deduper.shouldRecord("key-" + i, 1000 + i)); } @@ -66,7 +66,7 @@ public void evictsLeastRecentlyRecordedKeysPastCap() { @Test public void reRecordingMovesKeyToMostRecentEndOfEvictionOrder() { - ExposureDeduper deduper = new ExposureDeduper(100, 2); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 2); assertTrue(deduper.shouldRecord("a", 1000)); assertTrue(deduper.shouldRecord("b", 1000)); // "a" is re-recorded once its window elapses, which makes "b" the oldest tracked key. @@ -79,7 +79,7 @@ public void reRecordingMovesKeyToMostRecentEndOfEvictionOrder() { public void keepsLiveKeysWhenReclaimingExpiredOnesIsEnough() { // maxSize is 8 so that the batch term (maxSize / 4) is non-zero, which is what makes an // over-eager batch drop observable. - ExposureDeduper deduper = new ExposureDeduper(100, 8); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 8); for (int i = 0; i < 2; i++) { assertTrue(deduper.shouldRecord("expired-" + i, 1000)); } @@ -96,8 +96,8 @@ public void keepsLiveKeysWhenReclaimingExpiredOnesIsEnough() { @Test public void fallsBackToDefaultCapForNonPositiveMaxSize() { - ExposureDeduper deduper = new ExposureDeduper(10_000, 0); - for (int i = 0; i < LDConfig.DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE; i++) { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(10_000, 0); + for (int i = 0; i < LDConfig.DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE; i++) { assertTrue(deduper.shouldRecord("key-" + i, 1000)); } assertFalse(deduper.shouldRecord("key-0", 1000)); @@ -105,7 +105,7 @@ public void fallsBackToDefaultCapForNonPositiveMaxSize() { @Test public void recordsOnceWhenSameKeyIsCheckedConcurrently() throws Exception { - ExposureDeduper deduper = new ExposureDeduper(60_000, 100); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(60_000, 100); int threadCount = 10; Thread[] threads = new Thread[threadCount]; boolean[] recorded = new boolean[threadCount]; diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java index df9adaff..60a0f20b 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java @@ -41,21 +41,21 @@ public void testBuilderDefaults() { assertEquals(0, config.hooks.getHooks().size()); - assertEquals(LDConfig.DEFAULT_FLAG_EXPOSURE_DEDUPE_WINDOW_MILLIS, - config.getFlagExposureDedupeWindowMillis()); - assertEquals(LDConfig.DEFAULT_FLAG_EXPOSURE_DEDUPE_MAX_SIZE, - config.getFlagExposureDedupeMaxSize()); + assertEquals(LDConfig.DEFAULT_EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS, + config.getEvaluationExposureDedupeWindowMillis()); + assertEquals(LDConfig.DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE, + config.getEvaluationExposureDedupeMaxSize()); } @Test - public void testBuilderFlagExposureDedupe() { + public void testBuilderEvaluationExposureDedupe() { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled) - .flagExposureDedupeWindowMillis(5_000) - .flagExposureDedupeMaxSize(50) + .evaluationExposureDedupeWindowMillis(5_000) + .evaluationExposureDedupeMaxSize(50) .build(); - assertEquals(5_000, config.getFlagExposureDedupeWindowMillis()); - assertEquals(50, config.getFlagExposureDedupeMaxSize()); + assertEquals(5_000, config.getEvaluationExposureDedupeWindowMillis()); + assertEquals(50, config.getEvaluationExposureDedupeMaxSize()); } @Test From f38335f3c6e9fb1a67e12a1c11b371b4d15063a9 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 4 Aug 2026 19:12:37 -0700 Subject: [PATCH 04/29] fix: key exposure dedupe on experiment status The version reported on events is the flag's own version, so it does not move when a prerequisite flip changes an evaluation's reason. Without the experiment bit in the key, an evaluation entering or leaving an experiment on the same variation of the same flag version stays suppressed. Key construction moves onto the deduper so it can be covered directly. Co-authored-by: Cursor --- .../android/EvaluationExposureDeduper.java | 22 +++++++++++++++++++ .../launchdarkly/sdk/android/LDClient.java | 17 ++++++-------- .../EvaluationExposureDeduperTest.java | 13 +++++++++++ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureDeduper.java index 8ddbeadc..9484bfcc 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureDeduper.java @@ -37,6 +37,28 @@ boolean isEnabled() { return windowMillis > 0; } + /** + * Builds the key identifying an evaluation result. + *

+ * The variation and version pair is the same identity LaunchDarkly uses to bucket evaluations in + * summary events, so two evaluations sharing that pair report identical data. Experiment status + * needs its own component because the version reported on events is the flag's own version, which + * only moves when the flag itself changes: a prerequisite flipping can move an evaluation into or + * out of an experiment while it lands on the same variation of the same flag version. + * + * @param flagKey the flag key + * @param variation the variation index of the result + * @param flagVersion the flag version reported on events + * @param inExperiment whether the evaluation was part of an experiment rollout + * @param fullyQualifiedContextKey the fully qualified key of the evaluation context + * @return a stable key identifying the evaluation result + */ + static String exposureKey(String flagKey, int variation, int flagVersion, boolean inExperiment, + String fullyQualifiedContextKey) { + return flagKey + '\n' + variation + '\n' + flagVersion + '\n' + inExperiment + '\n' + + fullyQualifiedContextKey; + } + /** * Returns whether an exposure for the given key should be recorded, and if so starts a new dedupe * window for it. diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index b5020ae2..ff894a6a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -699,7 +699,7 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ if (flag == null) { logger.info("Unknown feature flag \"{}\"; returning default value", key); - if (shouldRecordExposure(context, key, EvaluationDetail.NO_VARIATION, EventProcessor.NO_VERSION)) { + if (shouldRecordExposure(context, key, EvaluationDetail.NO_VARIATION, EventProcessor.NO_VERSION, false)) { eventProcessor.recordEvaluationEvent(context, key, EventProcessor.NO_VERSION, EvaluationDetail.NO_VARIATION, defaultValue, null, defaultValue, false, null); @@ -744,7 +744,8 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ } else { result = EvaluationDetail.fromValue(value, variation, flag.getReason()); } - if (shouldRecordExposure(context, key, variation, flag.getVersionForEvents())) { + boolean inExperiment = flag.getReason() != null && flag.getReason().isInExperiment(); + if (shouldRecordExposure(context, key, variation, flag.getVersionForEvents(), inExperiment)) { eventProcessor.recordEvaluationEvent( context, key, @@ -766,20 +767,16 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ /** * Returns whether this evaluation should be reported to LaunchDarkly, and if so starts a new * dedupe window for it. - *

- * The variation and version pair is the same identity LaunchDarkly uses to bucket evaluations in - * summary events, so two evaluations sharing that pair report identical data. Because the - * evaluation reason is carried on the versioned flag payload, a change in reason implies a change - * in version and so is covered without being part of the key. */ - private boolean shouldRecordExposure(LDContext context, String flagKey, int variation, int flagVersion) { + private boolean shouldRecordExposure(LDContext context, String flagKey, int variation, int flagVersion, + boolean inExperiment) { if (!evaluationExposureDeduper.isEnabled()) { // Building the key allocates, so it is skipped entirely while deduplication is off, which // is the default. return true; } - String dedupeKey = flagKey + '\n' + variation + '\n' + flagVersion + '\n' - + context.getFullyQualifiedKey(); + String dedupeKey = EvaluationExposureDeduper.exposureKey(flagKey, variation, flagVersion, + inExperiment, context.getFullyQualifiedKey()); return evaluationExposureDeduper.shouldRecord(dedupeKey, System.currentTimeMillis()); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java index 7d5e4929..39968d71 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; import org.junit.Test; @@ -17,6 +18,18 @@ public void disabledForNonPositiveWindow() { } } + @Test + public void exposureKeyDistinguishesEveryComponent() { + String key = EvaluationExposureDeduper.exposureKey("flag", 1, 2, false, "user-key"); + assertEquals(key, EvaluationExposureDeduper.exposureKey("flag", 1, 2, false, "user-key")); + assertNotEquals(key, EvaluationExposureDeduper.exposureKey("other-flag", 1, 2, false, "user-key")); + assertNotEquals(key, EvaluationExposureDeduper.exposureKey("flag", 3, 2, false, "user-key")); + assertNotEquals(key, EvaluationExposureDeduper.exposureKey("flag", 1, 4, false, "user-key")); + assertNotEquals(key, EvaluationExposureDeduper.exposureKey("flag", 1, 2, false, "other-user-key")); + // Moving into an experiment on the same variation of the same flag version reports again. + assertNotEquals(key, EvaluationExposureDeduper.exposureKey("flag", 1, 2, true, "user-key")); + } + @Test public void suppressesRepeatsWithinWindow() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); From cd5e1bf82d07b4c13c5883fb66fecb945b805d00 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 4 Aug 2026 22:22:02 -0700 Subject: [PATCH 05/29] refactor: deduplicate exposures reported to hooks, not events Analytics events now record every evaluation again. Deduplication instead gates the evaluation hook series, which is what feeds plugin telemetry, so enabling it no longer changes the evaluation counts LaunchDarkly reports. The decision is made before the series opens rather than after the evaluation, because hooks pair their stages: the observability plugin starts a span in beforeEvaluation and ends it in afterEvaluation, so suppressing only the after stage would leave that span open. Reading the stored flag identifies the same exposure the result would. HookRunner takes the decision as an injected filter, which keeps the policy in LDClient and leaves the ten withEvaluation call sites untouched. Co-authored-by: Cursor --- .../sdk/android/LDClientEventTest.java | 73 ++----------------- .../sdk/android/LDClientHooksTest.java | 67 ++++++++++++++++- .../launchdarkly/sdk/android/HookRunner.java | 17 ++++- .../launchdarkly/sdk/android/LDClient.java | 67 ++++++++++------- .../launchdarkly/sdk/android/LDConfig.java | 22 +++--- .../sdk/android/HookRunnerTest.java | 49 +++++++++++++ 6 files changed, 188 insertions(+), 107 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java index e2a02076..90098f09 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java @@ -240,7 +240,7 @@ public void flagEvaluationWithPrereqProducesPrereqEvents() throws IOException, I } @Test - public void repeatedEvaluationsAreReportedWhenDedupeIsDisabledByDefault() throws IOException, InterruptedException { + public void exposureDeduplicationDoesNotSuppressEvaluationEvents() throws IOException, InterruptedException { try (MockWebServer mockEventsServer = new MockWebServer()) { mockEventsServer.start(); mockEventsServer.enqueue(new MockResponse()); @@ -249,8 +249,12 @@ public void repeatedEvaluationsAreReportedWhenDedupeIsDisabledByDefault() throws .variation(1).value(LDValue.of(true)).trackEvents(true).build(); PersistentDataStore store = new InMemoryPersistentDataStore(); TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flag); + // Deduplication applies to hooks only, so a window wide enough to suppress every repeat + // must still leave the analytics events untouched. LDConfig ldConfig = baseConfigBuilder(mockEventsServer) - .persistentDataStore(store).build(); + .persistentDataStore(store) + .evaluationExposureDedupeWindowMillis(60_000) + .build(); try (LDClient client = LDClient.init(application, ldConfig, ldContext, 0)) { for (int i = 0; i < 3; i++) { @@ -266,71 +270,6 @@ public void repeatedEvaluationsAreReportedWhenDedupeIsDisabledByDefault() throws } } - @Test - public void repeatedEvaluationsAreDeduplicatedWithinTheConfiguredWindow() throws IOException, InterruptedException { - try (MockWebServer mockEventsServer = new MockWebServer()) { - mockEventsServer.start(); - mockEventsServer.enqueue(new MockResponse()); - - Flag flag = new FlagBuilder("flagA").version(1) - .variation(1).value(LDValue.of(true)).trackEvents(true).build(); - PersistentDataStore store = new InMemoryPersistentDataStore(); - TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flag); - LDConfig ldConfig = baseConfigBuilder(mockEventsServer) - .persistentDataStore(store) - .evaluationExposureDedupeWindowMillis(60_000) - .build(); - - try (LDClient client = LDClient.init(application, ldConfig, ldContext, 0)) { - for (int i = 0; i < 3; i++) { - assertTrue(client.boolVariation("flagA", false)); - } - client.blockingFlush(); - - // The repeats are suppressed, so only one feature event and one summary count remain. - LDValue[] events = getEventsFromLastRequest(mockEventsServer, 3); - assertFeatureEvent(events[1], ldContext); - assertSummaryEvent(events[2]); - assertEquals(LDValue.of(1), events[2].get("features").get("flagA").get("counters").get(0).get("count")); - } - } - } - - @Test - public void identifyResetsEvaluationExposureDedupeCache() throws IOException, InterruptedException { - try (MockWebServer mockEventsServer = new MockWebServer()) { - mockEventsServer.start(); - mockEventsServer.enqueue(new MockResponse()); - - Flag flag = new FlagBuilder("flagA").version(1) - .variation(1).value(LDValue.of(true)).build(); - PersistentDataStore store = new InMemoryPersistentDataStore(); - TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flag); - LDConfig ldConfig = baseConfigBuilder(mockEventsServer) - .persistentDataStore(store) - .evaluationExposureDedupeWindowMillis(60_000) - .build(); - - try (LDClient client = LDClient.init(application, ldConfig, ldContext, 0)) { - assertTrue(client.boolVariation("flagA", false)); - assertTrue(client.boolVariation("flagA", false)); - - // Identifying to the unchanged context still clears the cache, so the evaluation - // after it is reported rather than suppressed. - client.identify(ldContext).get(); - assertTrue(client.boolVariation("flagA", false)); - client.blockingFlush(); - - LDValue[] events = getEventsFromLastRequest(mockEventsServer, 3); - LDValue summaryEvent = events[2]; - assertSummaryEvent(summaryEvent); - assertEquals(LDValue.of(2), summaryEvent.get("features").get("flagA").get("counters").get(0).get("count")); - } - } catch (java.util.concurrent.ExecutionException e) { - fail("identify failed: " + e); - } - } - // Cycle-detection tests exercise CSPE 1.2.5, 1.2.5.1, and 1.2.5.2. Prior to the cycle guard, // any of these configurations would cause a StackOverflowError on the first variation() call. // The tests set up a cyclic prerequisite graph via the persistent store, evaluate one flag on diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java index 485765ea..f74fab85 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java @@ -178,11 +178,76 @@ public void executesBothInitialHooksAndHooksAddedWithAddHooks() throws Exception } } + @Test + public void repeatedEvaluationsReachHooksWhenDedupeIsDisabledByDefault() throws Exception { + try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testHook)), ldContext, 1)) { + for (int i = 0; i < 3; i++) { + ldClient.boolVariation("test-flag", false); + } + + assertEquals(3, testHook.beforeEvaluationCalls.size()); + assertEquals(3, testHook.afterEvaluationCalls.size()); + } + } + + @Test + public void repeatedEvaluationsAreDeduplicatedWithinTheConfiguredWindow() throws Exception { + LDConfig config = makeOfflineConfigBuilder(List.of(testHook)) + .evaluationExposureDedupeWindowMillis(60_000) + .build(); + try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { + for (int i = 0; i < 3; i++) { + ldClient.boolVariation("test-flag", false); + } + + // The whole series is skipped, so a hook pairing its stages never sees an unmatched before. + assertEquals(1, testHook.beforeEvaluationCalls.size()); + assertEquals(1, testHook.afterEvaluationCalls.size()); + } + } + + @Test + public void identifyResetsEvaluationExposureDedupeCache() throws Exception { + LDConfig config = makeOfflineConfigBuilder(List.of(testHook)) + .evaluationExposureDedupeWindowMillis(60_000) + .build(); + try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { + ldClient.boolVariation("test-flag", false); + ldClient.boolVariation("test-flag", false); + assertEquals(1, testHook.afterEvaluationCalls.size()); + + // Identifying to the unchanged context still clears the cache, so the evaluation after it + // reaches the hooks rather than being suppressed. + ldClient.identify(ldContext).get(); + ldClient.boolVariation("test-flag", false); + + assertEquals(2, testHook.afterEvaluationCalls.size()); + } + } + + @Test + public void evaluationsOfDifferentFlagsReachHooksSeparately() throws Exception { + LDConfig config = makeOfflineConfigBuilder(List.of(testHook)) + .evaluationExposureDedupeWindowMillis(60_000) + .build(); + try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { + ldClient.boolVariation("test-flag", false); + ldClient.boolVariation("other-flag", false); + ldClient.boolVariation("test-flag", false); + + assertEquals(2, testHook.afterEvaluationCalls.size()); + } + } + private LDConfig makeOfflineConfig() { return makeOfflineConfig(null); } private LDConfig makeOfflineConfig(List hooks) { + return makeOfflineConfigBuilder(hooks).build(); + } + + private LDConfig.Builder makeOfflineConfigBuilder(List hooks) { LDConfig.Builder builder = new LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Disabled) .mobileKey(mobileKey) .offline(true) @@ -193,7 +258,7 @@ private LDConfig makeOfflineConfig(List hooks) { builder.hooks(Components.hooks().setHooks(hooks)); } - return builder.build(); + return builder; } private static class MockHook extends Hook { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java index b05ae07e..af2d2c17 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java @@ -26,14 +26,29 @@ public interface AfterIdentifyMethod { void invoke(IdentifySeriesResult result); } + /** + * Decides whether an evaluation's exposure should be reported to hooks. Consulted once per + * evaluation, before the series opens, so that a suppressed evaluation runs neither stage. + */ + @FunctionalInterface + public interface ExposureFilter { + boolean shouldReport(String flagKey, LDContext context); + } + private static final String UNKNOWN_HOOK_NAME = "unknown hook"; private final LDLogger logger; private final List hooks = new ArrayList<>(); + private final ExposureFilter exposureFilter; public HookRunner(LDLogger logger, List initialHooks) { + this(logger, initialHooks, (flagKey, context) -> true); + } + + public HookRunner(LDLogger logger, List initialHooks, ExposureFilter exposureFilter) { this.logger = logger; this.hooks.addAll(initialHooks); + this.exposureFilter = exposureFilter; } private String getHookName(Hook hook) { @@ -51,7 +66,7 @@ public void addHook(Hook hook) { } public EvaluationDetail withEvaluation(String method, String key, LDContext context, LDValue defaultValue, EvaluationMethod evalMethod) { - if (hooks.isEmpty()) { + if (hooks.isEmpty() || !exposureFilter.shouldReport(key, context)) { return evalMethod.evaluate(); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index ff894a6a..7d0cb44a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -440,12 +440,12 @@ protected LDClient( environmentStore ); - hookRunner = new HookRunner(logger, config.hooks.getHooks()); - evaluationExposureDeduper = new EvaluationExposureDeduper( config.getEvaluationExposureDedupeWindowMillis(), config.getEvaluationExposureDedupeMaxSize() ); + + hookRunner = new HookRunner(logger, config.hooks.getHooks(), this::shouldReportExposureToHooks); } @Override @@ -699,11 +699,9 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ if (flag == null) { logger.info("Unknown feature flag \"{}\"; returning default value", key); - if (shouldRecordExposure(context, key, EvaluationDetail.NO_VARIATION, EventProcessor.NO_VERSION, false)) { - eventProcessor.recordEvaluationEvent(context, key, - EventProcessor.NO_VERSION, EvaluationDetail.NO_VARIATION, defaultValue, - null, defaultValue, false, null); - } + eventProcessor.recordEvaluationEvent(context, key, + EventProcessor.NO_VERSION, EvaluationDetail.NO_VARIATION, defaultValue, + null, defaultValue, false, null); result = EvaluationDetail.fromValue(defaultValue, EvaluationDetail.NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); } else { if (flag.getPrerequisites() != null) { @@ -744,20 +742,17 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ } else { result = EvaluationDetail.fromValue(value, variation, flag.getReason()); } - boolean inExperiment = flag.getReason() != null && flag.getReason().isInExperiment(); - if (shouldRecordExposure(context, key, variation, flag.getVersionForEvents(), inExperiment)) { - eventProcessor.recordEvaluationEvent( - context, - key, - flag.getVersionForEvents(), - flag.getVariation() == null ? -1 : flag.getVariation().intValue(), - value, - flag.isTrackReason() | needsReason ? result.getReason() : null, - defaultValue, - flag.isTrackEvents(), - flag.getDebugEventsUntilDate() - ); - } + eventProcessor.recordEvaluationEvent( + context, + key, + flag.getVersionForEvents(), + flag.getVariation() == null ? -1 : flag.getVariation().intValue(), + value, + flag.isTrackReason() | needsReason ? result.getReason() : null, + defaultValue, + flag.isTrackEvents(), + flag.getDebugEventsUntilDate() + ); } logger.debug("returning variation: {} flagKey: {} context key: {}", result, key, context.getKey()); @@ -765,19 +760,35 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ } /** - * Returns whether this evaluation should be reported to LaunchDarkly, and if so starts a new - * dedupe window for it. + * Returns whether this evaluation's exposure should be reported to the registered hooks, and if + * so starts a new dedupe window for it. + *

+ * The decision is made before the series opens rather than after the evaluation completes, + * because hooks pair their stages: the observability plugin starts a span in + * {@code beforeEvaluation} and ends it in {@code afterEvaluation}, so suppressing only the after + * stage would leave that span open until something else closed it. Reading the stored flag here + * identifies the same exposure the result would, since the result is derived from it. */ - private boolean shouldRecordExposure(LDContext context, String flagKey, int variation, int flagVersion, - boolean inExperiment) { + private boolean shouldReportExposureToHooks(String flagKey, LDContext context) { if (!evaluationExposureDeduper.isEnabled()) { - // Building the key allocates, so it is skipped entirely while deduplication is off, which - // is the default. + // Looking up the flag and building the key both cost more than the check they feed, so + // they are skipped entirely while deduplication is off, which is the default. return true; } + + Flag flag = contextDataManager.getNonDeletedFlag(flagKey); + int variation = flag == null || flag.getVariation() == null + ? EvaluationDetail.NO_VARIATION : flag.getVariation(); + int flagVersion = flag == null ? EventProcessor.NO_VERSION : flag.getVersionForEvents(); + boolean inExperiment = flag != null && flag.getReason() != null && flag.getReason().isInExperiment(); + String dedupeKey = EvaluationExposureDeduper.exposureKey(flagKey, variation, flagVersion, inExperiment, context.getFullyQualifiedKey()); - return evaluationExposureDeduper.shouldRecord(dedupeKey, System.currentTimeMillis()); + if (!evaluationExposureDeduper.shouldRecord(dedupeKey, System.currentTimeMillis())) { + logger.debug("Deduplicated exposure for flagKey: {}", flagKey); + return false; + } + return true; } /** diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java index f0224938..01e48a71 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java @@ -665,16 +665,18 @@ public Builder maxCachedContexts(int maxCachedContexts) { /** * Sets the time window, in milliseconds, during which repeated feature flag evaluations that - * resolve to the same result are deduplicated. - *

- * Within the window, only a single evaluation is reported per unique combination of flag key, - * variation, flag version, and evaluation context. This is useful for reducing analytics event - * volume caused by frequent re-evaluations, for example a flag that is read on every redraw of - * a view. - *

- * Deduplicated evaluations are omitted from both the full feature events used by - * experimentation and the debugger, and the summary events that drive flag evaluation counts. - * Enabling this therefore reduces the evaluation counts LaunchDarkly reports for your flags. + * resolve to the same result are deduplicated before being reported to hooks. + *

+ * Within the window, a hook observes only a single evaluation per unique combination of flag + * key, variation, flag version, experiment status, and evaluation context. This is useful for + * reducing the telemetry volume produced by frequent re-evaluations, for example a flag that is + * read on every redraw of a view. + *

+ * Deduplication applies to the whole evaluation series, so a suppressed evaluation invokes + * neither {@code beforeEvaluation} nor {@code afterEvaluation} on any registered hook. This + * affects every hook, including your own, not only those added by plugins. Analytics events are + * unaffected: feature, debug, and summary events are still recorded for every evaluation, so the + * evaluation counts LaunchDarkly reports for your flags do not change. *

* The cache of recorded exposures is cleared by {@link LDClient#identify(LDContext)}, so the * first evaluation after an identify is always reported. diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java index af1fdaf1..c3eb573b 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java @@ -68,6 +68,55 @@ public void executesHooksAndReturnsEvaluationResult() { logging.assertNothingLogged(); } + @Test + public void skipsBothStagesWhenTheExposureFilterSuppressesTheEvaluation() { + String key = "test-flag"; + LDContext context = LDContext.create("user-123"); + LDValue defaultValue = LDValue.of(false); + EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); + + List filtered = new ArrayList<>(); + HookRunner suppressing = new HookRunner(logging.logger, List.of(testHook), (flagKey, evalContext) -> { + filtered.add(flagKey); + return false; + }); + + // No hook stage is expected: a suppressed evaluation must not leave a beforeEvaluation + // unmatched by its afterEvaluation. + replayAll(); + + EvaluationDetail result = suppressing.withEvaluation("testMethod", key, context, defaultValue, () -> evaluationResult); + + verifyAll(); + assertSame(evaluationResult, result); + assertEquals(List.of(key), filtered); + logging.assertNothingLogged(); + } + + @Test + public void consultsTheExposureFilterOncePerEvaluation() { + LDContext context = LDContext.create("user-123"); + LDValue defaultValue = LDValue.of(false); + EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); + EvaluationSeriesContext seriesContext = new EvaluationSeriesContext("testMethod", "test-flag", context, defaultValue); + + List filtered = new ArrayList<>(); + HookRunner allowing = new HookRunner(logging.logger, List.of(testHook), (flagKey, evalContext) -> { + filtered.add(flagKey); + return true; + }); + + expect(testHook.beforeEvaluation(seriesContext, Collections.emptyMap())).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + expect(testHook.afterEvaluation(seriesContext, Collections.emptyMap(), evaluationResult)).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + replayAll(); + + allowing.withEvaluation("testMethod", "test-flag", context, defaultValue, () -> evaluationResult); + + verifyAll(); + assertEquals(List.of("test-flag"), filtered); + logging.assertNothingLogged(); + } + @Test public void handlesErrorInEvaluationHooks() { String method = "testMethod"; From 399fcd78998388488b7df5b1f795ed3c2d8680e3 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 5 Aug 2026 22:24:27 -0700 Subject: [PATCH 06/29] feat: let each hook choose how its exposures are deduplicated A hook now carries its own deduper, so an audit hook can observe every evaluation while an observability hook on the same client keeps a long window. Hooks that ask for nothing fall back to the window configured on LDConfig, each with its own instance, since a shared one would let the first hook to observe an evaluation suppress it for the rest. EvaluationExposureDeduper moves to the integrations package and becomes public: implementations can be built with different parameters, opted out of with disabled(), or replaced by a subclass. The exposure key it is handed stays internal, in EvaluationExposureKey. Co-authored-by: Cursor --- .../sdk/android/LDClientHooksTest.java | 43 +++++ .../android/EvaluationExposureDeduper.java | 124 -------------- .../sdk/android/EvaluationExposureKey.java | 28 +++ .../launchdarkly/sdk/android/HookRunner.java | 105 ++++++++++-- .../launchdarkly/sdk/android/LDClient.java | 55 +++--- .../launchdarkly/sdk/android/LDConfig.java | 20 ++- .../EvaluationExposureDeduper.java | 153 +++++++++++++++++ .../sdk/android/integrations/Hook.java | 55 ++++++ .../HooksConfigurationBuilder.java | 27 ++- .../EvaluationExposureDeduperTest.java | 31 +++- .../sdk/android/HookRunnerTest.java | 161 ++++++++++++++---- 11 files changed, 582 insertions(+), 220 deletions(-) delete mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureDeduper.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureKey.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java index f74fab85..1f713604 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java @@ -10,6 +10,7 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext; @@ -239,6 +240,48 @@ public void evaluationsOfDifferentFlagsReachHooksSeparately() throws Exception { } } + @Test + public void hookKeepsItsOwnDeduperInsteadOfTheConfiguredOne() throws Exception { + MockHook reportingEverything = new MockHook(); + reportingEverything.evaluationExposureDeduper(EvaluationExposureDeduper.disabled()); + LDConfig config = makeOfflineConfigBuilder(null) + .evaluationExposureDedupeWindowMillis(60_000) + .hooks(Components.hooks().addHook(testHook).addHook(reportingEverything)) + .build(); + try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { + for (int i = 0; i < 3; i++) { + ldClient.boolVariation("test-flag", false); + } + + // testHook carries no deduper, so it falls back to the configured window. + assertEquals(1, testHook.afterEvaluationCalls.size()); + assertEquals(3, reportingEverything.afterEvaluationCalls.size()); + } + } + + @Test + public void hookDeduperAppliesWithoutAnyConfiguredWindow() throws Exception { + MockHook deduping = new MockHook(); + deduping.evaluationExposureDeduper(60_000, 100); + LDConfig config = makeOfflineConfigBuilder(null) + .hooks(Components.hooks().addHook(testHook).addHook(deduping)) + .build(); + try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { + for (int i = 0; i < 3; i++) { + ldClient.boolVariation("test-flag", false); + } + + // Deduplication is off by default, so only the hook that asked for it suppresses. + assertEquals(3, testHook.afterEvaluationCalls.size()); + assertEquals(1, deduping.afterEvaluationCalls.size()); + + // identify clears every hook's cache, whether it came from the hook or the config. + ldClient.identify(ldContext).get(); + ldClient.boolVariation("test-flag", false); + assertEquals(2, deduping.afterEvaluationCalls.size()); + } + } + private LDConfig makeOfflineConfig() { return makeOfflineConfig(null); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureDeduper.java deleted file mode 100644 index 9484bfcc..00000000 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureDeduper.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.launchdarkly.sdk.android; - -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * Tracks recently recorded evaluation exposures so that repeated evaluations resolving to the same - * result do not report a new evaluation event within a configured time window. - *

- * Each unique exposure key is only recorded once per window. The number of tracked keys is bounded; - * when the cap is exceeded the least recently recorded keys are evicted. - *

- * This class is thread-safe. Evaluations may be made from any thread, so the check of the window and - * the update of it are performed together under a single lock. - */ -final class EvaluationExposureDeduper { - private final long windowMillis; - private final int maxSize; - - // Insertion-ordered so that iteration visits the least recently recorded key first. Guarded by - // the instance lock, as is every access below. - private final LinkedHashMap lastRecordedAt = new LinkedHashMap<>(); - - /** - * @param windowMillis the dedupe window in milliseconds; zero or negative disables deduplication, - * so every exposure is recorded - * @param maxSize the maximum number of exposure keys to track; zero or negative falls back to - * {@link LDConfig#DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE} - */ - EvaluationExposureDeduper(int windowMillis, int maxSize) { - this.windowMillis = windowMillis; - this.maxSize = maxSize > 0 ? maxSize : LDConfig.DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE; - } - - boolean isEnabled() { - return windowMillis > 0; - } - - /** - * Builds the key identifying an evaluation result. - *

- * The variation and version pair is the same identity LaunchDarkly uses to bucket evaluations in - * summary events, so two evaluations sharing that pair report identical data. Experiment status - * needs its own component because the version reported on events is the flag's own version, which - * only moves when the flag itself changes: a prerequisite flipping can move an evaluation into or - * out of an experiment while it lands on the same variation of the same flag version. - * - * @param flagKey the flag key - * @param variation the variation index of the result - * @param flagVersion the flag version reported on events - * @param inExperiment whether the evaluation was part of an experiment rollout - * @param fullyQualifiedContextKey the fully qualified key of the evaluation context - * @return a stable key identifying the evaluation result - */ - static String exposureKey(String flagKey, int variation, int flagVersion, boolean inExperiment, - String fullyQualifiedContextKey) { - return flagKey + '\n' + variation + '\n' + flagVersion + '\n' + inExperiment + '\n' - + fullyQualifiedContextKey; - } - - /** - * Returns whether an exposure for the given key should be recorded, and if so starts a new dedupe - * window for it. - * - * @param key a stable key identifying the evaluation result - * @param nowMillis the current time in milliseconds since the epoch - * @return true if the exposure should be recorded, false if it should be suppressed - */ - synchronized boolean shouldRecord(String key, long nowMillis) { - if (!isEnabled()) { - return true; - } - - Long last = lastRecordedAt.get(key); - if (last != null && last > nowMillis - windowMillis) { - return false; - } - - // Remove before putting so the key moves to the most recent end of the iteration order. - lastRecordedAt.remove(key); - lastRecordedAt.put(key, nowMillis); - - if (lastRecordedAt.size() > maxSize) { - evict(nowMillis); - } - return true; - } - - /** - * Clears all recorded exposures. Called when the evaluation context changes. - */ - synchronized void reset() { - lastRecordedAt.clear(); - } - - private void evict(long nowMillis) { - // Keys whose window has already elapsed no longer change the outcome of shouldRecord, so - // reclaim those first. They sort before any live key, so this stops at the first live one. - long cutoff = nowMillis - windowMillis; - for (Iterator> it = lastRecordedAt.entrySet().iterator(); it.hasNext(); ) { - if (it.next().getValue() > cutoff) { - break; - } - it.remove(); - } - - if (lastRecordedAt.size() <= maxSize) { - // Reclaiming expired keys was enough. Dropping live keys past this point would report - // their next identical evaluation again. - return; - } - - // Evict a batch rather than a single key, so that a workload tracking more live keys than - // maxSize doesn't pay for an eviction on every subsequent exposure. - int dropCount = lastRecordedAt.size() - maxSize + maxSize / 4; - for (Iterator> it = lastRecordedAt.entrySet().iterator(); - dropCount > 0 && it.hasNext(); - dropCount--) { - it.next(); - it.remove(); - } - } -} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureKey.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureKey.java new file mode 100644 index 00000000..9e4f8bd9 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureKey.java @@ -0,0 +1,28 @@ +package com.launchdarkly.sdk.android; + +/** + * Builds the key identifying an evaluation result for exposure deduplication. + */ +abstract class EvaluationExposureKey { + private EvaluationExposureKey() {} + + /** + * The variation and version pair is the same identity LaunchDarkly uses to bucket evaluations in + * summary events, so two evaluations sharing that pair report identical data. Experiment status + * needs its own component because the version reported on events is the flag's own version, which + * only moves when the flag itself changes: a prerequisite flipping can move an evaluation into or + * out of an experiment while it lands on the same variation of the same flag version. + * + * @param flagKey the flag key + * @param variation the variation index of the result + * @param flagVersion the flag version reported on events + * @param inExperiment whether the evaluation was part of an experiment rollout + * @param fullyQualifiedContextKey the fully qualified key of the evaluation context + * @return a stable key identifying the evaluation result + */ + static String of(String flagKey, int variation, int flagVersion, boolean inExperiment, + String fullyQualifiedContextKey) { + return flagKey + '\n' + variation + '\n' + flagVersion + '\n' + inExperiment + '\n' + + fullyQualifiedContextKey; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java index af2d2c17..0212c392 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java @@ -4,6 +4,7 @@ import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext; @@ -27,28 +28,49 @@ public interface AfterIdentifyMethod { } /** - * Decides whether an evaluation's exposure should be reported to hooks. Consulted once per - * evaluation, before the series opens, so that a suppressed evaluation runs neither stage. + * Builds the key that the per-hook dedupers use to recognize a repeated evaluation. Consulted + * once per evaluation, before the series opens, and only when at least one hook can suppress. */ @FunctionalInterface - public interface ExposureFilter { - boolean shouldReport(String flagKey, LDContext context); + public interface ExposureKeySupplier { + String exposureKey(String flagKey, LDContext context); + } + + /** + * Creates the deduper for a hook registered without one. Each hook needs its own instance, + * because a deduper starts a window as soon as it reports an evaluation. + */ + @FunctionalInterface + public interface DefaultDeduperFactory { + EvaluationExposureDeduper create(); } private static final String UNKNOWN_HOOK_NAME = "unknown hook"; private final LDLogger logger; private final List hooks = new ArrayList<>(); - private final ExposureFilter exposureFilter; + // Parallel to hooks: the deduper deciding which evaluations reach the hook at the same index. + private final List dedupers = new ArrayList<>(); + private final DefaultDeduperFactory defaultDeduperFactory; + private final ExposureKeySupplier exposureKeySupplier; + + // False while every registered hook wants every evaluation, which is the default. Lets the + // evaluation path skip building the exposure key, which costs more than the checks it feeds. + private volatile boolean anyDedupeActive = false; public HookRunner(LDLogger logger, List initialHooks) { - this(logger, initialHooks, (flagKey, context) -> true); + this(logger, initialHooks, EvaluationExposureDeduper::disabled, (flagKey, context) -> ""); } - public HookRunner(LDLogger logger, List initialHooks, ExposureFilter exposureFilter) { + public HookRunner(LDLogger logger, List initialHooks, + DefaultDeduperFactory defaultDeduperFactory, + ExposureKeySupplier exposureKeySupplier) { this.logger = logger; - this.hooks.addAll(initialHooks); - this.exposureFilter = exposureFilter; + this.defaultDeduperFactory = defaultDeduperFactory; + this.exposureKeySupplier = exposureKeySupplier; + for (Hook hook : initialHooks) { + addHook(hook); + } } private String getHookName(Hook hook) { @@ -61,19 +83,72 @@ private String getHookName(Hook hook) { } } + /** + * Adds a hook, resolving now which evaluations will reach it: the deduper the hook carries, or + * one built from the SDK's configured defaults if it carries none. + * + * @param hook the hook to add + */ public void addHook(Hook hook) { + EvaluationExposureDeduper declared = hook.getEvaluationExposureDeduper(); + EvaluationExposureDeduper deduper = declared == null ? defaultDeduperFactory.create() : declared; + if (deduper != EvaluationExposureDeduper.disabled()) { + anyDedupeActive = true; + } + // The deduper goes in first so that an evaluation running concurrently with this never sees + // a hook whose deduper has not been appended yet. + dedupers.add(deduper); hooks.add(hook); } + /** + * Clears every hook's record of the evaluations it has already observed, so that the next + * evaluation of each reaches the hook again. Called when the evaluation context changes. + */ + public void resetEvaluationExposureDedupers() { + for (EvaluationExposureDeduper deduper : dedupers) { + deduper.reset(); + } + } + + /** + * Returns the hooks that should observe this evaluation. + *

+ * The decision is made before the series opens rather than after the evaluation completes, + * because hooks pair their stages: the observability plugin starts a span in + * {@code beforeEvaluation} and ends it in {@code afterEvaluation}, so suppressing only the after + * stage would leave that span open until something else closed it. + */ + private List hooksForEvaluation(String flagKey, LDContext context) { + if (!anyDedupeActive || hooks.isEmpty()) { + return hooks; + } + + String exposureKey = exposureKeySupplier.exposureKey(flagKey, context); + long nowMillis = System.currentTimeMillis(); + List reporting = new ArrayList<>(hooks.size()); + for (int i = 0; i < hooks.size(); i++) { + Hook hook = hooks.get(i); + if (dedupers.get(i).shouldRecord(exposureKey, nowMillis)) { + reporting.add(hook); + } else { + logger.debug("Deduplicated exposure of flag \"{}\" for hook \"{}\"", flagKey, + getHookName(hook)); + } + } + return reporting; + } + public EvaluationDetail withEvaluation(String method, String key, LDContext context, LDValue defaultValue, EvaluationMethod evalMethod) { - if (hooks.isEmpty() || !exposureFilter.shouldReport(key, context)) { + List reportingHooks = hooksForEvaluation(key, context); + if (reportingHooks.isEmpty()) { return evalMethod.evaluate(); } - List> seriesDataList = new ArrayList<>(hooks.size()); + List> seriesDataList = new ArrayList<>(reportingHooks.size()); EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue); - for (int i = 0; i < hooks.size(); i++) { - Hook currentHook = hooks.get(i); + for (int i = 0; i < reportingHooks.size(); i++) { + Hook currentHook = reportingHooks.get(i); try { Map seriesData = currentHook.beforeEvaluation(seriesContext, Collections.unmodifiableMap(Collections.emptyMap())); seriesDataList.add(Collections.unmodifiableMap(seriesData)); @@ -86,8 +161,8 @@ public EvaluationDetail withEvaluation(String method, String key, LDCon EvaluationDetail result = evalMethod.evaluate(); // Invoke hooks in reverse order and give them back the series data they gave us. - for (int i = hooks.size() - 1; i >= 0; i--) { - Hook currentHook = hooks.get(i); + for (int i = reportingHooks.size() - 1; i >= 0; i--) { + Hook currentHook = reportingHooks.get(i); try { currentHook.afterEvaluation(seriesContext, seriesDataList.get(i), result); } catch (Exception e) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index 7d0cb44a..8adf31dd 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -15,6 +15,7 @@ import com.launchdarkly.sdk.android.env.EnvironmentReporterBuilder; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; import com.launchdarkly.sdk.android.integrations.Plugin; @@ -73,7 +74,6 @@ public class LDClient implements LDClientInterface, Closeable { private final ConnectivityManager connectivityManager; private final LDLogger logger; private final HookRunner hookRunner; - private final EvaluationExposureDeduper evaluationExposureDeduper; private List plugins; // If 15 seconds or more is passed as a timeout to init, we will log a warning. private static final int EXCESSIVE_INIT_WAIT_SECONDS = 15; @@ -440,12 +440,8 @@ protected LDClient( environmentStore ); - evaluationExposureDeduper = new EvaluationExposureDeduper( - config.getEvaluationExposureDedupeWindowMillis(), - config.getEvaluationExposureDedupeMaxSize() - ); - - hookRunner = new HookRunner(logger, config.hooks.getHooks(), this::shouldReportExposureToHooks); + hookRunner = new HookRunner(logger, config.hooks.getHooks(), + this::defaultEvaluationExposureDeduper, this::exposureKey); } @Override @@ -505,10 +501,10 @@ private void identifyInternal(@NonNull LDContext context, clientContextImpl = clientContextImpl.setEvaluationContext(context); - // Exposures recorded before this point describe an earlier point in the app's lifecycle, so + // Exposures observed before this point describe an earlier point in the app's lifecycle, so // let them be reported again. This happens even when the context is unchanged, so that // identify is a reliable way for an app to mark a new phase of a session. - evaluationExposureDeduper.reset(); + hookRunner.resetEvaluationExposureDedupers(); // Load cached flags for the new context so they're available in case initialization // times out or otherwise fails. This does not short-circuit initialization — the data @@ -760,35 +756,36 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ } /** - * Returns whether this evaluation's exposure should be reported to the registered hooks, and if - * so starts a new dedupe window for it. + * The deduper for a hook that does not carry its own, built from the window and cache size + * configured on {@link LDConfig}. Hooks get separate instances, because a deduper starts a + * window as soon as it reports an evaluation. + */ + private EvaluationExposureDeduper defaultEvaluationExposureDeduper() { + int windowMillis = config.getEvaluationExposureDedupeWindowMillis(); + return windowMillis > 0 + ? new EvaluationExposureDeduper(windowMillis, config.getEvaluationExposureDedupeMaxSize()) + : EvaluationExposureDeduper.disabled(); + } + + /** + * Identifies the evaluation a hook is about to be told about, so that a deduper can recognize a + * repeat of it. *

- * The decision is made before the series opens rather than after the evaluation completes, - * because hooks pair their stages: the observability plugin starts a span in - * {@code beforeEvaluation} and ends it in {@code afterEvaluation}, so suppressing only the after - * stage would leave that span open until something else closed it. Reading the stored flag here + * This reads the stored flag rather than the evaluation result because the decision is made + * before the series opens: hooks pair their stages, so the observability plugin starts a span in + * {@code beforeEvaluation} and ends it in {@code afterEvaluation}, and suppressing only the + * after stage would leave that span open until something else closed it. The stored flag * identifies the same exposure the result would, since the result is derived from it. */ - private boolean shouldReportExposureToHooks(String flagKey, LDContext context) { - if (!evaluationExposureDeduper.isEnabled()) { - // Looking up the flag and building the key both cost more than the check they feed, so - // they are skipped entirely while deduplication is off, which is the default. - return true; - } - + private String exposureKey(String flagKey, LDContext context) { Flag flag = contextDataManager.getNonDeletedFlag(flagKey); int variation = flag == null || flag.getVariation() == null ? EvaluationDetail.NO_VARIATION : flag.getVariation(); int flagVersion = flag == null ? EventProcessor.NO_VERSION : flag.getVersionForEvents(); boolean inExperiment = flag != null && flag.getReason() != null && flag.getReason().isInExperiment(); - String dedupeKey = EvaluationExposureDeduper.exposureKey(flagKey, variation, flagVersion, - inExperiment, context.getFullyQualifiedKey()); - if (!evaluationExposureDeduper.shouldRecord(dedupeKey, System.currentTimeMillis())) { - logger.debug("Deduplicated exposure for flagKey: {}", flagKey); - return false; - } - return true; + return EvaluationExposureKey.of(flagKey, variation, flagVersion, inExperiment, + context.getFullyQualifiedKey()); } /** diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java index 01e48a71..add0767c 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java @@ -673,13 +673,18 @@ public Builder maxCachedContexts(int maxCachedContexts) { * read on every redraw of a view. *

* Deduplication applies to the whole evaluation series, so a suppressed evaluation invokes - * neither {@code beforeEvaluation} nor {@code afterEvaluation} on any registered hook. This - * affects every hook, including your own, not only those added by plugins. Analytics events are - * unaffected: feature, debug, and summary events are still recorded for every evaluation, so the - * evaluation counts LaunchDarkly reports for your flags do not change. + * neither {@code beforeEvaluation} nor {@code afterEvaluation} on that hook. Analytics events + * are unaffected: feature, debug, and summary events are still recorded for every evaluation, + * so the evaluation counts LaunchDarkly reports for your flags do not change. *

- * The cache of recorded exposures is cleared by {@link LDClient#identify(LDContext)}, so the - * first evaluation after an identify is always reported. + * This is the default for hooks that do not carry a policy of their own. Each hook is + * deduplicated separately, and a hook can override this with + * {@link com.launchdarkly.sdk.android.integrations.Hook#evaluationExposureDeduper(com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper)}, + * for example to observe every evaluation while other hooks are deduplicated. + *

+ * Every hook's record of what it has observed is cleared by + * {@link LDClient#identify(LDContext)}, so the first evaluation after an identify is always + * reported. *

* If not specified, the default is {@link #DEFAULT_EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS} (0), * which disables deduplication so that every evaluation is reported. @@ -699,7 +704,8 @@ public Builder evaluationExposureDedupeWindowMillis(int evaluationExposureDedupe * once. *

* When the limit is exceeded, the least recently recorded keys are evicted to bound memory - * usage. This only matters when {@link #evaluationExposureDedupeWindowMillis(int)} is enabled. + * usage. This only matters when {@link #evaluationExposureDedupeWindowMillis(int)} is enabled, + * and applies to hooks that do not carry a policy of their own. *

* If not specified, the default is {@link #DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE} (2000). * diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java new file mode 100644 index 00000000..359d2fcc --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -0,0 +1,153 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.android.LDConfig; + +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Decides whether a hook should be told about an evaluation, so that repeated evaluations resolving + * to the same result do not invoke the hook again within a time window. + *

+ * The SDK gives each registered hook its own deduper. Give one to a hook with + * {@link Hook#evaluationExposureDeduper(EvaluationExposureDeduper)} to control that hook's behavior; + * a hook carrying none gets a deduper built from + * {@link LDConfig.Builder#evaluationExposureDedupeWindowMillis(int)} and + * {@link LDConfig.Builder#evaluationExposureDedupeMaxSize(int)}. + * + *


+ *     Components.hooks()
+ *         .addHook(new MetricsHook())                                // SDK defaults
+ *         .addHook(new AuditHook().evaluationExposureDeduper(EvaluationExposureDeduper.disabled()))
+ *         .addHook(new ObservabilityHook().evaluationExposureDeduper(30_000, 5_000))
+ *         .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper))
+ * 
+ *

+ * This class is the SDK's implementation: it records each unique exposure key once per window and + * bounds the number of tracked keys, evicting the least recently recorded ones when the cap is + * exceeded. Subclass it to implement a different policy; only {@link #shouldRecord(String, long)} + * and {@link #reset()} are called by the SDK. + *

+ * A deduper is consulted once per evaluation, before the series opens, so a suppressed evaluation + * invokes neither {@code beforeEvaluation} nor {@code afterEvaluation}. Implementations must be + * thread-safe, because evaluations may be made from any thread. Give each hook its own instance + * unless you intend hooks to share a window: the first hook to be told about an exposure starts the + * window that suppresses the rest. + */ +public class EvaluationExposureDeduper { + private static final EvaluationExposureDeduper DISABLED = new Disabled(); + + private final long windowMillis; + private final int maxSize; + + // Insertion-ordered so that iteration visits the least recently recorded key first. Guarded by + // the instance lock, as is every access below. + private final LinkedHashMap lastRecordedAt = new LinkedHashMap<>(); + + /** + * @param windowMillis the dedupe window in milliseconds; zero or negative disables + * deduplication, so every evaluation reaches the hook + * @param maxSize the maximum number of exposure keys to track; zero or negative falls back to + * {@link LDConfig#DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE} + */ + public EvaluationExposureDeduper(int windowMillis, int maxSize) { + this.windowMillis = windowMillis; + this.maxSize = maxSize > 0 ? maxSize : LDConfig.DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE; + } + + /** + * Returns a deduper that suppresses nothing, so its hook is told about every evaluation + * regardless of the SDK's configured window. + *

+ * The returned instance holds no state and may be given to any number of hooks. + * + * @return a deduper that never suppresses an evaluation + */ + public static EvaluationExposureDeduper disabled() { + return DISABLED; + } + + /** + * Returns whether the hook should be told about the evaluation identified by the given key, and + * if so starts a new dedupe window for it. + *

+ * The SDK calls this once per evaluation per hook. The key identifies the evaluation result: two + * evaluations share a key when they resolve to the same variation of the same flag version, with + * the same experiment status, for the same context. + * + * @param key a stable key identifying the evaluation result + * @param nowMillis the current time in milliseconds since the epoch + * @return true if the hook should observe this evaluation, false if it should be suppressed + */ + public synchronized boolean shouldRecord(String key, long nowMillis) { + if (windowMillis <= 0) { + return true; + } + + Long last = lastRecordedAt.get(key); + if (last != null && last > nowMillis - windowMillis) { + return false; + } + + // Remove before putting so the key moves to the most recent end of the iteration order. + lastRecordedAt.remove(key); + lastRecordedAt.put(key, nowMillis); + + if (lastRecordedAt.size() > maxSize) { + evict(nowMillis); + } + return true; + } + + /** + * Clears all recorded exposures, so the next evaluation of each is reported again. The SDK calls + * this when the evaluation context changes. + */ + public synchronized void reset() { + lastRecordedAt.clear(); + } + + private void evict(long nowMillis) { + // Keys whose window has already elapsed no longer change the outcome of shouldRecord, so + // reclaim those first. They sort before any live key, so this stops at the first live one. + long cutoff = nowMillis - windowMillis; + for (Iterator> it = lastRecordedAt.entrySet().iterator(); it.hasNext(); ) { + if (it.next().getValue() > cutoff) { + break; + } + it.remove(); + } + + if (lastRecordedAt.size() <= maxSize) { + // Reclaiming expired keys was enough. Dropping live keys past this point would report + // their next identical evaluation again. + return; + } + + // Evict a batch rather than a single key, so that a workload tracking more live keys than + // maxSize doesn't pay for an eviction on every subsequent exposure. + int dropCount = lastRecordedAt.size() - maxSize + maxSize / 4; + for (Iterator> it = lastRecordedAt.entrySet().iterator(); + dropCount > 0 && it.hasNext(); + dropCount--) { + it.next(); + it.remove(); + } + } + + private static final class Disabled extends EvaluationExposureDeduper { + Disabled() { + super(0, 0); + } + + @Override + public boolean shouldRecord(String key, long nowMillis) { + return true; + } + + @Override + public void reset() { + } + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java index a9b88747..de6a168f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java @@ -17,6 +17,8 @@ public abstract class Hook { private final HookMetadata metadata; + private EvaluationExposureDeduper evaluationExposureDeduper; + /** * @return the hooks metadata */ @@ -33,6 +35,59 @@ public Hook(String name) { metadata = new HookMetadata(name) {}; } + /** + * Deduplicates this hook's evaluation series with the SDK's implementation, using the given + * parameters instead of the ones configured on {@link com.launchdarkly.sdk.android.LDConfig}. + * + *


+     *     Components.hooks()
+     *         .addHook(new ObservabilityHook().evaluationExposureDeduper(60_000, 2_000))
+     * 
+ * + * @param windowMillis the dedupe window in milliseconds; zero or negative reports every + * evaluation + * @param maxSize the maximum number of exposure keys to track; zero or negative uses + * {@link com.launchdarkly.sdk.android.LDConfig#DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE} + * @return this hook + */ + public Hook evaluationExposureDeduper(int windowMillis, int maxSize) { + return evaluationExposureDeduper(new EvaluationExposureDeduper(windowMillis, maxSize)); + } + + /** + * Sets which evaluations reach this hook, overriding the deduplication configured on + * {@link com.launchdarkly.sdk.android.LDConfig}. It affects only this hook. + *

+ * Pass {@link EvaluationExposureDeduper#disabled()} to observe every evaluation, or your own + * subclass of {@link EvaluationExposureDeduper} to implement a different policy. + * + *


+     *     Components.hooks()
+     *         .addHook(new AuditHook().evaluationExposureDeduper(EvaluationExposureDeduper.disabled()))
+     *         .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper))
+     * 
+ *

+ * The SDK reads this once, when the hook is registered, so call it before passing the hook to + * the SDK. Give each hook its own deduper unless you intend hooks to share a window: the first + * hook to observe an evaluation starts the window that suppresses the rest. + * + * @param evaluationExposureDeduper the deduper for this hook, or null to use the SDK's + * configured defaults + * @return this hook + */ + public Hook evaluationExposureDeduper(EvaluationExposureDeduper evaluationExposureDeduper) { + this.evaluationExposureDeduper = evaluationExposureDeduper; + return this; + } + + /** + * @return the deduper deciding which evaluations reach this hook, or null if it uses the + * deduplication configured on {@link com.launchdarkly.sdk.android.LDConfig} + */ + public final EvaluationExposureDeduper getEvaluationExposureDeduper() { + return evaluationExposureDeduper; + } + /** * {@link #beforeEvaluation(EvaluationSeriesContext, Map)} is executed by the SDK at the start of the evaluation of * a feature flag. It will not be executed as part of a call to diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java index d951f770..9e74561a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java @@ -23,6 +23,18 @@ * .build(); * *

+ * A hook can carry its own exposure deduplication policy, which controls how often repeated + * evaluations resolving to the same result reach it. See + * {@link Hook#evaluationExposureDeduper(EvaluationExposureDeduper)}. + * + *


+ *     Components.hooks()
+ *         .addHook(new MetricsHook())
+ *         .addHook(new AuditHook().evaluationExposureDeduper(EvaluationExposureDeduper.disabled()))
+ *         .addHook(new ObservabilityHook().evaluationExposureDeduper(60_000, 2_000))
+ *         .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper))
+ * 
+ *

* Note that this class is abstract; the actual implementation is created by calling {@link Components#hooks()}. */ public abstract class HooksConfigurationBuilder { @@ -45,8 +57,21 @@ public HooksConfigurationBuilder setHooks(List hooks) { return this; } + /** + * Adds a hook to the configuration. Note that the order of hooks is important and controls the order in which + * they will be executed. See {@link Hook} for more details. + * + * @param hook to be added to the configuration + * @return the builder + */ + public HooksConfigurationBuilder addHook(Hook hook) { + List hooks = new ArrayList<>(this.hooks); + hooks.add(hook); + return setHooks(hooks); + } + /** * @return the hooks configuration */ abstract public HookConfiguration build(); -} \ No newline at end of file +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java index 39968d71..10e5f26a 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java @@ -3,31 +3,44 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; + import org.junit.Test; public class EvaluationExposureDeduperTest { @Test - public void disabledForNonPositiveWindow() { + public void recordsEverythingForNonPositiveWindow() { for (int window : new int[] { 0, -1 }) { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(window, 10); - assertFalse(deduper.isEnabled()); assertTrue(deduper.shouldRecord("a", 0)); assertTrue(deduper.shouldRecord("a", 0)); } } + @Test + public void disabledRecordsEverythingAndIsShared() { + EvaluationExposureDeduper deduper = EvaluationExposureDeduper.disabled(); + assertTrue(deduper.shouldRecord("a", 1000)); + assertTrue(deduper.shouldRecord("a", 1000)); + deduper.reset(); + assertTrue(deduper.shouldRecord("a", 1000)); + // The runner recognizes it by identity to skip building exposure keys altogether. + assertSame(deduper, EvaluationExposureDeduper.disabled()); + } + @Test public void exposureKeyDistinguishesEveryComponent() { - String key = EvaluationExposureDeduper.exposureKey("flag", 1, 2, false, "user-key"); - assertEquals(key, EvaluationExposureDeduper.exposureKey("flag", 1, 2, false, "user-key")); - assertNotEquals(key, EvaluationExposureDeduper.exposureKey("other-flag", 1, 2, false, "user-key")); - assertNotEquals(key, EvaluationExposureDeduper.exposureKey("flag", 3, 2, false, "user-key")); - assertNotEquals(key, EvaluationExposureDeduper.exposureKey("flag", 1, 4, false, "user-key")); - assertNotEquals(key, EvaluationExposureDeduper.exposureKey("flag", 1, 2, false, "other-user-key")); + String key = EvaluationExposureKey.of("flag", 1, 2, false, "user-key"); + assertEquals(key, EvaluationExposureKey.of("flag", 1, 2, false, "user-key")); + assertNotEquals(key, EvaluationExposureKey.of("other-flag", 1, 2, false, "user-key")); + assertNotEquals(key, EvaluationExposureKey.of("flag", 3, 2, false, "user-key")); + assertNotEquals(key, EvaluationExposureKey.of("flag", 1, 4, false, "user-key")); + assertNotEquals(key, EvaluationExposureKey.of("flag", 1, 2, false, "other-user-key")); // Moving into an experiment on the same variation of the same flag version reports again. - assertNotEquals(key, EvaluationExposureDeduper.exposureKey("flag", 1, 2, true, "user-key")); + assertNotEquals(key, EvaluationExposureKey.of("flag", 1, 2, true, "user-key")); } @Test diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java index c3eb573b..5d607998 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java @@ -10,6 +10,7 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.HookMetadata; @@ -68,53 +69,143 @@ public void executesHooksAndReturnsEvaluationResult() { logging.assertNothingLogged(); } + /** + * Records the evaluation stages it observes, so a test can tell a suppressed evaluation (no + * stages) from a reported one. + */ + private static class RecordingHook extends Hook { + final List stages = new ArrayList<>(); + + RecordingHook(String name) { + super(name); + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + stages.add("before"); + return seriesData; + } + + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, + EvaluationDetail evaluationDetail) { + stages.add("after"); + return seriesData; + } + } + + private void evaluate(HookRunner runner) { + runner.withEvaluation("testMethod", "test-flag", LDContext.create("user-123"), LDValue.of(false), + () -> EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off())); + } + @Test - public void skipsBothStagesWhenTheExposureFilterSuppressesTheEvaluation() { - String key = "test-flag"; - LDContext context = LDContext.create("user-123"); - LDValue defaultValue = LDValue.of(false); - EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); + public void hookDeduperSkipsBothStagesOfARepeatedEvaluation() { + RecordingHook hook = new RecordingHook("deduping"); + hook.evaluationExposureDeduper(60_000, 10); + HookRunner runner = new HookRunner(logging.logger, List.of(hook), + EvaluationExposureDeduper::disabled, (flagKey, context) -> "exposure-key"); - List filtered = new ArrayList<>(); - HookRunner suppressing = new HookRunner(logging.logger, List.of(testHook), (flagKey, evalContext) -> { - filtered.add(flagKey); - return false; - }); + evaluate(runner); + evaluate(runner); - // No hook stage is expected: a suppressed evaluation must not leave a beforeEvaluation - // unmatched by its afterEvaluation. - replayAll(); + // A suppressed evaluation must not leave a beforeEvaluation unmatched by its afterEvaluation. + assertEquals(List.of("before", "after"), hook.stages); + } - EvaluationDetail result = suppressing.withEvaluation("testMethod", key, context, defaultValue, () -> evaluationResult); + @Test + public void hooksAreDeduplicatedIndependentlyOfEachOther() { + RecordingHook deduping = new RecordingHook("deduping"); + deduping.evaluationExposureDeduper(60_000, 10); + RecordingHook reportingEverything = new RecordingHook("reporting-everything"); + reportingEverything.evaluationExposureDeduper(EvaluationExposureDeduper.disabled()); + HookRunner runner = new HookRunner(logging.logger, List.of(deduping, reportingEverything), + EvaluationExposureDeduper::disabled, (flagKey, context) -> "exposure-key"); + + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("before", "after"), deduping.stages); + assertEquals(List.of("before", "after", "before", "after"), reportingEverything.stages); + } - verifyAll(); - assertSame(evaluationResult, result); - assertEquals(List.of(key), filtered); - logging.assertNothingLogged(); + @Test + public void hooksWithoutADeduperEachGetTheirOwnFromTheDefaultFactory() { + RecordingHook first = new RecordingHook("first"); + RecordingHook second = new RecordingHook("second"); + HookRunner runner = new HookRunner(logging.logger, List.of(first, second), + () -> new EvaluationExposureDeduper(60_000, 10), (flagKey, context) -> "exposure-key"); + + evaluate(runner); + evaluate(runner); + + // Sharing one deduper would have let the first hook's report suppress the second hook's. + assertEquals(List.of("before", "after"), first.stages); + assertEquals(List.of("before", "after"), second.stages); } @Test - public void consultsTheExposureFilterOncePerEvaluation() { - LDContext context = LDContext.create("user-123"); - LDValue defaultValue = LDValue.of(false); - EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); - EvaluationSeriesContext seriesContext = new EvaluationSeriesContext("testMethod", "test-flag", context, defaultValue); + public void resettingDedupersReportsTheSameEvaluationAgain() { + RecordingHook hook = new RecordingHook("deduping"); + hook.evaluationExposureDeduper(60_000, 10); + HookRunner runner = new HookRunner(logging.logger, List.of(hook), + EvaluationExposureDeduper::disabled, (flagKey, context) -> "exposure-key"); - List filtered = new ArrayList<>(); - HookRunner allowing = new HookRunner(logging.logger, List.of(testHook), (flagKey, evalContext) -> { - filtered.add(flagKey); - return true; - }); + evaluate(runner); + runner.resetEvaluationExposureDedupers(); + evaluate(runner); - expect(testHook.beforeEvaluation(seriesContext, Collections.emptyMap())).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); - expect(testHook.afterEvaluation(seriesContext, Collections.emptyMap(), evaluationResult)).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); - replayAll(); + assertEquals(List.of("before", "after", "before", "after"), hook.stages); + } - allowing.withEvaluation("testMethod", "test-flag", context, defaultValue, () -> evaluationResult); + @Test + public void buildsTheExposureKeyOncePerEvaluationRegardlessOfHookCount() { + RecordingHook first = new RecordingHook("first"); + first.evaluationExposureDeduper(60_000, 10); + RecordingHook second = new RecordingHook("second"); + second.evaluationExposureDeduper(60_000, 10); + List keyRequests = new ArrayList<>(); + HookRunner runner = new HookRunner(logging.logger, List.of(first, second), + EvaluationExposureDeduper::disabled, (flagKey, context) -> { + keyRequests.add(flagKey); + return "exposure-key"; + }); + + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("test-flag", "test-flag"), keyRequests); + } - verifyAll(); - assertEquals(List.of("test-flag"), filtered); - logging.assertNothingLogged(); + @Test + public void doesNotBuildTheExposureKeyWhenNoHookCanSuppress() { + RecordingHook hook = new RecordingHook("reporting-everything"); + hook.evaluationExposureDeduper(EvaluationExposureDeduper.disabled()); + List keyRequests = new ArrayList<>(); + HookRunner runner = new HookRunner(logging.logger, List.of(hook), + EvaluationExposureDeduper::disabled, (flagKey, context) -> { + keyRequests.add(flagKey); + return "exposure-key"; + }); + + evaluate(runner); + + assertEquals(List.of(), keyRequests); + assertEquals(List.of("before", "after"), hook.stages); + } + + @Test + public void hookAddedLaterCarriesItsOwnDeduper() { + RecordingHook added = new RecordingHook("added"); + added.evaluationExposureDeduper(60_000, 10); + HookRunner runner = new HookRunner(logging.logger, List.of(), + EvaluationExposureDeduper::disabled, (flagKey, context) -> "exposure-key"); + runner.addHook(added); + + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("before", "after"), added.stages); } @Test From bdb7632519475443f98996157e38afd45136b792 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 5 Aug 2026 22:24:33 -0700 Subject: [PATCH 07/29] docs: run the example app against a real environment The mobile key was hardcoded as a placeholder, so the app could not talk to LaunchDarkly without editing tracked source. It now reads the key and a production/staging switch from local.properties, which git ignores. The app registers a hook with a dedupe window and shows how many evaluations it requested against how many reached the hook, so the deduplication can be observed on device. Co-authored-by: Cursor --- example/README.md | 49 +++++++++ example/build.gradle | 19 ++++ .../launchdarkly/example/MainActivity.java | 103 +++++++++++++++++- example/src/main/res/layout/activity_main.xml | 14 ++- 4 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 example/README.md diff --git a/example/README.md b/example/README.md new file mode 100644 index 00000000..5ccdbd32 --- /dev/null +++ b/example/README.md @@ -0,0 +1,49 @@ +# LaunchDarkly Android SDK example app + +## Configuration + +The app reads its settings from `local.properties` in the repository root, which is not checked in. +Create it if it does not exist and add: + +```properties +launchdarkly.mobileKey=your-mobile-key +launchdarkly.environment=production +``` + +| Property | Default | Description | +| --- | --- | --- | +| `launchdarkly.mobileKey` | none | Mobile key for the environment to connect to. The app refuses to initialize without it. | +| `launchdarkly.environment` | `production` | `staging` points the streaming, polling, and events endpoints at the `ld-stg.launchdarkly.com` hosts. Any other value uses the SDK defaults. | + +These become `BuildConfig` fields, so change them and rebuild for them to take effect. The mobile key +has to match the environment: a production key against staging fails to authorize. + +## Verifying evaluation exposure deduplication + +The app registers a hook that counts evaluation series stages and displays the totals below the +evaluation result: + +``` +Environment: production +Evaluation Exposure dedupe window: 60000 ms +Evaluations requested: 4 +Reported to hooks: 1 (before 1 / after 1) +``` + +The window is the `EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS` constant in `MainActivity`, which the +app gives to that one hook: + +```java +Components.hooks().addHook(exposureHook.evaluationExposureDeduper(60_000, 2_000)) +``` + +Set it to `0` to turn deduplication off for the hook. Each hook is deduplicated on its own, so a +second hook registered with `EvaluationExposureDeduper.disabled()` would keep seeing every +evaluation. A hook registered without a deduper falls back to +`LDConfig.Builder.evaluationExposureDedupeWindowMillis`. + +Enter a flag key, then tap **Evaluate Flag** repeatedly. "Evaluations requested" climbs with every +tap while "Reported to hooks" stays put, because repeated evaluations resolving to the same result +within the window are suppressed for the whole series. Tapping **Identify** clears the dedupe cache, +so the next evaluation is reported again. Analytics events are not deduplicated: every evaluation is +still counted by LaunchDarkly. diff --git a/example/build.gradle b/example/build.gradle index 797ab8d8..f5286b24 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -1,9 +1,19 @@ +import java.util.Properties + plugins { id("com.android.application") // make sure this line comes *after* you apply the Android plugin id("com.getkeepsafe.dexcount") } +// local.properties is not checked in, so it is where machine-specific settings such as your mobile +// key belong. See example/README.md for the keys this app reads. +def localProperties = new Properties() +def localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) { + localPropertiesFile.withInputStream { localProperties.load(it) } +} + android { namespace "com.launchdarkly.example" compileSdk = 34 @@ -14,6 +24,15 @@ android { targetSdk = 34 versionCode = 1 versionName = "1.0" + + buildConfigField("String", "MOBILE_KEY", + "\"${localProperties.getProperty('launchdarkly.mobileKey', '')}\"") + buildConfigField("String", "LD_ENVIRONMENT", + "\"${localProperties.getProperty('launchdarkly.environment', 'production')}\"") + } + + buildFeatures { + buildConfig = true } buildTypes { diff --git a/example/src/main/java/com/launchdarkly/example/MainActivity.java b/example/src/main/java/com/launchdarkly/example/MainActivity.java index 684f85b3..3f8f588c 100644 --- a/example/src/main/java/com/launchdarkly/example/MainActivity.java +++ b/example/src/main/java/com/launchdarkly/example/MainActivity.java @@ -13,7 +13,9 @@ import androidx.appcompat.app.AppCompatActivity; +import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.android.Components; import com.launchdarkly.sdk.android.ConnectionInformation; import com.launchdarkly.sdk.android.LDAllFlagsListener; @@ -22,22 +24,89 @@ import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; import com.launchdarkly.sdk.android.LDFailure; import com.launchdarkly.sdk.android.LDStatusListener; +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.android.integrations.Hook; import java.util.Date; import java.util.Locale; +import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; import timber.log.Timber; public class MainActivity extends AppCompatActivity { + private static final int EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS = 5_000; + + // The staging hosts mirror the production ones in StandardEndpoints under the ld-stg domain. + private static final String STAGING_DOMAIN = "ld-stg.launchdarkly.com"; + private LDClient ldClient; private LDStatusListener ldStatusListener; private LDAllFlagsListener allFlagsListener; + private final ExposureCountingHook exposureHook = new ExposureCountingHook(); + private final AtomicInteger evaluationsRequested = new AtomicInteger(); + + /** + * Counts the evaluation hook stages so the example can show what exposure deduplication does. + * Deduplication skips the whole series, so both counts stay equal and both stop climbing while + * repeated evaluations resolve to the same result. + */ + private class ExposureCountingHook extends Hook { + final AtomicInteger befores = new AtomicInteger(); + final AtomicInteger afters = new AtomicInteger(); + + ExposureCountingHook() { + super("exposure-counting-hook"); + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + befores.incrementAndGet(); + updateDedupeStatus(); + return seriesData; + } + + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, EvaluationDetail evaluationDetail) { + afters.incrementAndGet(); + updateDedupeStatus(); + return seriesData; + } + } + + private static boolean isStaging() { + return "staging".equalsIgnoreCase(BuildConfig.LD_ENVIRONMENT); + } + + private void updateDedupeStatus() { + if (Looper.myLooper() != MainActivity.this.getMainLooper()) { + new Handler(MainActivity.this.getMainLooper()).post(this::updateDedupeStatus); + return; + } + + int requested = evaluationsRequested.get(); + int reported = exposureHook.afters.get(); + String window = EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS > 0 + ? EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS + " ms" + : "disabled"; + + String result = String.format(Locale.US, + "Environment: %s\nEvaluation Exposure dedupe window: %s\nEvaluations requested: %d\nReported to hooks: %d (before %d / after %d)", + isStaging() ? "staging" : "production", + window, + requested, + reported, + exposureHook.befores.get(), + reported); + ((TextView) MainActivity.this.findViewById(R.id.dedupe_status)).setText(result); + } + private void updateStatusString(final ConnectionInformation connectionInformation) { if (Looper.myLooper() != MainActivity.this.getMainLooper()) { new Handler(MainActivity.this.getMainLooper()).post(() -> updateStatusString(connectionInformation)); @@ -68,14 +137,36 @@ public void onCreate(Bundle savedInstanceState) { setupIdentifyButton(); setupOfflineSwitch(); setupListeners(); + updateDedupeStatus(); - LDConfig ldConfig = new LDConfig.Builder(AutoEnvAttributes.Enabled) - .mobileKey("MOBILE_KEY") + if (BuildConfig.MOBILE_KEY.isEmpty()) { + String message = "Set launchdarkly.mobileKey in local.properties and rebuild."; + Timber.e(message); + Toast.makeText(this, message, Toast.LENGTH_LONG).show(); + return; + } + + LDConfig.Builder configBuilder = new LDConfig.Builder(AutoEnvAttributes.Enabled) + .mobileKey(BuildConfig.MOBILE_KEY) .http( Components.httpConfiguration().useReport(false) // change useReport to `true` if the request is to be REPORT'ed instead of GET'ed ) - .build(); + .hooks( + Components.hooks().addHook(exposureHook.evaluationExposureDeduper( + EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS, 2_000)) + ); + + if (isStaging()) { + configBuilder.serviceEndpoints( + Components.serviceEndpoints() + .streaming("https://clientstream." + STAGING_DOMAIN) + .polling("https://clientsdk." + STAGING_DOMAIN) + .events("https://mobile." + STAGING_DOMAIN) + ); + } + + LDConfig ldConfig = configBuilder.build(); LDContext context = LDContext.builder("user key") .set("email", "fake@example.com") @@ -161,6 +252,7 @@ private void setupIdentifyButton() { String userKey = ((EditText) MainActivity.this.findViewById(R.id.userKey_editText)).getText().toString(); final LDContext updatedContext = LDContext.create(userKey); MainActivity.this.doSafeClientAction(() -> ldClient.identify(updatedContext)); + MainActivity.this.updateDedupeStatus(); }); } @@ -182,6 +274,7 @@ private void setupEval() { evalButton.setOnClickListener(v -> { Timber.i("eval onClick"); final String flagKey = ((EditText) MainActivity.this.findViewById(R.id.feature_flag_key)).getText().toString(); + evaluationsRequested.incrementAndGet(); String type = spinner.getSelectedItem().toString(); final String result; @@ -194,10 +287,13 @@ private void setupEval() { ((TextView) MainActivity.this.findViewById(R.id.result_textView)).setText(result); MainActivity.this.doSafeClientAction(() -> { ldClient.registerFeatureFlagListener(flagKey, flagKey1 -> { + evaluationsRequested.incrementAndGet(); ((TextView) MainActivity.this.findViewById(R.id.result_textView)) .setText(ldClient.stringVariation(flagKey1, "default")); + MainActivity.this.updateDedupeStatus(); }); }); + MainActivity.this.updateDedupeStatus(); return; case "Boolean": result = MainActivity.this.doSafeClientGet(() -> String.valueOf(ldClient.boolVariation(flagKey, false))); @@ -219,6 +315,7 @@ private void setupEval() { logResult = result == null ? "no result" : result; Timber.i(logResult); ((TextView) MainActivity.this.findViewById(R.id.result_textView)).setText(result); + MainActivity.this.updateDedupeStatus(); }); } diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml index 168abad2..8657d607 100644 --- a/example/src/main/res/layout/activity_main.xml +++ b/example/src/main/res/layout/activity_main.xml @@ -84,13 +84,25 @@ android:id="@+id/result_textView" android:layout_width="wrap_content" android:layout_height="0dp" - android:layout_above="@+id/connection_status" + android:layout_above="@+id/dedupe_status" android:layout_below="@+id/space" android:layout_alignParentStart="true" android:layout_alignParentLeft="true" android:layout_alignParentEnd="true" android:layout_alignParentRight="true" /> + + Date: Thu, 6 Aug 2026 14:28:07 -0700 Subject: [PATCH 08/29] refactor: make evaluation exposure dedupe opt-in per hook The LDConfig options gave the SDK a global dedupe policy that every hook inherited unless it overrode it, which meant registering any hook opted it into suppression decided somewhere else in the config. Deduplication is a property of what a hook does with an evaluation, so let the hook be the only place that decides: a hook observes every evaluation until it carries a deduper of its own. Removes evaluationExposureDedupeWindowMillis and evaluationExposureDedupeMaxSize along with their getters and the two public default constants. The cache cap moves to EvaluationExposureDeduper.DEFAULT_MAX_SIZE, which also drops the deduper's dependency on LDConfig, and HookRunner no longer needs a factory to build dedupers for hooks that did not bring one. EvaluationExposureDeduper.disabled() now behaves the same as carrying no deduper. It stays because passing it states the intent explicitly, and because HookRunner recognizes it by identity to skip building exposure keys. Co-authored-by: Cursor --- .../sdk/android/LDClientEventTest.java | 9 +- .../sdk/android/LDClientHooksTest.java | 54 ++++------- .../launchdarkly/sdk/android/HookRunner.java | 19 +--- .../launchdarkly/sdk/android/LDClient.java | 16 +--- .../launchdarkly/sdk/android/LDConfig.java | 92 ------------------- .../EvaluationExposureDeduper.java | 28 +++--- .../sdk/android/integrations/Hook.java | 36 +++++--- .../HooksConfigurationBuilder.java | 7 +- .../EvaluationExposureDeduperTest.java | 4 +- .../sdk/android/HookRunnerTest.java | 49 ++++++++-- .../sdk/android/LDConfigTest.java | 16 ---- 11 files changed, 110 insertions(+), 220 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java index 90098f09..38b259b7 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java @@ -16,6 +16,7 @@ import com.launchdarkly.sdk.ObjectBuilder; import com.launchdarkly.sdk.android.DataModel.Flag; import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; +import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.subsystems.PersistentDataStore; import com.launchdarkly.sdk.internal.GsonHelpers; import com.launchdarkly.sdk.json.JsonSerialization; @@ -249,11 +250,13 @@ public void exposureDeduplicationDoesNotSuppressEvaluationEvents() throws IOExce .variation(1).value(LDValue.of(true)).trackEvents(true).build(); PersistentDataStore store = new InMemoryPersistentDataStore(); TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flag); - // Deduplication applies to hooks only, so a window wide enough to suppress every repeat - // must still leave the analytics events untouched. + // Deduplication applies to hooks only, so a hook given a window wide enough to suppress + // every repeat must still leave the analytics events untouched. + Hook dedupingHook = new Hook("deduping-hook") {}; + dedupingHook.evaluationExposureDeduper(60_000, 100); LDConfig ldConfig = baseConfigBuilder(mockEventsServer) .persistentDataStore(store) - .evaluationExposureDedupeWindowMillis(60_000) + .hooks(Components.hooks().addHook(dedupingHook)) .build(); try (LDClient client = LDClient.init(application, ldConfig, ldContext, 0)) { diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java index 1f713604..7f6c5555 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java @@ -180,23 +180,22 @@ public void executesBothInitialHooksAndHooksAddedWithAddHooks() throws Exception } @Test - public void repeatedEvaluationsReachHooksWhenDedupeIsDisabledByDefault() throws Exception { + public void repeatedEvaluationsReachAHookThatAskedForNoDedupe() throws Exception { try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testHook)), ldContext, 1)) { for (int i = 0; i < 3; i++) { ldClient.boolVariation("test-flag", false); } + // Deduplication is opt-in per hook, and this one did not opt in. assertEquals(3, testHook.beforeEvaluationCalls.size()); assertEquals(3, testHook.afterEvaluationCalls.size()); } } @Test - public void repeatedEvaluationsAreDeduplicatedWithinTheConfiguredWindow() throws Exception { - LDConfig config = makeOfflineConfigBuilder(List.of(testHook)) - .evaluationExposureDedupeWindowMillis(60_000) - .build(); - try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { + public void repeatedEvaluationsAreDeduplicatedWithinTheHooksWindow() throws Exception { + testHook.evaluationExposureDeduper(60_000, 100); + try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testHook)), ldContext, 1)) { for (int i = 0; i < 3; i++) { ldClient.boolVariation("test-flag", false); } @@ -209,10 +208,8 @@ public void repeatedEvaluationsAreDeduplicatedWithinTheConfiguredWindow() throws @Test public void identifyResetsEvaluationExposureDedupeCache() throws Exception { - LDConfig config = makeOfflineConfigBuilder(List.of(testHook)) - .evaluationExposureDedupeWindowMillis(60_000) - .build(); - try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { + testHook.evaluationExposureDeduper(60_000, 100); + try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testHook)), ldContext, 1)) { ldClient.boolVariation("test-flag", false); ldClient.boolVariation("test-flag", false); assertEquals(1, testHook.afterEvaluationCalls.size()); @@ -228,10 +225,8 @@ public void identifyResetsEvaluationExposureDedupeCache() throws Exception { @Test public void evaluationsOfDifferentFlagsReachHooksSeparately() throws Exception { - LDConfig config = makeOfflineConfigBuilder(List.of(testHook)) - .evaluationExposureDedupeWindowMillis(60_000) - .build(); - try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { + testHook.evaluationExposureDeduper(60_000, 100); + try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testHook)), ldContext, 1)) { ldClient.boolVariation("test-flag", false); ldClient.boolVariation("other-flag", false); ldClient.boolVariation("test-flag", false); @@ -241,41 +236,26 @@ public void evaluationsOfDifferentFlagsReachHooksSeparately() throws Exception { } @Test - public void hookKeepsItsOwnDeduperInsteadOfTheConfiguredOne() throws Exception { - MockHook reportingEverything = new MockHook(); - reportingEverything.evaluationExposureDeduper(EvaluationExposureDeduper.disabled()); - LDConfig config = makeOfflineConfigBuilder(null) - .evaluationExposureDedupeWindowMillis(60_000) - .hooks(Components.hooks().addHook(testHook).addHook(reportingEverything)) - .build(); - try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { - for (int i = 0; i < 3; i++) { - ldClient.boolVariation("test-flag", false); - } - - // testHook carries no deduper, so it falls back to the configured window. - assertEquals(1, testHook.afterEvaluationCalls.size()); - assertEquals(3, reportingEverything.afterEvaluationCalls.size()); - } - } - - @Test - public void hookDeduperAppliesWithoutAnyConfiguredWindow() throws Exception { + public void hooksWithDifferentWindowsSuppressIndependently() throws Exception { MockHook deduping = new MockHook(); deduping.evaluationExposureDeduper(60_000, 100); + MockHook reportingEverything = new MockHook(); + reportingEverything.evaluationExposureDeduper(EvaluationExposureDeduper.disabled()); LDConfig config = makeOfflineConfigBuilder(null) - .hooks(Components.hooks().addHook(testHook).addHook(deduping)) + .hooks(Components.hooks().addHook(testHook).addHook(deduping).addHook(reportingEverything)) .build(); try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { for (int i = 0; i < 3; i++) { ldClient.boolVariation("test-flag", false); } - // Deduplication is off by default, so only the hook that asked for it suppresses. + // Only the hook that asked for a window suppresses. Carrying no deduper and carrying the + // disabled one behave the same way. assertEquals(3, testHook.afterEvaluationCalls.size()); assertEquals(1, deduping.afterEvaluationCalls.size()); + assertEquals(3, reportingEverything.afterEvaluationCalls.size()); - // identify clears every hook's cache, whether it came from the hook or the config. + // identify clears the cache of every hook that has one. ldClient.identify(ldContext).get(); ldClient.boolVariation("test-flag", false); assertEquals(2, deduping.afterEvaluationCalls.size()); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java index 0212c392..5b571ecb 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java @@ -36,22 +36,12 @@ public interface ExposureKeySupplier { String exposureKey(String flagKey, LDContext context); } - /** - * Creates the deduper for a hook registered without one. Each hook needs its own instance, - * because a deduper starts a window as soon as it reports an evaluation. - */ - @FunctionalInterface - public interface DefaultDeduperFactory { - EvaluationExposureDeduper create(); - } - private static final String UNKNOWN_HOOK_NAME = "unknown hook"; private final LDLogger logger; private final List hooks = new ArrayList<>(); // Parallel to hooks: the deduper deciding which evaluations reach the hook at the same index. private final List dedupers = new ArrayList<>(); - private final DefaultDeduperFactory defaultDeduperFactory; private final ExposureKeySupplier exposureKeySupplier; // False while every registered hook wants every evaluation, which is the default. Lets the @@ -59,14 +49,12 @@ public interface DefaultDeduperFactory { private volatile boolean anyDedupeActive = false; public HookRunner(LDLogger logger, List initialHooks) { - this(logger, initialHooks, EvaluationExposureDeduper::disabled, (flagKey, context) -> ""); + this(logger, initialHooks, (flagKey, context) -> ""); } public HookRunner(LDLogger logger, List initialHooks, - DefaultDeduperFactory defaultDeduperFactory, ExposureKeySupplier exposureKeySupplier) { this.logger = logger; - this.defaultDeduperFactory = defaultDeduperFactory; this.exposureKeySupplier = exposureKeySupplier; for (Hook hook : initialHooks) { addHook(hook); @@ -85,13 +73,14 @@ private String getHookName(Hook hook) { /** * Adds a hook, resolving now which evaluations will reach it: the deduper the hook carries, or - * one built from the SDK's configured defaults if it carries none. + * every evaluation if it carries none. * * @param hook the hook to add */ public void addHook(Hook hook) { EvaluationExposureDeduper declared = hook.getEvaluationExposureDeduper(); - EvaluationExposureDeduper deduper = declared == null ? defaultDeduperFactory.create() : declared; + EvaluationExposureDeduper deduper = + declared == null ? EvaluationExposureDeduper.disabled() : declared; if (deduper != EvaluationExposureDeduper.disabled()) { anyDedupeActive = true; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index 8adf31dd..a5d5cf2a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -15,7 +15,6 @@ import com.launchdarkly.sdk.android.env.EnvironmentReporterBuilder; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata; -import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; import com.launchdarkly.sdk.android.integrations.Plugin; @@ -440,8 +439,7 @@ protected LDClient( environmentStore ); - hookRunner = new HookRunner(logger, config.hooks.getHooks(), - this::defaultEvaluationExposureDeduper, this::exposureKey); + hookRunner = new HookRunner(logger, config.hooks.getHooks(), this::exposureKey); } @Override @@ -755,18 +753,6 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ return result; } - /** - * The deduper for a hook that does not carry its own, built from the window and cache size - * configured on {@link LDConfig}. Hooks get separate instances, because a deduper starts a - * window as soon as it reports an evaluation. - */ - private EvaluationExposureDeduper defaultEvaluationExposureDeduper() { - int windowMillis = config.getEvaluationExposureDedupeWindowMillis(); - return windowMillis > 0 - ? new EvaluationExposureDeduper(windowMillis, config.getEvaluationExposureDedupeMaxSize()) - : EvaluationExposureDeduper.disabled(); - } - /** * Identifies the evaluation a hook is about to be told about, so that a deduper can recognize a * repeat of it. diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java index add0767c..9056f9dc 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java @@ -62,17 +62,6 @@ public class LDConfig { static final String primaryEnvironmentName = "default"; - /** - * The default value for {@link LDConfig.Builder#evaluationExposureDedupeWindowMillis(int)}: 0, meaning that - * deduplication is disabled. - */ - public static final int DEFAULT_EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS = 0; - - /** - * The default value for {@link LDConfig.Builder#evaluationExposureDedupeMaxSize(int)}: 2000. - */ - public static final int DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE = 2_000; - static final int DEFAULT_MAX_CACHED_CONTEXTS = 5; static final int DEFAULT_CONNECTION_TIMEOUT_MILLIS = 10_000; // 10 seconds @@ -98,8 +87,6 @@ public class LDConfig { private final String loggerName; private final int maxCachedContexts; private final boolean offline; - private final int evaluationExposureDedupeWindowMillis; - private final int evaluationExposureDedupeMaxSize; private final long connectionModeStateDebounceMs; private final PersistentDataStore persistentDataStore; // configurable for testing only @@ -119,8 +106,6 @@ public class LDConfig { int maxCachedContexts, boolean generateAnonymousKeys, boolean autoEnvAttributes, - int evaluationExposureDedupeWindowMillis, - int evaluationExposureDedupeMaxSize, long connectionModeStateDebounceMs, PersistentDataStore persistentDataStore, LDLogAdapter logAdapter, @@ -141,8 +126,6 @@ public class LDConfig { this.maxCachedContexts = maxCachedContexts; this.generateAnonymousKeys = generateAnonymousKeys; this.autoEnvAttributes = autoEnvAttributes; - this.evaluationExposureDedupeWindowMillis = evaluationExposureDedupeWindowMillis; - this.evaluationExposureDedupeMaxSize = evaluationExposureDedupeMaxSize; this.connectionModeStateDebounceMs = connectionModeStateDebounceMs; this.persistentDataStore = persistentDataStore; this.logAdapter = logAdapter; @@ -212,20 +195,6 @@ int getMaxCachedContexts() { return maxCachedContexts; } - /** - * @return the evaluation exposure deduplication window in milliseconds, or 0 if deduplication is disabled - */ - public int getEvaluationExposureDedupeWindowMillis() { - return evaluationExposureDedupeWindowMillis; - } - - /** - * @return the maximum number of evaluation exposure keys tracked for deduplication at once - */ - public int getEvaluationExposureDedupeMaxSize() { - return evaluationExposureDedupeMaxSize; - } - /** * @return true if keys should be generated for anonymous contexts, false otherwise */ @@ -295,9 +264,6 @@ public enum AutoEnvAttributes { private int maxCachedContexts = DEFAULT_MAX_CACHED_CONTEXTS; - private int evaluationExposureDedupeWindowMillis = DEFAULT_EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS; - private int evaluationExposureDedupeMaxSize = DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE; - private boolean offline = false; private boolean disableBackgroundUpdating = false; private boolean diagnosticOptOut = false; @@ -663,62 +629,6 @@ public Builder maxCachedContexts(int maxCachedContexts) { return this; } - /** - * Sets the time window, in milliseconds, during which repeated feature flag evaluations that - * resolve to the same result are deduplicated before being reported to hooks. - *

- * Within the window, a hook observes only a single evaluation per unique combination of flag - * key, variation, flag version, experiment status, and evaluation context. This is useful for - * reducing the telemetry volume produced by frequent re-evaluations, for example a flag that is - * read on every redraw of a view. - *

- * Deduplication applies to the whole evaluation series, so a suppressed evaluation invokes - * neither {@code beforeEvaluation} nor {@code afterEvaluation} on that hook. Analytics events - * are unaffected: feature, debug, and summary events are still recorded for every evaluation, - * so the evaluation counts LaunchDarkly reports for your flags do not change. - *

- * This is the default for hooks that do not carry a policy of their own. Each hook is - * deduplicated separately, and a hook can override this with - * {@link com.launchdarkly.sdk.android.integrations.Hook#evaluationExposureDeduper(com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper)}, - * for example to observe every evaluation while other hooks are deduplicated. - *

- * Every hook's record of what it has observed is cleared by - * {@link LDClient#identify(LDContext)}, so the first evaluation after an identify is always - * reported. - *

- * If not specified, the default is {@link #DEFAULT_EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS} (0), - * which disables deduplication so that every evaluation is reported. - * - * @param evaluationExposureDedupeWindowMillis the dedupe window in milliseconds; zero or negative - * disables deduplication - * @return the builder - * @see #evaluationExposureDedupeMaxSize(int) - */ - public Builder evaluationExposureDedupeWindowMillis(int evaluationExposureDedupeWindowMillis) { - this.evaluationExposureDedupeWindowMillis = evaluationExposureDedupeWindowMillis; - return this; - } - - /** - * Sets the maximum number of unique evaluation exposure keys tracked for deduplication at - * once. - *

- * When the limit is exceeded, the least recently recorded keys are evicted to bound memory - * usage. This only matters when {@link #evaluationExposureDedupeWindowMillis(int)} is enabled, - * and applies to hooks that do not carry a policy of their own. - *

- * If not specified, the default is {@link #DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE} (2000). - * - * @param evaluationExposureDedupeMaxSize the maximum number of keys to track; zero or negative - * values are ignored and the default is used instead - * @return the builder - * @see #evaluationExposureDedupeWindowMillis(int) - */ - public Builder evaluationExposureDedupeMaxSize(int evaluationExposureDedupeMaxSize) { - this.evaluationExposureDedupeMaxSize = evaluationExposureDedupeMaxSize; - return this; - } - /** * Set to {@code true} to make the SDK provide unique keys for anonymous contexts. *

@@ -929,8 +839,6 @@ public LDConfig build() { maxCachedContexts, generateAnonymousKeys, autoEnvAttributes, - evaluationExposureDedupeWindowMillis, - evaluationExposureDedupeMaxSize, connectionModeStateDebounceMs, persistentDataStore, actualLogAdapter, diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java index 359d2fcc..964cc07d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -1,7 +1,5 @@ package com.launchdarkly.sdk.android.integrations; -import com.launchdarkly.sdk.android.LDConfig; - import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; @@ -10,16 +8,12 @@ * Decides whether a hook should be told about an evaluation, so that repeated evaluations resolving * to the same result do not invoke the hook again within a time window. *

- * The SDK gives each registered hook its own deduper. Give one to a hook with - * {@link Hook#evaluationExposureDeduper(EvaluationExposureDeduper)} to control that hook's behavior; - * a hook carrying none gets a deduper built from - * {@link LDConfig.Builder#evaluationExposureDedupeWindowMillis(int)} and - * {@link LDConfig.Builder#evaluationExposureDedupeMaxSize(int)}. + * Deduplication is opt-in per hook: a hook is told about every evaluation until you give it a + * deduper with {@link Hook#evaluationExposureDeduper(int, int)}. * *


  *     Components.hooks()
- *         .addHook(new MetricsHook())                                // SDK defaults
- *         .addHook(new AuditHook().evaluationExposureDeduper(EvaluationExposureDeduper.disabled()))
+ *         .addHook(new MetricsHook())                                // told about every evaluation
  *         .addHook(new ObservabilityHook().evaluationExposureDeduper(30_000, 5_000))
  *         .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper))
  * 
@@ -36,6 +30,11 @@ * window that suppresses the rest. */ public class EvaluationExposureDeduper { + /** + * The number of exposure keys tracked by a deduper built without a positive cap of its own: 2000. + */ + public static final int DEFAULT_MAX_SIZE = 2_000; + private static final EvaluationExposureDeduper DISABLED = new Disabled(); private final long windowMillis; @@ -49,18 +48,19 @@ public class EvaluationExposureDeduper { * @param windowMillis the dedupe window in milliseconds; zero or negative disables * deduplication, so every evaluation reaches the hook * @param maxSize the maximum number of exposure keys to track; zero or negative falls back to - * {@link LDConfig#DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE} + * {@link #DEFAULT_MAX_SIZE} */ public EvaluationExposureDeduper(int windowMillis, int maxSize) { this.windowMillis = windowMillis; - this.maxSize = maxSize > 0 ? maxSize : LDConfig.DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE; + this.maxSize = maxSize > 0 ? maxSize : DEFAULT_MAX_SIZE; } /** - * Returns a deduper that suppresses nothing, so its hook is told about every evaluation - * regardless of the SDK's configured window. + * Returns a deduper that suppresses nothing, so its hook is told about every evaluation. *

- * The returned instance holds no state and may be given to any number of hooks. + * This is what a hook gets when it is registered without a deduper, so passing it is only useful + * to state that intent explicitly. The returned instance holds no state and may be given to any + * number of hooks. * * @return a deduper that never suppresses an evaluation */ diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java index de6a168f..588a091a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java @@ -36,8 +36,13 @@ public Hook(String name) { } /** - * Deduplicates this hook's evaluation series with the SDK's implementation, using the given - * parameters instead of the ones configured on {@link com.launchdarkly.sdk.android.LDConfig}. + * Deduplicates this hook's evaluation series with the SDK's implementation, so that repeated + * evaluations resolving to the same result reach it at most once per window. + *

+ * Within the window, this hook observes only a single evaluation per unique combination of flag + * key, variation, flag version, experiment status, and evaluation context. This is useful for + * reducing the telemetry volume produced by frequent re-evaluations, for example a flag that is + * read on every redraw of a view. * *


      *     Components.hooks()
@@ -47,7 +52,7 @@ public Hook(String name) {
      * @param windowMillis the dedupe window in milliseconds; zero or negative reports every
      *                     evaluation
      * @param maxSize the maximum number of exposure keys to track; zero or negative uses
-     *                {@link com.launchdarkly.sdk.android.LDConfig#DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE}
+     *                {@link EvaluationExposureDeduper#DEFAULT_MAX_SIZE}
      * @return this hook
      */
     public Hook evaluationExposureDeduper(int windowMillis, int maxSize) {
@@ -55,24 +60,31 @@ public Hook evaluationExposureDeduper(int windowMillis, int maxSize) {
     }
 
     /**
-     * Sets which evaluations reach this hook, overriding the deduplication configured on
-     * {@link com.launchdarkly.sdk.android.LDConfig}. It affects only this hook.
+     * Sets which evaluations reach this hook. It affects only this hook.
      * 

- * Pass {@link EvaluationExposureDeduper#disabled()} to observe every evaluation, or your own - * subclass of {@link EvaluationExposureDeduper} to implement a different policy. + * Pass your own subclass of {@link EvaluationExposureDeduper} to implement a policy other than + * the SDK's, or {@link EvaluationExposureDeduper#disabled()} to state explicitly that this hook + * observes every evaluation, which is what it does anyway when no deduper is set. * *


      *     Components.hooks()
-     *         .addHook(new AuditHook().evaluationExposureDeduper(EvaluationExposureDeduper.disabled()))
      *         .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper))
      * 
*

+ * Deduplication applies to the whole evaluation series, so a suppressed evaluation invokes + * neither {@link #beforeEvaluation(EvaluationSeriesContext, Map)} nor + * {@link #afterEvaluation(EvaluationSeriesContext, Map, EvaluationDetail)}. Analytics events are + * unaffected: feature, debug, and summary events are still recorded for every evaluation, so the + * evaluation counts LaunchDarkly reports for your flags do not change. What the hook has + * observed is cleared by {@link com.launchdarkly.sdk.android.LDClient#identify(com.launchdarkly.sdk.LDContext)}, + * so the first evaluation after an identify always reaches it. + *

* The SDK reads this once, when the hook is registered, so call it before passing the hook to * the SDK. Give each hook its own deduper unless you intend hooks to share a window: the first * hook to observe an evaluation starts the window that suppresses the rest. * - * @param evaluationExposureDeduper the deduper for this hook, or null to use the SDK's - * configured defaults + * @param evaluationExposureDeduper the deduper for this hook, or null to observe every + * evaluation * @return this hook */ public Hook evaluationExposureDeduper(EvaluationExposureDeduper evaluationExposureDeduper) { @@ -81,8 +93,8 @@ public Hook evaluationExposureDeduper(EvaluationExposureDeduper evaluationExposu } /** - * @return the deduper deciding which evaluations reach this hook, or null if it uses the - * deduplication configured on {@link com.launchdarkly.sdk.android.LDConfig} + * @return the deduper deciding which evaluations reach this hook, or null if it observes every + * evaluation */ public final EvaluationExposureDeduper getEvaluationExposureDeduper() { return evaluationExposureDeduper; diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java index 9e74561a..869ea5ae 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java @@ -23,14 +23,13 @@ * .build(); *

*

- * A hook can carry its own exposure deduplication policy, which controls how often repeated - * evaluations resolving to the same result reach it. See - * {@link Hook#evaluationExposureDeduper(EvaluationExposureDeduper)}. + * A hook observes every evaluation unless it carries an exposure deduplication policy, which limits + * how often repeated evaluations resolving to the same result reach it. See + * {@link Hook#evaluationExposureDeduper(int, int)}. * *


  *     Components.hooks()
  *         .addHook(new MetricsHook())
- *         .addHook(new AuditHook().evaluationExposureDeduper(EvaluationExposureDeduper.disabled()))
  *         .addHook(new ObservabilityHook().evaluationExposureDeduper(60_000, 2_000))
  *         .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper))
  * 
diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java index 10e5f26a..54fdece8 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java @@ -21,7 +21,7 @@ public void recordsEverythingForNonPositiveWindow() { } @Test - public void disabledRecordsEverythingAndIsShared() { + public void disabledRecordsEverythingAndIsSharedAcrossHooks() { EvaluationExposureDeduper deduper = EvaluationExposureDeduper.disabled(); assertTrue(deduper.shouldRecord("a", 1000)); assertTrue(deduper.shouldRecord("a", 1000)); @@ -123,7 +123,7 @@ public void keepsLiveKeysWhenReclaimingExpiredOnesIsEnough() { @Test public void fallsBackToDefaultCapForNonPositiveMaxSize() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(10_000, 0); - for (int i = 0; i < LDConfig.DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE; i++) { + for (int i = 0; i < EvaluationExposureDeduper.DEFAULT_MAX_SIZE; i++) { assertTrue(deduper.shouldRecord("key-" + i, 1000)); } assertFalse(deduper.shouldRecord("key-0", 1000)); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java index 5d607998..80cc207e 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java @@ -104,7 +104,7 @@ public void hookDeduperSkipsBothStagesOfARepeatedEvaluation() { RecordingHook hook = new RecordingHook("deduping"); hook.evaluationExposureDeduper(60_000, 10); HookRunner runner = new HookRunner(logging.logger, List.of(hook), - EvaluationExposureDeduper::disabled, (flagKey, context) -> "exposure-key"); + (flagKey, context) -> "exposure-key"); evaluate(runner); evaluate(runner); @@ -113,14 +113,25 @@ public void hookDeduperSkipsBothStagesOfARepeatedEvaluation() { assertEquals(List.of("before", "after"), hook.stages); } + @Test + public void hookWithoutADeduperObservesEveryEvaluation() { + RecordingHook hook = new RecordingHook("no-deduper"); + HookRunner runner = new HookRunner(logging.logger, List.of(hook), + (flagKey, context) -> "exposure-key"); + + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("before", "after", "before", "after"), hook.stages); + } + @Test public void hooksAreDeduplicatedIndependentlyOfEachOther() { RecordingHook deduping = new RecordingHook("deduping"); deduping.evaluationExposureDeduper(60_000, 10); RecordingHook reportingEverything = new RecordingHook("reporting-everything"); - reportingEverything.evaluationExposureDeduper(EvaluationExposureDeduper.disabled()); HookRunner runner = new HookRunner(logging.logger, List.of(deduping, reportingEverything), - EvaluationExposureDeduper::disabled, (flagKey, context) -> "exposure-key"); + (flagKey, context) -> "exposure-key"); evaluate(runner); evaluate(runner); @@ -130,11 +141,13 @@ public void hooksAreDeduplicatedIndependentlyOfEachOther() { } @Test - public void hooksWithoutADeduperEachGetTheirOwnFromTheDefaultFactory() { + public void hooksGivenSeparateDedupersDoNotSuppressEachOther() { RecordingHook first = new RecordingHook("first"); + first.evaluationExposureDeduper(60_000, 10); RecordingHook second = new RecordingHook("second"); + second.evaluationExposureDeduper(60_000, 10); HookRunner runner = new HookRunner(logging.logger, List.of(first, second), - () -> new EvaluationExposureDeduper(60_000, 10), (flagKey, context) -> "exposure-key"); + (flagKey, context) -> "exposure-key"); evaluate(runner); evaluate(runner); @@ -144,12 +157,29 @@ public void hooksWithoutADeduperEachGetTheirOwnFromTheDefaultFactory() { assertEquals(List.of("before", "after"), second.stages); } + @Test + public void hooksSharingOneDeduperShareItsWindow() { + EvaluationExposureDeduper shared = new EvaluationExposureDeduper(60_000, 10); + RecordingHook first = new RecordingHook("first"); + first.evaluationExposureDeduper(shared); + RecordingHook second = new RecordingHook("second"); + second.evaluationExposureDeduper(shared); + HookRunner runner = new HookRunner(logging.logger, List.of(first, second), + (flagKey, context) -> "exposure-key"); + + evaluate(runner); + + // The first hook's report starts the window, which suppresses the second hook's. + assertEquals(List.of("before", "after"), first.stages); + assertEquals(List.of(), second.stages); + } + @Test public void resettingDedupersReportsTheSameEvaluationAgain() { RecordingHook hook = new RecordingHook("deduping"); hook.evaluationExposureDeduper(60_000, 10); HookRunner runner = new HookRunner(logging.logger, List.of(hook), - EvaluationExposureDeduper::disabled, (flagKey, context) -> "exposure-key"); + (flagKey, context) -> "exposure-key"); evaluate(runner); runner.resetEvaluationExposureDedupers(); @@ -166,7 +196,7 @@ public void buildsTheExposureKeyOncePerEvaluationRegardlessOfHookCount() { second.evaluationExposureDeduper(60_000, 10); List keyRequests = new ArrayList<>(); HookRunner runner = new HookRunner(logging.logger, List.of(first, second), - EvaluationExposureDeduper::disabled, (flagKey, context) -> { + (flagKey, context) -> { keyRequests.add(flagKey); return "exposure-key"; }); @@ -180,10 +210,9 @@ public void buildsTheExposureKeyOncePerEvaluationRegardlessOfHookCount() { @Test public void doesNotBuildTheExposureKeyWhenNoHookCanSuppress() { RecordingHook hook = new RecordingHook("reporting-everything"); - hook.evaluationExposureDeduper(EvaluationExposureDeduper.disabled()); List keyRequests = new ArrayList<>(); HookRunner runner = new HookRunner(logging.logger, List.of(hook), - EvaluationExposureDeduper::disabled, (flagKey, context) -> { + (flagKey, context) -> { keyRequests.add(flagKey); return "exposure-key"; }); @@ -199,7 +228,7 @@ public void hookAddedLaterCarriesItsOwnDeduper() { RecordingHook added = new RecordingHook("added"); added.evaluationExposureDeduper(60_000, 10); HookRunner runner = new HookRunner(logging.logger, List.of(), - EvaluationExposureDeduper::disabled, (flagKey, context) -> "exposure-key"); + (flagKey, context) -> "exposure-key"); runner.addHook(added); evaluate(runner); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java index 60a0f20b..5db26461 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java @@ -40,22 +40,6 @@ public void testBuilderDefaults() { assertFalse(config.getDiagnosticOptOut()); assertEquals(0, config.hooks.getHooks().size()); - - assertEquals(LDConfig.DEFAULT_EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS, - config.getEvaluationExposureDedupeWindowMillis()); - assertEquals(LDConfig.DEFAULT_EVALUATION_EXPOSURE_DEDUPE_MAX_SIZE, - config.getEvaluationExposureDedupeMaxSize()); - } - - @Test - public void testBuilderEvaluationExposureDedupe() { - LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled) - .evaluationExposureDedupeWindowMillis(5_000) - .evaluationExposureDedupeMaxSize(50) - .build(); - - assertEquals(5_000, config.getEvaluationExposureDedupeWindowMillis()); - assertEquals(50, config.getEvaluationExposureDedupeMaxSize()); } @Test From e3812419cbc6b2dd9cacc452866ae42b431aedd3 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 6 Aug 2026 14:28:13 -0700 Subject: [PATCH 09/29] docs: demonstrate two dedupe windows in the example app One hook could not show that hooks are deduplicated independently, which is the part of the API most likely to be misread. The example now registers two hooks with different windows and reports each one's counts separately, so evaluating a flag repeatedly past five seconds moves the fast hook's count while the slow one stays put. The hook moves out of MainActivity into its own file and sets its window in its constructor, which is how a hook shipped by a plugin would choose its policy. MainActivity registers both without mentioning deduplication at all. Co-authored-by: Cursor --- example/README.md | 39 +++++----- .../example/ExposureCountingHook.java | 71 +++++++++++++++++++ .../launchdarkly/example/MainActivity.java | 62 ++++------------ 3 files changed, 107 insertions(+), 65 deletions(-) create mode 100644 example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java diff --git a/example/README.md b/example/README.md index 5ccdbd32..406c30f4 100644 --- a/example/README.md +++ b/example/README.md @@ -20,30 +20,37 @@ has to match the environment: a production key against staging fails to authoriz ## Verifying evaluation exposure deduplication -The app registers a hook that counts evaluation series stages and displays the totals below the -evaluation result: +The app registers two `ExposureCountingHook` instances with different dedupe windows, and each counts +the evaluation series stages it observes. The totals appear below the evaluation result: ``` Environment: production -Evaluation Exposure dedupe window: 60000 ms -Evaluations requested: 4 -Reported to hooks: 1 (before 1 / after 1) +Evaluations requested: 7 +fast (5000 ms): 3 (before 3 / after 3) +slow (60000 ms): 1 (before 1 / after 1) ``` -The window is the `EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS` constant in `MainActivity`, which the -app gives to that one hook: +Each hook declares its own window in its constructor, which is how a hook shipped by a plugin would +choose its policy: ```java -Components.hooks().addHook(exposureHook.evaluationExposureDeduper(60_000, 2_000)) +evaluationExposureDeduper(new EvaluationExposureDeduper(dedupeWindowMillis, DEDUPE_MAX_SIZE)); ``` -Set it to `0` to turn deduplication off for the hook. Each hook is deduplicated on its own, so a -second hook registered with `EvaluationExposureDeduper.disabled()` would keep seeing every -evaluation. A hook registered without a deduper falls back to -`LDConfig.Builder.evaluationExposureDedupeWindowMillis`. +The windows are the `FAST_DEDUPE_WINDOW_MILLIS` and `SLOW_DEDUPE_WINDOW_MILLIS` constants in +`MainActivity`, which registers both hooks without saying anything more about deduplication: + +```java +Components.hooks().addHook(fastHook).addHook(slowHook) +``` + +Deduplication is opt-in per hook: a hook registered without a deduper observes every evaluation. +Passing `EvaluationExposureDeduper.disabled()` states that explicitly, and passing your own subclass +of `EvaluationExposureDeduper` replaces the policy entirely. Enter a flag key, then tap **Evaluate Flag** repeatedly. "Evaluations requested" climbs with every -tap while "Reported to hooks" stays put, because repeated evaluations resolving to the same result -within the window are suppressed for the whole series. Tapping **Identify** clears the dedupe cache, -so the next evaluation is reported again. Analytics events are not deduplicated: every evaluation is -still counted by LaunchDarkly. +tap while each hook's count stays put, because repeated evaluations resolving to the same result +within that hook's window are suppressed for the whole series. Keep tapping past five seconds and the +`fast` count moves again while `slow` stays where it is. Tapping **Identify** clears both hooks' +caches, so the next evaluation reaches both. Analytics events are not deduplicated: every evaluation +is still counted by LaunchDarkly. diff --git a/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java b/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java new file mode 100644 index 00000000..9dc9f96e --- /dev/null +++ b/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java @@ -0,0 +1,71 @@ +package com.launchdarkly.example; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.android.integrations.Hook; + +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Counts the evaluation series stages it observes, so the example can show what exposure + * deduplication does. The hook declares its own dedupe window, which is how a hook shipped by a + * plugin would choose its policy. + *

+ * Deduplication skips the whole series, so both counts stay equal and both stop climbing while + * repeated evaluations resolve to the same result. + */ +class ExposureCountingHook extends Hook { + private static final int DEDUPE_MAX_SIZE = 2_000; + + private final String label; + private final int dedupeWindowMillis; + private final Runnable onStage; + private final AtomicInteger befores = new AtomicInteger(); + private final AtomicInteger afters = new AtomicInteger(); + + /** + * @param label a name for this hook, shown in the app's dedupe status + * @param dedupeWindowMillis the hook's dedupe window; zero or negative observes every evaluation + * @param onStage run after each stage so the app can refresh its display + */ + ExposureCountingHook(String label, int dedupeWindowMillis, Runnable onStage) { + super(label); + this.label = label; + this.dedupeWindowMillis = dedupeWindowMillis; + this.onStage = onStage; + evaluationExposureDeduper(dedupeWindowMillis > 0 + ? new EvaluationExposureDeduper(dedupeWindowMillis, DEDUPE_MAX_SIZE) + : EvaluationExposureDeduper.disabled()); + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + befores.incrementAndGet(); + onStage.run(); + return seriesData; + } + + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, + EvaluationDetail evaluationDetail) { + afters.incrementAndGet(); + onStage.run(); + return seriesData; + } + + /** + * @return a line describing this hook's window and how many evaluations have reached it + */ + String status() { + return String.format(Locale.US, "%s (%s): %d (before %d / after %d)", + label, + dedupeWindowMillis > 0 ? dedupeWindowMillis + " ms" : "no dedupe", + afters.get(), + befores.get(), + afters.get()); + } +} diff --git a/example/src/main/java/com/launchdarkly/example/MainActivity.java b/example/src/main/java/com/launchdarkly/example/MainActivity.java index 3f8f588c..eaf5d86a 100644 --- a/example/src/main/java/com/launchdarkly/example/MainActivity.java +++ b/example/src/main/java/com/launchdarkly/example/MainActivity.java @@ -13,9 +13,7 @@ import androidx.appcompat.app.AppCompatActivity; -import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDContext; -import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.android.Components; import com.launchdarkly.sdk.android.ConnectionInformation; import com.launchdarkly.sdk.android.LDAllFlagsListener; @@ -24,12 +22,9 @@ import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; import com.launchdarkly.sdk.android.LDFailure; import com.launchdarkly.sdk.android.LDStatusListener; -import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; -import com.launchdarkly.sdk.android.integrations.Hook; import java.util.Date; import java.util.Locale; -import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -40,7 +35,9 @@ public class MainActivity extends AppCompatActivity { - private static final int EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS = 5_000; + // Two hooks with different windows, to show that each one is deduplicated on its own. + private static final int FAST_DEDUPE_WINDOW_MILLIS = 5_000; + private static final int SLOW_DEDUPE_WINDOW_MILLIS = 60_000; // The staging hosts mirror the production ones in StandardEndpoints under the ld-stg domain. private static final String STAGING_DOMAIN = "ld-stg.launchdarkly.com"; @@ -49,37 +46,12 @@ public class MainActivity extends AppCompatActivity { private LDStatusListener ldStatusListener; private LDAllFlagsListener allFlagsListener; - private final ExposureCountingHook exposureHook = new ExposureCountingHook(); + private final ExposureCountingHook fastHook = + new ExposureCountingHook("fast", FAST_DEDUPE_WINDOW_MILLIS, this::updateDedupeStatus); + private final ExposureCountingHook slowHook = + new ExposureCountingHook("slow", SLOW_DEDUPE_WINDOW_MILLIS, this::updateDedupeStatus); private final AtomicInteger evaluationsRequested = new AtomicInteger(); - /** - * Counts the evaluation hook stages so the example can show what exposure deduplication does. - * Deduplication skips the whole series, so both counts stay equal and both stop climbing while - * repeated evaluations resolve to the same result. - */ - private class ExposureCountingHook extends Hook { - final AtomicInteger befores = new AtomicInteger(); - final AtomicInteger afters = new AtomicInteger(); - - ExposureCountingHook() { - super("exposure-counting-hook"); - } - - @Override - public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { - befores.incrementAndGet(); - updateDedupeStatus(); - return seriesData; - } - - @Override - public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, EvaluationDetail evaluationDetail) { - afters.incrementAndGet(); - updateDedupeStatus(); - return seriesData; - } - } - private static boolean isStaging() { return "staging".equalsIgnoreCase(BuildConfig.LD_ENVIRONMENT); } @@ -90,20 +62,12 @@ private void updateDedupeStatus() { return; } - int requested = evaluationsRequested.get(); - int reported = exposureHook.afters.get(); - String window = EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS > 0 - ? EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS + " ms" - : "disabled"; - String result = String.format(Locale.US, - "Environment: %s\nEvaluation Exposure dedupe window: %s\nEvaluations requested: %d\nReported to hooks: %d (before %d / after %d)", + "Environment: %s\nEvaluations requested: %d\n%s\n%s", isStaging() ? "staging" : "production", - window, - requested, - reported, - exposureHook.befores.get(), - reported); + evaluationsRequested.get(), + fastHook.status(), + slowHook.status()); ((TextView) MainActivity.this.findViewById(R.id.dedupe_status)).setText(result); } @@ -153,8 +117,8 @@ public void onCreate(Bundle savedInstanceState) { // change useReport to `true` if the request is to be REPORT'ed instead of GET'ed ) .hooks( - Components.hooks().addHook(exposureHook.evaluationExposureDeduper( - EVALUATION_EXPOSURE_DEDUPE_WINDOW_MILLIS, 2_000)) + // Each hook brought its own window, so neither suppresses the other. + Components.hooks().addHook(fastHook).addHook(slowHook) ); if (isStaging()) { From c791c6fcd1f797fb366eeff6e1dde16e6351995a Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 6 Aug 2026 14:57:52 -0700 Subject: [PATCH 10/29] less error prone logic --- example/README.md | 37 ------------- .../launchdarkly/sdk/android/HookRunner.java | 52 +++++++++++-------- 2 files changed, 30 insertions(+), 59 deletions(-) diff --git a/example/README.md b/example/README.md index 406c30f4..dc1debc9 100644 --- a/example/README.md +++ b/example/README.md @@ -17,40 +17,3 @@ launchdarkly.environment=production These become `BuildConfig` fields, so change them and rebuild for them to take effect. The mobile key has to match the environment: a production key against staging fails to authorize. - -## Verifying evaluation exposure deduplication - -The app registers two `ExposureCountingHook` instances with different dedupe windows, and each counts -the evaluation series stages it observes. The totals appear below the evaluation result: - -``` -Environment: production -Evaluations requested: 7 -fast (5000 ms): 3 (before 3 / after 3) -slow (60000 ms): 1 (before 1 / after 1) -``` - -Each hook declares its own window in its constructor, which is how a hook shipped by a plugin would -choose its policy: - -```java -evaluationExposureDeduper(new EvaluationExposureDeduper(dedupeWindowMillis, DEDUPE_MAX_SIZE)); -``` - -The windows are the `FAST_DEDUPE_WINDOW_MILLIS` and `SLOW_DEDUPE_WINDOW_MILLIS` constants in -`MainActivity`, which registers both hooks without saying anything more about deduplication: - -```java -Components.hooks().addHook(fastHook).addHook(slowHook) -``` - -Deduplication is opt-in per hook: a hook registered without a deduper observes every evaluation. -Passing `EvaluationExposureDeduper.disabled()` states that explicitly, and passing your own subclass -of `EvaluationExposureDeduper` replaces the policy entirely. - -Enter a flag key, then tap **Evaluate Flag** repeatedly. "Evaluations requested" climbs with every -tap while each hook's count stays put, because repeated evaluations resolving to the same result -within that hook's window are suppressed for the whole series. Keep tapping past five seconds and the -`fast` count moves again while `slow` stays where it is. Tapping **Identify** clears both hooks' -caches, so the next evaluation reaches both. Analytics events are not deduplicated: every evaluation -is still counted by LaunchDarkly. diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java index 5b571ecb..d4f64bba 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java @@ -36,12 +36,24 @@ public interface ExposureKeySupplier { String exposureKey(String flagKey, LDContext context); } + /** + * A registered hook together with the deduper that decides which evaluations reach it. Kept as + * one value so adding a hook never leaves the two out of sync. + */ + private static final class RegisteredHook { + final Hook hook; + final EvaluationExposureDeduper deduper; + + RegisteredHook(Hook hook, EvaluationExposureDeduper deduper) { + this.hook = hook; + this.deduper = deduper; + } + } + private static final String UNKNOWN_HOOK_NAME = "unknown hook"; private final LDLogger logger; - private final List hooks = new ArrayList<>(); - // Parallel to hooks: the deduper deciding which evaluations reach the hook at the same index. - private final List dedupers = new ArrayList<>(); + private final List hooks = new ArrayList<>(); private final ExposureKeySupplier exposureKeySupplier; // False while every registered hook wants every evaluation, which is the default. Lets the @@ -84,10 +96,7 @@ public void addHook(Hook hook) { if (deduper != EvaluationExposureDeduper.disabled()) { anyDedupeActive = true; } - // The deduper goes in first so that an evaluation running concurrently with this never sees - // a hook whose deduper has not been appended yet. - dedupers.add(deduper); - hooks.add(hook); + hooks.add(new RegisteredHook(hook, deduper)); } /** @@ -95,8 +104,8 @@ public void addHook(Hook hook) { * evaluation of each reaches the hook again. Called when the evaluation context changes. */ public void resetEvaluationExposureDedupers() { - for (EvaluationExposureDeduper deduper : dedupers) { - deduper.reset(); + for (RegisteredHook registered : hooks) { + registered.deduper.reset(); } } @@ -108,28 +117,27 @@ public void resetEvaluationExposureDedupers() { * {@code beforeEvaluation} and ends it in {@code afterEvaluation}, so suppressing only the after * stage would leave that span open until something else closed it. */ - private List hooksForEvaluation(String flagKey, LDContext context) { + private List hooksForEvaluation(String flagKey, LDContext context) { if (!anyDedupeActive || hooks.isEmpty()) { return hooks; } String exposureKey = exposureKeySupplier.exposureKey(flagKey, context); long nowMillis = System.currentTimeMillis(); - List reporting = new ArrayList<>(hooks.size()); - for (int i = 0; i < hooks.size(); i++) { - Hook hook = hooks.get(i); - if (dedupers.get(i).shouldRecord(exposureKey, nowMillis)) { - reporting.add(hook); + List reporting = new ArrayList<>(hooks.size()); + for (RegisteredHook registered : hooks) { + if (registered.deduper.shouldRecord(exposureKey, nowMillis)) { + reporting.add(registered); } else { logger.debug("Deduplicated exposure of flag \"{}\" for hook \"{}\"", flagKey, - getHookName(hook)); + getHookName(registered.hook)); } } return reporting; } public EvaluationDetail withEvaluation(String method, String key, LDContext context, LDValue defaultValue, EvaluationMethod evalMethod) { - List reportingHooks = hooksForEvaluation(key, context); + List reportingHooks = hooksForEvaluation(key, context); if (reportingHooks.isEmpty()) { return evalMethod.evaluate(); } @@ -137,7 +145,7 @@ public EvaluationDetail withEvaluation(String method, String key, LDCon List> seriesDataList = new ArrayList<>(reportingHooks.size()); EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue); for (int i = 0; i < reportingHooks.size(); i++) { - Hook currentHook = reportingHooks.get(i); + Hook currentHook = reportingHooks.get(i).hook; try { Map seriesData = currentHook.beforeEvaluation(seriesContext, Collections.unmodifiableMap(Collections.emptyMap())); seriesDataList.add(Collections.unmodifiableMap(seriesData)); @@ -151,7 +159,7 @@ public EvaluationDetail withEvaluation(String method, String key, LDCon // Invoke hooks in reverse order and give them back the series data they gave us. for (int i = reportingHooks.size() - 1; i >= 0; i--) { - Hook currentHook = reportingHooks.get(i); + Hook currentHook = reportingHooks.get(i).hook; try { currentHook.afterEvaluation(seriesContext, seriesDataList.get(i), result); } catch (Exception e) { @@ -170,7 +178,7 @@ public AfterIdentifyMethod identify(LDContext context, Integer timeout) { List> seriesDataList = new ArrayList<>(hooks.size()); IdentifySeriesContext seriesContext = new IdentifySeriesContext(context, timeout); for (int i = 0; i < hooks.size(); i++) { - Hook currentHook = hooks.get(i); + Hook currentHook = hooks.get(i).hook; try { Map seriesData = currentHook.beforeIdentify(seriesContext, Collections.unmodifiableMap(Collections.emptyMap())); seriesDataList.add(Collections.unmodifiableMap(seriesData)); @@ -183,7 +191,7 @@ public AfterIdentifyMethod identify(LDContext context, Integer timeout) { return (IdentifySeriesResult result) -> { // Invoke hooks in reverse order and give them back the series data they gave us. for (int i = hooks.size() - 1; i >= 0; i--) { - Hook currentHook = hooks.get(i); + Hook currentHook = hooks.get(i).hook; try { currentHook.afterIdentify(seriesContext, seriesDataList.get(i), result); } catch (Exception e) { @@ -202,7 +210,7 @@ public void afterTrack(String key, LDContext context, LDValue data, Double metri // The track series has only an "after" stage, so hooks run in registration order, as required by // the shared SDK contract tests (unlike afterEvaluation/afterIdentify, which run in reverse). for (int i = 0; i < hooks.size(); i++) { - Hook currentHook = hooks.get(i); + Hook currentHook = hooks.get(i).hook; try { currentHook.afterTrack(seriesContext); } catch (Exception e) { From ad9b1e2f9be12b78ba56cd932ac7d1bbb289f6ed Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 6 Aug 2026 15:48:47 -0700 Subject: [PATCH 11/29] fix example --- .../example/ExposureCountingHook.java | 23 +++++-------- .../launchdarkly/example/MainActivity.java | 32 +++++++++++++------ 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java b/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java index 9dc9f96e..76752c3f 100644 --- a/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java +++ b/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java @@ -2,7 +2,6 @@ import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; @@ -12,34 +11,27 @@ /** * Counts the evaluation series stages it observes, so the example can show what exposure - * deduplication does. The hook declares its own dedupe window, which is how a hook shipped by a - * plugin would choose its policy. + * deduplication does. Deduplication is configured at registration with + * {@link Hook#evaluationExposureDeduper(int, int)}, the same way a customer would configure any + * other hook. *

* Deduplication skips the whole series, so both counts stay equal and both stop climbing while * repeated evaluations resolve to the same result. */ class ExposureCountingHook extends Hook { - private static final int DEDUPE_MAX_SIZE = 2_000; - private final String label; - private final int dedupeWindowMillis; private final Runnable onStage; private final AtomicInteger befores = new AtomicInteger(); private final AtomicInteger afters = new AtomicInteger(); /** * @param label a name for this hook, shown in the app's dedupe status - * @param dedupeWindowMillis the hook's dedupe window; zero or negative observes every evaluation * @param onStage run after each stage so the app can refresh its display */ - ExposureCountingHook(String label, int dedupeWindowMillis, Runnable onStage) { + ExposureCountingHook(String label, Runnable onStage) { super(label); this.label = label; - this.dedupeWindowMillis = dedupeWindowMillis; this.onStage = onStage; - evaluationExposureDeduper(dedupeWindowMillis > 0 - ? new EvaluationExposureDeduper(dedupeWindowMillis, DEDUPE_MAX_SIZE) - : EvaluationExposureDeduper.disabled()); } @Override @@ -58,12 +50,13 @@ public Map afterEvaluation(EvaluationSeriesContext seriesContext } /** + * @param windowMillis the dedupe window this hook was registered with, for the status line * @return a line describing this hook's window and how many evaluations have reached it */ - String status() { - return String.format(Locale.US, "%s (%s): %d (before %d / after %d)", + String status(int windowMillis) { + return String.format(Locale.US, "%s (%d ms): %d (before %d / after %d)", label, - dedupeWindowMillis > 0 ? dedupeWindowMillis + " ms" : "no dedupe", + windowMillis, afters.get(), befores.get(), afters.get()); diff --git a/example/src/main/java/com/launchdarkly/example/MainActivity.java b/example/src/main/java/com/launchdarkly/example/MainActivity.java index eaf5d86a..b4780337 100644 --- a/example/src/main/java/com/launchdarkly/example/MainActivity.java +++ b/example/src/main/java/com/launchdarkly/example/MainActivity.java @@ -37,19 +37,21 @@ public class MainActivity extends AppCompatActivity { // Two hooks with different windows, to show that each one is deduplicated on its own. private static final int FAST_DEDUPE_WINDOW_MILLIS = 5_000; - private static final int SLOW_DEDUPE_WINDOW_MILLIS = 60_000; + private static final int SLOW_DEDUPE_WINDOW_MILLIS = 10_000; // The staging hosts mirror the production ones in StandardEndpoints under the ld-stg domain. private static final String STAGING_DOMAIN = "ld-stg.launchdarkly.com"; + private static final String DEFAULT_USER_KEY = "user key"; + private LDClient ldClient; private LDStatusListener ldStatusListener; private LDAllFlagsListener allFlagsListener; private final ExposureCountingHook fastHook = - new ExposureCountingHook("fast", FAST_DEDUPE_WINDOW_MILLIS, this::updateDedupeStatus); + new ExposureCountingHook("fast", this::updateDedupeStatus); private final ExposureCountingHook slowHook = - new ExposureCountingHook("slow", SLOW_DEDUPE_WINDOW_MILLIS, this::updateDedupeStatus); + new ExposureCountingHook("slow", this::updateDedupeStatus); private final AtomicInteger evaluationsRequested = new AtomicInteger(); private static boolean isStaging() { @@ -66,8 +68,8 @@ private void updateDedupeStatus() { "Environment: %s\nEvaluations requested: %d\n%s\n%s", isStaging() ? "staging" : "production", evaluationsRequested.get(), - fastHook.status(), - slowHook.status()); + fastHook.status(FAST_DEDUPE_WINDOW_MILLIS), + slowHook.status(SLOW_DEDUPE_WINDOW_MILLIS)); ((TextView) MainActivity.this.findViewById(R.id.dedupe_status)).setText(result); } @@ -117,8 +119,11 @@ public void onCreate(Bundle savedInstanceState) { // change useReport to `true` if the request is to be REPORT'ed instead of GET'ed ) .hooks( - // Each hook brought its own window, so neither suppresses the other. - Components.hooks().addHook(fastHook).addHook(slowHook) + // Same fluent shape a customer uses for any hook: configure the deduper + // at registration. Each hook has its own window, so neither suppresses the other. + Components.hooks() + .addHook(fastHook.evaluationExposureDeduper(FAST_DEDUPE_WINDOW_MILLIS, 2_000)) + .addHook(slowHook.evaluationExposureDeduper(SLOW_DEDUPE_WINDOW_MILLIS, 2_000)) ); if (isStaging()) { @@ -132,7 +137,7 @@ public void onCreate(Bundle savedInstanceState) { LDConfig ldConfig = configBuilder.build(); - LDContext context = LDContext.builder("user key") + LDContext context = LDContext.builder(DEFAULT_USER_KEY) .set("email", "fake@example.com") .build(); @@ -213,9 +218,16 @@ private void setupIdentifyButton() { Button identify = findViewById(R.id.identify_button); identify.setOnClickListener(v -> { Timber.i("identify onClick"); - String userKey = ((EditText) MainActivity.this.findViewById(R.id.userKey_editText)).getText().toString(); + String typedKey = ((EditText) MainActivity.this.findViewById(R.id.userKey_editText)) + .getText().toString().trim(); + // An empty key builds an invalid context, which identify rejects before it resets the + // hooks' dedupe caches. Fall back to the key the client started with, so identifying to + // an unchanged context still demonstrates that the reset happens either way. + final String userKey = typedKey.isEmpty() ? DEFAULT_USER_KEY : typedKey; final LDContext updatedContext = LDContext.create(userKey); - MainActivity.this.doSafeClientAction(() -> ldClient.identify(updatedContext)); + MainActivity.this.doSafeClientAction(() -> { + ldClient.identify(updatedContext); + }); MainActivity.this.updateDedupeStatus(); }); } From 749508e82d90a803d475520cb6c15b043d52b3cb Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 6 Aug 2026 16:59:40 -0700 Subject: [PATCH 12/29] feat: default the exposure deduper to a 10 minute window over 2000 keys Building a deduper required picking both a window and a cap, with no guidance on what a reasonable window is. Both now have defaults, reachable through a no-argument constructor and a no-argument Hook setter. Co-authored-by: Cursor --- .../EvaluationExposureDeduper.java | 17 ++++++++++++++++- .../sdk/android/integrations/Hook.java | 17 +++++++++++++++++ .../HooksConfigurationBuilder.java | 5 +++-- .../android/EvaluationExposureDeduperTest.java | 18 ++++++++++++++++++ .../sdk/android/HookRunnerTest.java | 13 +++++++++++++ 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java index 964cc07d..968d5978 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -14,7 +14,8 @@ *


  *     Components.hooks()
  *         .addHook(new MetricsHook())                                // told about every evaluation
- *         .addHook(new ObservabilityHook().evaluationExposureDeduper(30_000, 5_000))
+ *         .addHook(new ObservabilityHook().evaluationExposureDeduper())  // default window and cap
+ *         .addHook(new TelemetryHook().evaluationExposureDeduper(30_000, 5_000))
  *         .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper))
  * 
*

@@ -30,6 +31,12 @@ * window that suppresses the rest. */ public class EvaluationExposureDeduper { + /** + * The dedupe window used by a deduper built without a window of its own: 10 minutes, in + * milliseconds. + */ + public static final int DEFAULT_WINDOW_MILLIS = 600_000; + /** * The number of exposure keys tracked by a deduper built without a positive cap of its own: 2000. */ @@ -44,6 +51,14 @@ public class EvaluationExposureDeduper { // the instance lock, as is every access below. private final LinkedHashMap lastRecordedAt = new LinkedHashMap<>(); + /** + * Creates a deduper with a window of {@link #DEFAULT_WINDOW_MILLIS} over at most + * {@link #DEFAULT_MAX_SIZE} exposure keys. + */ + public EvaluationExposureDeduper() { + this(DEFAULT_WINDOW_MILLIS, DEFAULT_MAX_SIZE); + } + /** * @param windowMillis the dedupe window in milliseconds; zero or negative disables * deduplication, so every evaluation reaches the hook diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java index 588a091a..4249cd09 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java @@ -35,6 +35,23 @@ public Hook(String name) { metadata = new HookMetadata(name) {}; } + /** + * Deduplicates this hook's evaluation series with the SDK's implementation, using a window of + * {@link EvaluationExposureDeduper#DEFAULT_WINDOW_MILLIS} over at most + * {@link EvaluationExposureDeduper#DEFAULT_MAX_SIZE} exposure keys. + * + *


+     *     Components.hooks()
+     *         .addHook(new ObservabilityHook().evaluationExposureDeduper())
+     * 
+ * + * @return this hook + * @see #evaluationExposureDeduper(int, int) + */ + public Hook evaluationExposureDeduper() { + return evaluationExposureDeduper(new EvaluationExposureDeduper()); + } + /** * Deduplicates this hook's evaluation series with the SDK's implementation, so that repeated * evaluations resolving to the same result reach it at most once per window. diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java index 869ea5ae..94b034e2 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java @@ -25,12 +25,13 @@ *

* A hook observes every evaluation unless it carries an exposure deduplication policy, which limits * how often repeated evaluations resolving to the same result reach it. See - * {@link Hook#evaluationExposureDeduper(int, int)}. + * {@link Hook#evaluationExposureDeduper()}. * *


  *     Components.hooks()
  *         .addHook(new MetricsHook())
- *         .addHook(new ObservabilityHook().evaluationExposureDeduper(60_000, 2_000))
+ *         .addHook(new ObservabilityHook().evaluationExposureDeduper())
+ *         .addHook(new TelemetryHook().evaluationExposureDeduper(60_000, 2_000))
  *         .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper))
  * 
*

diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java index 54fdece8..2a2d8ad8 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java @@ -120,6 +120,24 @@ public void keepsLiveKeysWhenReclaimingExpiredOnesIsEnough() { } } + @Test + public void usesDefaultWindowAndCapWhenBuiltWithoutParameters() { + // Ten minutes over 2000 keys. + assertEquals(600_000, EvaluationExposureDeduper.DEFAULT_WINDOW_MILLIS); + assertEquals(2_000, EvaluationExposureDeduper.DEFAULT_MAX_SIZE); + + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(); + assertTrue(deduper.shouldRecord("a", 1000)); + assertFalse(deduper.shouldRecord("a", 600_999)); + assertTrue(deduper.shouldRecord("a", 601_000)); + + for (int i = 0; i < EvaluationExposureDeduper.DEFAULT_MAX_SIZE - 1; i++) { + assertTrue(deduper.shouldRecord("key-" + i, 601_000)); + } + // "a" and these keys fill the cap exactly, so nothing has been evicted yet. + assertFalse(deduper.shouldRecord("key-0", 601_000)); + } + @Test public void fallsBackToDefaultCapForNonPositiveMaxSize() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(10_000, 0); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java index 80cc207e..d0155186 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java @@ -113,6 +113,19 @@ public void hookDeduperSkipsBothStagesOfARepeatedEvaluation() { assertEquals(List.of("before", "after"), hook.stages); } + @Test + public void hookDeduperWithoutParametersSkipsARepeatedEvaluation() { + RecordingHook hook = new RecordingHook("deduping"); + hook.evaluationExposureDeduper(); + HookRunner runner = new HookRunner(logging.logger, List.of(hook), + (flagKey, context) -> "exposure-key"); + + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("before", "after"), hook.stages); + } + @Test public void hookWithoutADeduperObservesEveryEvaluation() { RecordingHook hook = new RecordingHook("no-deduper"); From f46f1c9f947dd2bfa755244e57e6e41cd21a9622 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 6 Aug 2026 20:37:04 -0700 Subject: [PATCH 13/29] fix: keep exposure keys distinct across environments A hook set on LDConfig is one instance shared by the clients for every environment in secondaryMobileKeys, and so is its deduper. The exposure key carried no environment identity, so two environments resolving a flag to the same variation of the same version looked like a repeat of each other and only the one evaluating first reached the hook. Co-authored-by: Cursor --- .../sdk/android/LDClientHooksTest.java | 16 ++++++++++++++++ .../sdk/android/EvaluationExposureKey.java | 15 +++++++++++---- .../com/launchdarkly/sdk/android/LDClient.java | 4 ++-- .../integrations/EvaluationExposureDeduper.java | 4 +++- .../sdk/android/integrations/Hook.java | 8 ++++---- .../android/EvaluationExposureDeduperTest.java | 16 +++++++++------- 6 files changed, 45 insertions(+), 18 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java index 7f6c5555..3be3906f 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java @@ -262,6 +262,22 @@ public void hooksWithDifferentWindowsSuppressIndependently() throws Exception { } } + @Test + public void environmentsSharingAHookDoNotSuppressEachOther() throws Exception { + testHook.evaluationExposureDeduper(60_000, 100); + LDConfig config = makeOfflineConfigBuilder(List.of(testHook)) + .secondaryMobileKeys(Collections.singletonMap("other", "other-mobile-key")) + .build(); + try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { + ldClient.boolVariation("test-flag", false); + LDClient.getForMobileKey("other").boolVariation("test-flag", false); + + // Both environments resolve the flag identically, but the hook they share, and so the + // deduper it carries, is told about each of them. + assertEquals(2, testHook.afterEvaluationCalls.size()); + } + } + private LDConfig makeOfflineConfig() { return makeOfflineConfig(null); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureKey.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureKey.java index 9e4f8bd9..7fe17172 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureKey.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureKey.java @@ -12,7 +12,14 @@ private EvaluationExposureKey() {} * needs its own component because the version reported on events is the flag's own version, which * only moves when the flag itself changes: a prerequisite flipping can move an evaluation into or * out of an experiment while it lands on the same variation of the same flag version. + *

+ * The environment leads the key because a hook configured on {@code LDConfig} is one instance + * shared by the clients for every environment in {@code secondaryMobileKeys}, and so is its + * deduper. Without this component, two environments resolving a flag to the same variation of the + * same version would look like a repeat of each other, and only the environment evaluating first + * would reach the hook. * + * @param environmentName the name of the environment being evaluated against * @param flagKey the flag key * @param variation the variation index of the result * @param flagVersion the flag version reported on events @@ -20,9 +27,9 @@ private EvaluationExposureKey() {} * @param fullyQualifiedContextKey the fully qualified key of the evaluation context * @return a stable key identifying the evaluation result */ - static String of(String flagKey, int variation, int flagVersion, boolean inExperiment, - String fullyQualifiedContextKey) { - return flagKey + '\n' + variation + '\n' + flagVersion + '\n' + inExperiment + '\n' - + fullyQualifiedContextKey; + static String of(String environmentName, String flagKey, int variation, int flagVersion, + boolean inExperiment, String fullyQualifiedContextKey) { + return environmentName + '\n' + flagKey + '\n' + variation + '\n' + flagVersion + '\n' + + inExperiment + '\n' + fullyQualifiedContextKey; } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index a5d5cf2a..2e44ecd5 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -770,8 +770,8 @@ private String exposureKey(String flagKey, LDContext context) { int flagVersion = flag == null ? EventProcessor.NO_VERSION : flag.getVersionForEvents(); boolean inExperiment = flag != null && flag.getReason() != null && flag.getReason().isInExperiment(); - return EvaluationExposureKey.of(flagKey, variation, flagVersion, inExperiment, - context.getFullyQualifiedKey()); + return EvaluationExposureKey.of(clientContextImpl.getEnvironmentName(), flagKey, variation, + flagVersion, inExperiment, context.getFullyQualifiedKey()); } /** diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java index 968d5978..9e27243f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -89,7 +89,9 @@ public static EvaluationExposureDeduper disabled() { *

* The SDK calls this once per evaluation per hook. The key identifies the evaluation result: two * evaluations share a key when they resolve to the same variation of the same flag version, with - * the same experiment status, for the same context. + * the same experiment status, for the same context, in the same environment. Evaluations made + * against different environments never share a key, so a hook shared by the clients for several + * environments observes each of them. * * @param key a stable key identifying the evaluation result * @param nowMillis the current time in milliseconds since the epoch diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java index 4249cd09..1e7ee22f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java @@ -56,10 +56,10 @@ public Hook evaluationExposureDeduper() { * Deduplicates this hook's evaluation series with the SDK's implementation, so that repeated * evaluations resolving to the same result reach it at most once per window. *

- * Within the window, this hook observes only a single evaluation per unique combination of flag - * key, variation, flag version, experiment status, and evaluation context. This is useful for - * reducing the telemetry volume produced by frequent re-evaluations, for example a flag that is - * read on every redraw of a view. + * Within the window, this hook observes only a single evaluation per unique combination of + * environment, flag key, variation, flag version, experiment status, and evaluation context. This + * is useful for reducing the telemetry volume produced by frequent re-evaluations, for example a + * flag that is read on every redraw of a view. * *


      *     Components.hooks()
diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java
index 2a2d8ad8..6c114fc0 100644
--- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java
+++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java
@@ -33,14 +33,16 @@ public void disabledRecordsEverythingAndIsSharedAcrossHooks() {
 
     @Test
     public void exposureKeyDistinguishesEveryComponent() {
-        String key = EvaluationExposureKey.of("flag", 1, 2, false, "user-key");
-        assertEquals(key, EvaluationExposureKey.of("flag", 1, 2, false, "user-key"));
-        assertNotEquals(key, EvaluationExposureKey.of("other-flag", 1, 2, false, "user-key"));
-        assertNotEquals(key, EvaluationExposureKey.of("flag", 3, 2, false, "user-key"));
-        assertNotEquals(key, EvaluationExposureKey.of("flag", 1, 4, false, "user-key"));
-        assertNotEquals(key, EvaluationExposureKey.of("flag", 1, 2, false, "other-user-key"));
+        String key = EvaluationExposureKey.of("default", "flag", 1, 2, false, "user-key");
+        assertEquals(key, EvaluationExposureKey.of("default", "flag", 1, 2, false, "user-key"));
+        assertNotEquals(key, EvaluationExposureKey.of("default", "other-flag", 1, 2, false, "user-key"));
+        assertNotEquals(key, EvaluationExposureKey.of("default", "flag", 3, 2, false, "user-key"));
+        assertNotEquals(key, EvaluationExposureKey.of("default", "flag", 1, 4, false, "user-key"));
+        assertNotEquals(key, EvaluationExposureKey.of("default", "flag", 1, 2, false, "other-user-key"));
         // Moving into an experiment on the same variation of the same flag version reports again.
-        assertNotEquals(key, EvaluationExposureKey.of("flag", 1, 2, true, "user-key"));
+        assertNotEquals(key, EvaluationExposureKey.of("default", "flag", 1, 2, true, "user-key"));
+        // A hook shared across environments observes the same result once per environment.
+        assertNotEquals(key, EvaluationExposureKey.of("other-env", "flag", 1, 2, false, "user-key"));
     }
 
     @Test

From 0a444a1d40433a92a2cf4c84b99dcc2ed53dc6d5 Mon Sep 17 00:00:00 2001
From: Andrey Belonogov 
Date: Fri, 7 Aug 2026 15:40:29 -0700
Subject: [PATCH 14/29] refactor: identify exposures with a typed key instead
 of a joined string

Building the exposure key by concatenating its components meant every
evaluation allocated and hashed a string proportional to the flag key,
context key and environment name. EvaluationExposureKey holds the
components instead, hashing them once when the key is built.

The deduper now lets LinkedHashMap evict for it. Each recording
re-inserts its key, so the eldest entry is the one recorded longest ago:
if any tracked window has elapsed, the eldest entry's has, which makes
removeEldestEntry pick the same entry the hand-written reclaim pass did,
in constant time and without the batching it needed to stay amortized.

Co-authored-by: Cursor 
---
 .../sdk/android/EvaluationExposureKey.java    |  35 -----
 .../launchdarkly/sdk/android/HookRunner.java  |  10 +-
 .../launchdarkly/sdk/android/LDClient.java    |   5 +-
 .../EvaluationExposureDeduper.java            |  69 +++------
 .../integrations/EvaluationExposureKey.java   | 131 ++++++++++++++++++
 .../EvaluationExposureDeduperTest.java        | 113 ++++++++-------
 .../sdk/android/HookRunnerTest.java           |  25 ++--
 7 files changed, 239 insertions(+), 149 deletions(-)
 delete mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureKey.java
 create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java

diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureKey.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureKey.java
deleted file mode 100644
index 7fe17172..00000000
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EvaluationExposureKey.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.launchdarkly.sdk.android;
-
-/**
- * Builds the key identifying an evaluation result for exposure deduplication.
- */
-abstract class EvaluationExposureKey {
-    private EvaluationExposureKey() {}
-
-    /**
-     * The variation and version pair is the same identity LaunchDarkly uses to bucket evaluations in
-     * summary events, so two evaluations sharing that pair report identical data. Experiment status
-     * needs its own component because the version reported on events is the flag's own version, which
-     * only moves when the flag itself changes: a prerequisite flipping can move an evaluation into or
-     * out of an experiment while it lands on the same variation of the same flag version.
-     * 

- * The environment leads the key because a hook configured on {@code LDConfig} is one instance - * shared by the clients for every environment in {@code secondaryMobileKeys}, and so is its - * deduper. Without this component, two environments resolving a flag to the same variation of the - * same version would look like a repeat of each other, and only the environment evaluating first - * would reach the hook. - * - * @param environmentName the name of the environment being evaluated against - * @param flagKey the flag key - * @param variation the variation index of the result - * @param flagVersion the flag version reported on events - * @param inExperiment whether the evaluation was part of an experiment rollout - * @param fullyQualifiedContextKey the fully qualified key of the evaluation context - * @return a stable key identifying the evaluation result - */ - static String of(String environmentName, String flagKey, int variation, int flagVersion, - boolean inExperiment, String fullyQualifiedContextKey) { - return environmentName + '\n' + flagKey + '\n' + variation + '\n' + flagVersion + '\n' - + inExperiment + '\n' + fullyQualifiedContextKey; - } -} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java index d4f64bba..d5295fa3 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java @@ -5,11 +5,13 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext; import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; import com.launchdarkly.sdk.android.integrations.TrackSeriesContext; +import com.launchdarkly.sdk.android.subsystems.EventProcessor; import java.util.ArrayList; import java.util.Collections; @@ -33,7 +35,7 @@ public interface AfterIdentifyMethod { */ @FunctionalInterface public interface ExposureKeySupplier { - String exposureKey(String flagKey, LDContext context); + EvaluationExposureKey exposureKey(String flagKey, LDContext context); } /** @@ -61,7 +63,9 @@ private static final class RegisteredHook { private volatile boolean anyDedupeActive = false; public HookRunner(LDLogger logger, List initialHooks) { - this(logger, initialHooks, (flagKey, context) -> ""); + this(logger, initialHooks, (flagKey, context) -> new EvaluationExposureKey( + LDConfig.primaryEnvironmentName, flagKey, EvaluationDetail.NO_VARIATION, + EventProcessor.NO_VERSION, false, context.getFullyQualifiedKey())); } public HookRunner(LDLogger logger, List initialHooks, @@ -122,7 +126,7 @@ private List hooksForEvaluation(String flagKey, LDContext contex return hooks; } - String exposureKey = exposureKeySupplier.exposureKey(flagKey, context); + EvaluationExposureKey exposureKey = exposureKeySupplier.exposureKey(flagKey, context); long nowMillis = System.currentTimeMillis(); List reporting = new ArrayList<>(hooks.size()); for (RegisteredHook registered : hooks) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index 2e44ecd5..6ac08516 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -15,6 +15,7 @@ import com.launchdarkly.sdk.android.env.EnvironmentReporterBuilder; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; import com.launchdarkly.sdk.android.integrations.Plugin; @@ -763,14 +764,14 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ * after stage would leave that span open until something else closed it. The stored flag * identifies the same exposure the result would, since the result is derived from it. */ - private String exposureKey(String flagKey, LDContext context) { + private EvaluationExposureKey exposureKey(String flagKey, LDContext context) { Flag flag = contextDataManager.getNonDeletedFlag(flagKey); int variation = flag == null || flag.getVariation() == null ? EvaluationDetail.NO_VARIATION : flag.getVariation(); int flagVersion = flag == null ? EventProcessor.NO_VERSION : flag.getVersionForEvents(); boolean inExperiment = flag != null && flag.getReason() != null && flag.getReason().isInExperiment(); - return EvaluationExposureKey.of(clientContextImpl.getEnvironmentName(), flagKey, variation, + return new EvaluationExposureKey(clientContextImpl.getEnvironmentName(), flagKey, variation, flagVersion, inExperiment, context.getFullyQualifiedKey()); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java index 9e27243f..8eadaca1 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk.android.integrations; -import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; @@ -20,9 +19,9 @@ *

*

* This class is the SDK's implementation: it records each unique exposure key once per window and - * bounds the number of tracked keys, evicting the least recently recorded ones when the cap is - * exceeded. Subclass it to implement a different policy; only {@link #shouldRecord(String, long)} - * and {@link #reset()} are called by the SDK. + * bounds the number of tracked keys, evicting the least recently recorded one when the cap is + * exceeded. Subclass it to implement a different policy; only + * {@link #shouldRecord(EvaluationExposureKey, long)} and {@link #reset()} are called by the SDK. *

* A deduper is consulted once per evaluation, before the series opens, so a suppressed evaluation * invokes neither {@code beforeEvaluation} nor {@code afterEvaluation}. Implementations must be @@ -47,9 +46,17 @@ public class EvaluationExposureDeduper { private final long windowMillis; private final int maxSize; - // Insertion-ordered so that iteration visits the least recently recorded key first. Guarded by - // the instance lock, as is every access below. - private final LinkedHashMap lastRecordedAt = new LinkedHashMap<>(); + // Insertion-ordered, and each recording re-inserts its key, so the eldest entry is the one + // recorded longest ago. That makes it the right one to evict: if any tracked window has elapsed, + // the eldest entry's has, and dropping it costs nothing because an elapsed window no longer + // suppresses anything. Guarded by the instance lock, as is every access below. + private final LinkedHashMap lastRecordedAt = + new LinkedHashMap() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxSize; + } + }; /** * Creates a deduper with a window of {@link #DEFAULT_WINDOW_MILLIS} over at most @@ -87,17 +94,14 @@ public static EvaluationExposureDeduper disabled() { * Returns whether the hook should be told about the evaluation identified by the given key, and * if so starts a new dedupe window for it. *

- * The SDK calls this once per evaluation per hook. The key identifies the evaluation result: two - * evaluations share a key when they resolve to the same variation of the same flag version, with - * the same experiment status, for the same context, in the same environment. Evaluations made - * against different environments never share a key, so a hook shared by the clients for several - * environments observes each of them. + * The SDK calls this once per evaluation per hook. See {@link EvaluationExposureKey} for what makes + * two evaluations the same exposure. * - * @param key a stable key identifying the evaluation result + * @param key the key identifying the evaluation result * @param nowMillis the current time in milliseconds since the epoch * @return true if the hook should observe this evaluation, false if it should be suppressed */ - public synchronized boolean shouldRecord(String key, long nowMillis) { + public synchronized boolean shouldRecord(EvaluationExposureKey key, long nowMillis) { if (windowMillis <= 0) { return true; } @@ -107,13 +111,10 @@ public synchronized boolean shouldRecord(String key, long nowMillis) { return false; } - // Remove before putting so the key moves to the most recent end of the iteration order. + // Remove before putting so the key moves to the most recent end of the iteration order. The + // map evicts the eldest entry itself once this put takes it past the cap. lastRecordedAt.remove(key); lastRecordedAt.put(key, nowMillis); - - if (lastRecordedAt.size() > maxSize) { - evict(nowMillis); - } return true; } @@ -125,41 +126,13 @@ public synchronized void reset() { lastRecordedAt.clear(); } - private void evict(long nowMillis) { - // Keys whose window has already elapsed no longer change the outcome of shouldRecord, so - // reclaim those first. They sort before any live key, so this stops at the first live one. - long cutoff = nowMillis - windowMillis; - for (Iterator> it = lastRecordedAt.entrySet().iterator(); it.hasNext(); ) { - if (it.next().getValue() > cutoff) { - break; - } - it.remove(); - } - - if (lastRecordedAt.size() <= maxSize) { - // Reclaiming expired keys was enough. Dropping live keys past this point would report - // their next identical evaluation again. - return; - } - - // Evict a batch rather than a single key, so that a workload tracking more live keys than - // maxSize doesn't pay for an eviction on every subsequent exposure. - int dropCount = lastRecordedAt.size() - maxSize + maxSize / 4; - for (Iterator> it = lastRecordedAt.entrySet().iterator(); - dropCount > 0 && it.hasNext(); - dropCount--) { - it.next(); - it.remove(); - } - } - private static final class Disabled extends EvaluationExposureDeduper { Disabled() { super(0, 0); } @Override - public boolean shouldRecord(String key, long nowMillis) { + public boolean shouldRecord(EvaluationExposureKey key, long nowMillis) { return true; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java new file mode 100644 index 00000000..1bd1019e --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java @@ -0,0 +1,131 @@ +package com.launchdarkly.sdk.android.integrations; + +import java.util.Objects; + +/** + * Identifies the evaluation result a hook is about to be told about, so that an + * {@link EvaluationExposureDeduper} can recognize a repeat of it. + *

+ * Two evaluations are the same exposure when every component here matches. The variation and version + * pair is the same identity LaunchDarkly uses to bucket evaluations in summary events, so two + * evaluations sharing that pair report identical data. Experiment status needs its own component + * because the version reported on events is the flag's own version, which only moves when the flag + * itself changes: a prerequisite flipping can move an evaluation into or out of an experiment while it + * lands on the same variation of the same flag version. The environment is a component because a hook + * configured on {@code LDConfig} is one instance shared by the clients for every environment in + * {@code secondaryMobileKeys}, and so is its deduper. + *

+ * Instances are immutable, and their hash code is computed once, when the key is built. + */ +public final class EvaluationExposureKey { + private final String environmentName; + private final String flagKey; + private final int variation; + private final int flagVersion; + private final boolean inExperiment; + private final String fullyQualifiedContextKey; + private final int hashCode; + + /** + * @param environmentName the name of the environment the evaluation was made against + * @param flagKey the flag key + * @param variation the variation index of the result + * @param flagVersion the flag version reported on events + * @param inExperiment whether the evaluation was part of an experiment rollout + * @param fullyQualifiedContextKey the fully qualified key of the evaluation context + */ + public EvaluationExposureKey(String environmentName, String flagKey, int variation, + int flagVersion, boolean inExperiment, + String fullyQualifiedContextKey) { + this.environmentName = environmentName; + this.flagKey = flagKey; + this.variation = variation; + this.flagVersion = flagVersion; + this.inExperiment = inExperiment; + this.fullyQualifiedContextKey = fullyQualifiedContextKey; + + int hash = Objects.hashCode(environmentName); + hash = 31 * hash + Objects.hashCode(flagKey); + hash = 31 * hash + variation; + hash = 31 * hash + flagVersion; + hash = 31 * hash + (inExperiment ? 1 : 0); + this.hashCode = 31 * hash + Objects.hashCode(fullyQualifiedContextKey); + } + + /** + * @return the name of the environment the evaluation was made against + */ + public String getEnvironmentName() { + return environmentName; + } + + /** + * @return the flag key + */ + public String getFlagKey() { + return flagKey; + } + + /** + * @return the variation index of the result + */ + public int getVariation() { + return variation; + } + + /** + * @return the flag version reported on events + */ + public int getFlagVersion() { + return flagVersion; + } + + /** + * @return whether the evaluation was part of an experiment rollout + */ + public boolean isInExperiment() { + return inExperiment; + } + + /** + * @return the fully qualified key of the evaluation context + */ + public String getFullyQualifiedContextKey() { + return fullyQualifiedContextKey; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof EvaluationExposureKey)) { + return false; + } + + EvaluationExposureKey o = (EvaluationExposureKey) other; + // The cached hash codes and the primitives reject unequal keys without touching the strings. + return hashCode == o.hashCode + && variation == o.variation + && flagVersion == o.flagVersion + && inExperiment == o.inExperiment + && Objects.equals(flagKey, o.flagKey) + && Objects.equals(environmentName, o.environmentName) + && Objects.equals(fullyQualifiedContextKey, o.fullyQualifiedContextKey); + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public String toString() { + return "EvaluationExposureKey(environmentName=" + environmentName + + ", flagKey=" + flagKey + + ", variation=" + variation + + ", flagVersion=" + flagVersion + + ", inExperiment=" + inExperiment + + ", fullyQualifiedContextKey=" + fullyQualifiedContextKey + ")"; + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java index 6c114fc0..466e87f3 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java @@ -7,118 +7,128 @@ import static org.junit.Assert.assertTrue; import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; import org.junit.Test; public class EvaluationExposureDeduperTest { + /** + * An exposure key that differs from every other one this test builds only by its flag key, so + * that a test can talk about "the exposure of a" without spelling out the whole key. + */ + private static EvaluationExposureKey key(String flagKey) { + return new EvaluationExposureKey("default", flagKey, 1, 2, false, "user-key"); + } + @Test public void recordsEverythingForNonPositiveWindow() { for (int window : new int[] { 0, -1 }) { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(window, 10); - assertTrue(deduper.shouldRecord("a", 0)); - assertTrue(deduper.shouldRecord("a", 0)); + assertTrue(deduper.shouldRecord(key("a"), 0)); + assertTrue(deduper.shouldRecord(key("a"), 0)); } } @Test public void disabledRecordsEverythingAndIsSharedAcrossHooks() { EvaluationExposureDeduper deduper = EvaluationExposureDeduper.disabled(); - assertTrue(deduper.shouldRecord("a", 1000)); - assertTrue(deduper.shouldRecord("a", 1000)); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + assertTrue(deduper.shouldRecord(key("a"), 1000)); deduper.reset(); - assertTrue(deduper.shouldRecord("a", 1000)); + assertTrue(deduper.shouldRecord(key("a"), 1000)); // The runner recognizes it by identity to skip building exposure keys altogether. assertSame(deduper, EvaluationExposureDeduper.disabled()); } @Test public void exposureKeyDistinguishesEveryComponent() { - String key = EvaluationExposureKey.of("default", "flag", 1, 2, false, "user-key"); - assertEquals(key, EvaluationExposureKey.of("default", "flag", 1, 2, false, "user-key")); - assertNotEquals(key, EvaluationExposureKey.of("default", "other-flag", 1, 2, false, "user-key")); - assertNotEquals(key, EvaluationExposureKey.of("default", "flag", 3, 2, false, "user-key")); - assertNotEquals(key, EvaluationExposureKey.of("default", "flag", 1, 4, false, "user-key")); - assertNotEquals(key, EvaluationExposureKey.of("default", "flag", 1, 2, false, "other-user-key")); + EvaluationExposureKey key = new EvaluationExposureKey("default", "flag", 1, 2, false, "user-key"); + EvaluationExposureKey same = new EvaluationExposureKey("default", "flag", 1, 2, false, "user-key"); + assertEquals(key, same); + assertEquals(key.hashCode(), same.hashCode()); + + assertNotEquals(key, new EvaluationExposureKey("default", "other-flag", 1, 2, false, "user-key")); + assertNotEquals(key, new EvaluationExposureKey("default", "flag", 3, 2, false, "user-key")); + assertNotEquals(key, new EvaluationExposureKey("default", "flag", 1, 4, false, "user-key")); + assertNotEquals(key, new EvaluationExposureKey("default", "flag", 1, 2, false, "other-user-key")); // Moving into an experiment on the same variation of the same flag version reports again. - assertNotEquals(key, EvaluationExposureKey.of("default", "flag", 1, 2, true, "user-key")); + assertNotEquals(key, new EvaluationExposureKey("default", "flag", 1, 2, true, "user-key")); // A hook shared across environments observes the same result once per environment. - assertNotEquals(key, EvaluationExposureKey.of("other-env", "flag", 1, 2, false, "user-key")); + assertNotEquals(key, new EvaluationExposureKey("other-env", "flag", 1, 2, false, "user-key")); } @Test public void suppressesRepeatsWithinWindow() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); - assertTrue(deduper.shouldRecord("a", 1000)); - assertFalse(deduper.shouldRecord("a", 1000)); - assertFalse(deduper.shouldRecord("a", 1099)); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + assertFalse(deduper.shouldRecord(key("a"), 1000)); + assertFalse(deduper.shouldRecord(key("a"), 1099)); } @Test public void recordsAgainOnceWindowElapses() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); - assertTrue(deduper.shouldRecord("a", 1000)); - assertTrue(deduper.shouldRecord("a", 1100)); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + assertTrue(deduper.shouldRecord(key("a"), 1100)); // Recording restarts the window rather than extending the original one. - assertFalse(deduper.shouldRecord("a", 1150)); - assertTrue(deduper.shouldRecord("a", 1200)); + assertFalse(deduper.shouldRecord(key("a"), 1150)); + assertTrue(deduper.shouldRecord(key("a"), 1200)); } @Test public void tracksKeysIndependently() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); - assertTrue(deduper.shouldRecord("a", 1000)); - assertTrue(deduper.shouldRecord("b", 1000)); - assertFalse(deduper.shouldRecord("a", 1000)); - assertFalse(deduper.shouldRecord("b", 1000)); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + assertTrue(deduper.shouldRecord(key("b"), 1000)); + assertFalse(deduper.shouldRecord(key("a"), 1000)); + assertFalse(deduper.shouldRecord(key("b"), 1000)); } @Test public void recordsAgainAfterReset() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); - assertTrue(deduper.shouldRecord("a", 1000)); + assertTrue(deduper.shouldRecord(key("a"), 1000)); deduper.reset(); - assertTrue(deduper.shouldRecord("a", 1000)); + assertTrue(deduper.shouldRecord(key("a"), 1000)); } @Test public void evictsLeastRecentlyRecordedKeysPastCap() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(10_000, 4); for (int i = 0; i < 5; i++) { - assertTrue(deduper.shouldRecord("key-" + i, 1000 + i)); + assertTrue(deduper.shouldRecord(key("key-" + i), 1000 + i)); } // "key-0" was recorded first, so it is the one dropped and can be recorded again, while the // most recently recorded key is still being tracked. - assertTrue(deduper.shouldRecord("key-0", 1010)); - assertFalse(deduper.shouldRecord("key-4", 1010)); + assertTrue(deduper.shouldRecord(key("key-0"), 1010)); + assertFalse(deduper.shouldRecord(key("key-4"), 1010)); } @Test public void reRecordingMovesKeyToMostRecentEndOfEvictionOrder() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 2); - assertTrue(deduper.shouldRecord("a", 1000)); - assertTrue(deduper.shouldRecord("b", 1000)); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + assertTrue(deduper.shouldRecord(key("b"), 1000)); // "a" is re-recorded once its window elapses, which makes "b" the oldest tracked key. - assertTrue(deduper.shouldRecord("a", 1100)); - assertTrue(deduper.shouldRecord("c", 1100)); - assertFalse(deduper.shouldRecord("a", 1100)); + assertTrue(deduper.shouldRecord(key("a"), 1100)); + assertTrue(deduper.shouldRecord(key("c"), 1100)); + assertFalse(deduper.shouldRecord(key("a"), 1100)); } @Test - public void keepsLiveKeysWhenReclaimingExpiredOnesIsEnough() { - // maxSize is 8 so that the batch term (maxSize / 4) is non-zero, which is what makes an - // over-eager batch drop observable. + public void evictionPrefersKeysWhoseWindowHasElapsed() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 8); for (int i = 0; i < 2; i++) { - assertTrue(deduper.shouldRecord("expired-" + i, 1000)); + assertTrue(deduper.shouldRecord(key("expired-" + i), 1000)); } - // The 7th of these exceeds the cap and triggers eviction. Reclaiming the two keys whose - // window has elapsed brings the map back within the cap on its own, so every one of these - // keys is still tracked and none of them should be reported again. + // The 7th of these takes the map past the cap. The keys recorded longest ago are the two + // whose window has since elapsed, so those are the ones evicted and every live key is still + // tracked. for (int i = 0; i < 7; i++) { - assertTrue(deduper.shouldRecord("live-" + i, 1150)); + assertTrue(deduper.shouldRecord(key("live-" + i), 1150)); } for (int i = 0; i < 7; i++) { - assertFalse(deduper.shouldRecord("live-" + i, 1150)); + assertFalse(deduper.shouldRecord(key("live-" + i), 1150)); } } @@ -129,36 +139,37 @@ public void usesDefaultWindowAndCapWhenBuiltWithoutParameters() { assertEquals(2_000, EvaluationExposureDeduper.DEFAULT_MAX_SIZE); EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(); - assertTrue(deduper.shouldRecord("a", 1000)); - assertFalse(deduper.shouldRecord("a", 600_999)); - assertTrue(deduper.shouldRecord("a", 601_000)); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + assertFalse(deduper.shouldRecord(key("a"), 600_999)); + assertTrue(deduper.shouldRecord(key("a"), 601_000)); for (int i = 0; i < EvaluationExposureDeduper.DEFAULT_MAX_SIZE - 1; i++) { - assertTrue(deduper.shouldRecord("key-" + i, 601_000)); + assertTrue(deduper.shouldRecord(key("key-" + i), 601_000)); } // "a" and these keys fill the cap exactly, so nothing has been evicted yet. - assertFalse(deduper.shouldRecord("key-0", 601_000)); + assertFalse(deduper.shouldRecord(key("key-0"), 601_000)); } @Test public void fallsBackToDefaultCapForNonPositiveMaxSize() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(10_000, 0); for (int i = 0; i < EvaluationExposureDeduper.DEFAULT_MAX_SIZE; i++) { - assertTrue(deduper.shouldRecord("key-" + i, 1000)); + assertTrue(deduper.shouldRecord(key("key-" + i), 1000)); } - assertFalse(deduper.shouldRecord("key-0", 1000)); + assertFalse(deduper.shouldRecord(key("key-0"), 1000)); } @Test public void recordsOnceWhenSameKeyIsCheckedConcurrently() throws Exception { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(60_000, 100); + EvaluationExposureKey key = key("a"); int threadCount = 10; Thread[] threads = new Thread[threadCount]; boolean[] recorded = new boolean[threadCount]; for (int i = 0; i < threadCount; i++) { final int index = i; - threads[i] = new Thread(() -> recorded[index] = deduper.shouldRecord("a", 1000)); + threads[i] = new Thread(() -> recorded[index] = deduper.shouldRecord(key, 1000)); } for (Thread thread : threads) { thread.start(); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java index d0155186..c1cc690f 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java @@ -11,6 +11,7 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.HookMetadata; @@ -29,6 +30,10 @@ import java.util.Map; public class HookRunnerTest extends EasyMockSupport { + // Every evaluation in these tests is the same exposure, so the runner's supplier returns this. + private static final EvaluationExposureKey EXPOSURE_KEY = + new EvaluationExposureKey("default", "test-flag", 1, 2, false, "user-123"); + private HookRunner hookRunner; private Hook testHook; @@ -104,7 +109,7 @@ public void hookDeduperSkipsBothStagesOfARepeatedEvaluation() { RecordingHook hook = new RecordingHook("deduping"); hook.evaluationExposureDeduper(60_000, 10); HookRunner runner = new HookRunner(logging.logger, List.of(hook), - (flagKey, context) -> "exposure-key"); + (flagKey, context) -> EXPOSURE_KEY); evaluate(runner); evaluate(runner); @@ -118,7 +123,7 @@ public void hookDeduperWithoutParametersSkipsARepeatedEvaluation() { RecordingHook hook = new RecordingHook("deduping"); hook.evaluationExposureDeduper(); HookRunner runner = new HookRunner(logging.logger, List.of(hook), - (flagKey, context) -> "exposure-key"); + (flagKey, context) -> EXPOSURE_KEY); evaluate(runner); evaluate(runner); @@ -130,7 +135,7 @@ public void hookDeduperWithoutParametersSkipsARepeatedEvaluation() { public void hookWithoutADeduperObservesEveryEvaluation() { RecordingHook hook = new RecordingHook("no-deduper"); HookRunner runner = new HookRunner(logging.logger, List.of(hook), - (flagKey, context) -> "exposure-key"); + (flagKey, context) -> EXPOSURE_KEY); evaluate(runner); evaluate(runner); @@ -144,7 +149,7 @@ public void hooksAreDeduplicatedIndependentlyOfEachOther() { deduping.evaluationExposureDeduper(60_000, 10); RecordingHook reportingEverything = new RecordingHook("reporting-everything"); HookRunner runner = new HookRunner(logging.logger, List.of(deduping, reportingEverything), - (flagKey, context) -> "exposure-key"); + (flagKey, context) -> EXPOSURE_KEY); evaluate(runner); evaluate(runner); @@ -160,7 +165,7 @@ public void hooksGivenSeparateDedupersDoNotSuppressEachOther() { RecordingHook second = new RecordingHook("second"); second.evaluationExposureDeduper(60_000, 10); HookRunner runner = new HookRunner(logging.logger, List.of(first, second), - (flagKey, context) -> "exposure-key"); + (flagKey, context) -> EXPOSURE_KEY); evaluate(runner); evaluate(runner); @@ -178,7 +183,7 @@ public void hooksSharingOneDeduperShareItsWindow() { RecordingHook second = new RecordingHook("second"); second.evaluationExposureDeduper(shared); HookRunner runner = new HookRunner(logging.logger, List.of(first, second), - (flagKey, context) -> "exposure-key"); + (flagKey, context) -> EXPOSURE_KEY); evaluate(runner); @@ -192,7 +197,7 @@ public void resettingDedupersReportsTheSameEvaluationAgain() { RecordingHook hook = new RecordingHook("deduping"); hook.evaluationExposureDeduper(60_000, 10); HookRunner runner = new HookRunner(logging.logger, List.of(hook), - (flagKey, context) -> "exposure-key"); + (flagKey, context) -> EXPOSURE_KEY); evaluate(runner); runner.resetEvaluationExposureDedupers(); @@ -211,7 +216,7 @@ public void buildsTheExposureKeyOncePerEvaluationRegardlessOfHookCount() { HookRunner runner = new HookRunner(logging.logger, List.of(first, second), (flagKey, context) -> { keyRequests.add(flagKey); - return "exposure-key"; + return EXPOSURE_KEY; }); evaluate(runner); @@ -227,7 +232,7 @@ public void doesNotBuildTheExposureKeyWhenNoHookCanSuppress() { HookRunner runner = new HookRunner(logging.logger, List.of(hook), (flagKey, context) -> { keyRequests.add(flagKey); - return "exposure-key"; + return EXPOSURE_KEY; }); evaluate(runner); @@ -241,7 +246,7 @@ public void hookAddedLaterCarriesItsOwnDeduper() { RecordingHook added = new RecordingHook("added"); added.evaluationExposureDeduper(60_000, 10); HookRunner runner = new HookRunner(logging.logger, List.of(), - (flagKey, context) -> "exposure-key"); + (flagKey, context) -> EXPOSURE_KEY); runner.addHook(added); evaluate(runner); From 38806cf103905cfbc275430404ef80e08d28137b Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 7 Aug 2026 15:58:13 -0700 Subject: [PATCH 15/29] refactor: dedupe against a flag's last result rather than every result seen Tracking every distinct result meant a flag that flipped from A to B and back suppressed the return to A, because A's own window was still open, leaving a hook reconstructing a timeline to believe the flag never came back. The deduper now remembers only the result each flag last reported and tells the hook about the flag whenever that result changes, or once the window elapses while it stays the same. The cache is now bounded by the flag set rather than by how many results those flags have taken, which leaves the cap as a safety net that a typical application never reaches. Co-authored-by: Cursor --- .../EvaluationExposureDeduper.java | 124 +++++++++++++++--- .../EvaluationExposureDeduperTest.java | 51 +++++-- 2 files changed, 145 insertions(+), 30 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java index 8eadaca1..21a62cdd 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -2,6 +2,7 @@ import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; /** * Decides whether a hook should be told about an evaluation, so that repeated evaluations resolving @@ -18,10 +19,13 @@ * .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper)) * *

- * This class is the SDK's implementation: it records each unique exposure key once per window and - * bounds the number of tracked keys, evicting the least recently recorded one when the cap is - * exceeded. Subclass it to implement a different policy; only - * {@link #shouldRecord(EvaluationExposureKey, long)} and {@link #reset()} are called by the SDK. + * This class is the SDK's implementation: it remembers the result each flag last reported, and tells + * the hook about the flag again as soon as that result changes, or once the window elapses while it + * stays the same. Tracking one result per flag rather than every result seen keeps a flag that flips + * back and forth from hiding the flips, and bounds the cache by the size of the flag set. The cap is + * a safety net on top of that, evicting the flag recorded longest ago. Subclass this to implement a + * different policy; only {@link #shouldRecord(EvaluationExposureKey, long)} and {@link #reset()} are + * called by the SDK. *

* A deduper is consulted once per evaluation, before the series opens, so a suppressed evaluation * invokes neither {@code beforeEvaluation} nor {@code afterEvaluation}. Implementations must be @@ -37,7 +41,7 @@ public class EvaluationExposureDeduper { public static final int DEFAULT_WINDOW_MILLIS = 600_000; /** - * The number of exposure keys tracked by a deduper built without a positive cap of its own: 2000. + * The number of flags tracked by a deduper built without a positive cap of its own: 2000. */ public static final int DEFAULT_MAX_SIZE = 2_000; @@ -46,14 +50,14 @@ public class EvaluationExposureDeduper { private final long windowMillis; private final int maxSize; - // Insertion-ordered, and each recording re-inserts its key, so the eldest entry is the one + // Insertion-ordered, and each recording re-inserts its flag, so the eldest entry is the flag // recorded longest ago. That makes it the right one to evict: if any tracked window has elapsed, // the eldest entry's has, and dropping it costs nothing because an elapsed window no longer // suppresses anything. Guarded by the instance lock, as is every access below. - private final LinkedHashMap lastRecordedAt = - new LinkedHashMap() { + private final LinkedHashMap lastReported = + new LinkedHashMap() { @Override - protected boolean removeEldestEntry(Map.Entry eldest) { + protected boolean removeEldestEntry(Map.Entry eldest) { return size() > maxSize; } }; @@ -69,8 +73,8 @@ public EvaluationExposureDeduper() { /** * @param windowMillis the dedupe window in milliseconds; zero or negative disables * deduplication, so every evaluation reaches the hook - * @param maxSize the maximum number of exposure keys to track; zero or negative falls back to - * {@link #DEFAULT_MAX_SIZE} + * @param maxSize the maximum number of flags to track, counting a flag once per environment it is + * evaluated in; zero or negative falls back to {@link #DEFAULT_MAX_SIZE} */ public EvaluationExposureDeduper(int windowMillis, int maxSize) { this.windowMillis = windowMillis; @@ -92,10 +96,12 @@ public static EvaluationExposureDeduper disabled() { /** * Returns whether the hook should be told about the evaluation identified by the given key, and - * if so starts a new dedupe window for it. + * if so starts a new dedupe window for the flag. *

- * The SDK calls this once per evaluation per hook. See {@link EvaluationExposureKey} for what makes - * two evaluations the same exposure. + * The SDK calls this once per evaluation per hook. This implementation answers true when the flag + * is reporting a different result than it last did, and when the window has elapsed on the result + * it is repeating. See {@link EvaluationExposureKey} for what makes two evaluations the same + * result. * * @param key the key identifying the evaluation result * @param nowMillis the current time in milliseconds since the epoch @@ -106,15 +112,22 @@ public synchronized boolean shouldRecord(EvaluationExposureKey key, long nowMill return true; } - Long last = lastRecordedAt.get(key); - if (last != null && last > nowMillis - windowMillis) { + TrackedFlag flag = new TrackedFlag(key); + LastReported reported = lastReported.get(flag); + if (reported == null) { + lastReported.put(flag, new LastReported(key, nowMillis)); + return true; + } + if (reported.atMillis > nowMillis - windowMillis && reported.isSameResultAs(key)) { return false; } - // Remove before putting so the key moves to the most recent end of the iteration order. The - // map evicts the eldest entry itself once this put takes it past the cap. - lastRecordedAt.remove(key); - lastRecordedAt.put(key, nowMillis); + // The flag is being reported again, so its record is reused rather than replaced, and + // re-inserted to move it to the most recent end of the iteration order. The map evicts the + // eldest entry itself if that ever takes it past the cap. + reported.update(key, nowMillis); + lastReported.remove(flag); + lastReported.put(flag, reported); return true; } @@ -123,7 +136,76 @@ public synchronized boolean shouldRecord(EvaluationExposureKey key, long nowMill * this when the evaluation context changes. */ public synchronized void reset() { - lastRecordedAt.clear(); + lastReported.clear(); + } + + /** + * The flag a record belongs to. The environment is part of it because a hook set on the + * configuration is one instance shared by the clients for every environment in + * {@code secondaryMobileKeys}: were the environments to share a record, each would look like the + * other having changed its result, and neither would ever be suppressed. + */ + private static final class TrackedFlag { + private final String environmentName; + private final String flagKey; + private final int hashCode; + + TrackedFlag(EvaluationExposureKey key) { + this.environmentName = key.getEnvironmentName(); + this.flagKey = key.getFlagKey(); + this.hashCode = 31 * Objects.hashCode(environmentName) + Objects.hashCode(flagKey); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof TrackedFlag)) { + return false; + } + + TrackedFlag o = (TrackedFlag) other; + return hashCode == o.hashCode + && Objects.equals(flagKey, o.flagKey) + && Objects.equals(environmentName, o.environmentName); + } + + @Override + public int hashCode() { + return hashCode; + } + } + + /** + * The result a flag last reported, and when. Mutable so that a flag costs one record for as long + * as it is tracked, however often its result changes. + */ + private static final class LastReported { + private int variation; + private int flagVersion; + private boolean inExperiment; + private String fullyQualifiedContextKey; + private long atMillis; + + LastReported(EvaluationExposureKey key, long atMillis) { + update(key, atMillis); + } + + void update(EvaluationExposureKey key, long atMillis) { + this.variation = key.getVariation(); + this.flagVersion = key.getFlagVersion(); + this.inExperiment = key.isInExperiment(); + this.fullyQualifiedContextKey = key.getFullyQualifiedContextKey(); + this.atMillis = atMillis; + } + + boolean isSameResultAs(EvaluationExposureKey key) { + return variation == key.getVariation() + && flagVersion == key.getFlagVersion() + && inExperiment == key.isInExperiment() + && Objects.equals(fullyQualifiedContextKey, key.getFullyQualifiedContextKey()); + } } private static final class Disabled extends EvaluationExposureDeduper { diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java index 466e87f3..d62c5591 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java @@ -20,6 +20,13 @@ private static EvaluationExposureKey key(String flagKey) { return new EvaluationExposureKey("default", flagKey, 1, 2, false, "user-key"); } + /** + * The same flag as {@link #key(String)}, resolved to a different variation. + */ + private static EvaluationExposureKey otherResult(String flagKey) { + return new EvaluationExposureKey("default", flagKey, 3, 2, false, "user-key"); + } + @Test public void recordsEverythingForNonPositiveWindow() { for (int window : new int[] { 0, -1 }) { @@ -76,7 +83,7 @@ public void recordsAgainOnceWindowElapses() { } @Test - public void tracksKeysIndependently() { + public void tracksFlagsIndependently() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); assertTrue(deduper.shouldRecord(key("a"), 1000)); assertTrue(deduper.shouldRecord(key("b"), 1000)); @@ -84,6 +91,32 @@ public void tracksKeysIndependently() { assertFalse(deduper.shouldRecord(key("b"), 1000)); } + @Test + public void reportsAFlagAgainAsSoonAsItsResultChanges() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + assertTrue(deduper.shouldRecord(otherResult("a"), 1010)); + assertFalse(deduper.shouldRecord(otherResult("a"), 1020)); + // Only the result the flag reported last is tracked, so flipping back is a change too and the + // hook is told about it rather than being left to think the flag never returned to it. + assertTrue(deduper.shouldRecord(key("a"), 1030)); + assertFalse(deduper.shouldRecord(key("a"), 1040)); + } + + @Test + public void tracksTheSameFlagSeparatelyPerEnvironment() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); + EvaluationExposureKey primary = new EvaluationExposureKey("default", "flag", 1, 2, false, "user-key"); + EvaluationExposureKey secondary = new EvaluationExposureKey("other", "flag", 3, 4, false, "user-key"); + + // A hook set on the configuration is shared by the clients for every environment, so its + // deduper sees both. Neither environment may look to the other like its result changing. + assertTrue(deduper.shouldRecord(primary, 1000)); + assertTrue(deduper.shouldRecord(secondary, 1000)); + assertFalse(deduper.shouldRecord(primary, 1010)); + assertFalse(deduper.shouldRecord(secondary, 1010)); + } + @Test public void recordsAgainAfterReset() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); @@ -93,36 +126,36 @@ public void recordsAgainAfterReset() { } @Test - public void evictsLeastRecentlyRecordedKeysPastCap() { + public void evictsTheFlagRecordedLongestAgoPastCap() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(10_000, 4); for (int i = 0; i < 5; i++) { assertTrue(deduper.shouldRecord(key("key-" + i), 1000 + i)); } // "key-0" was recorded first, so it is the one dropped and can be recorded again, while the - // most recently recorded key is still being tracked. + // most recently recorded flag is still being tracked. assertTrue(deduper.shouldRecord(key("key-0"), 1010)); assertFalse(deduper.shouldRecord(key("key-4"), 1010)); } @Test - public void reRecordingMovesKeyToMostRecentEndOfEvictionOrder() { + public void reRecordingMovesAFlagToMostRecentEndOfEvictionOrder() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 2); assertTrue(deduper.shouldRecord(key("a"), 1000)); assertTrue(deduper.shouldRecord(key("b"), 1000)); - // "a" is re-recorded once its window elapses, which makes "b" the oldest tracked key. + // "a" is re-recorded once its window elapses, which makes "b" the oldest tracked flag. assertTrue(deduper.shouldRecord(key("a"), 1100)); assertTrue(deduper.shouldRecord(key("c"), 1100)); assertFalse(deduper.shouldRecord(key("a"), 1100)); } @Test - public void evictionPrefersKeysWhoseWindowHasElapsed() { + public void evictionPrefersFlagsWhoseWindowHasElapsed() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 8); for (int i = 0; i < 2; i++) { assertTrue(deduper.shouldRecord(key("expired-" + i), 1000)); } - // The 7th of these takes the map past the cap. The keys recorded longest ago are the two - // whose window has since elapsed, so those are the ones evicted and every live key is still + // The 7th of these takes the map past the cap. The flags recorded longest ago are the two + // whose window has since elapsed, so those are the ones evicted and every live flag is still // tracked. for (int i = 0; i < 7; i++) { assertTrue(deduper.shouldRecord(key("live-" + i), 1150)); @@ -146,7 +179,7 @@ public void usesDefaultWindowAndCapWhenBuiltWithoutParameters() { for (int i = 0; i < EvaluationExposureDeduper.DEFAULT_MAX_SIZE - 1; i++) { assertTrue(deduper.shouldRecord(key("key-" + i), 601_000)); } - // "a" and these keys fill the cap exactly, so nothing has been evicted yet. + // "a" and these flags fill the cap exactly, so nothing has been evicted yet. assertFalse(deduper.shouldRecord(key("key-0"), 601_000)); } From c71a02a348b0c0f5dff16e988012e25ea835b25f Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 7 Aug 2026 16:45:53 -0700 Subject: [PATCH 16/29] refactor: stop exposing a cap on how many results a deduper tracks Tracking one result per flag means the cache is already bounded by the flag set, so a cap was a knob with nothing to tune: the SDK now keeps its own bound of 2000 flags, which only an application that generates flag keys rather than naming them can reach. The window is all a hook configures. Co-authored-by: Cursor --- .../sdk/android/LDClientEventTest.java | 2 +- .../sdk/android/LDClientHooksTest.java | 10 ++--- .../EvaluationExposureDeduper.java | 45 ++++++++++--------- .../sdk/android/integrations/Hook.java | 20 ++++----- .../HooksConfigurationBuilder.java | 2 +- .../sdk/android/HookRunnerTest.java | 18 ++++---- .../EvaluationExposureDeduperTest.java | 35 ++++++++------- 7 files changed, 66 insertions(+), 66 deletions(-) rename launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/{ => integrations}/EvaluationExposureDeduperTest.java (91%) diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java index 38b259b7..ad76e193 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java @@ -253,7 +253,7 @@ public void exposureDeduplicationDoesNotSuppressEvaluationEvents() throws IOExce // Deduplication applies to hooks only, so a hook given a window wide enough to suppress // every repeat must still leave the analytics events untouched. Hook dedupingHook = new Hook("deduping-hook") {}; - dedupingHook.evaluationExposureDeduper(60_000, 100); + dedupingHook.evaluationExposureDeduper(60_000); LDConfig ldConfig = baseConfigBuilder(mockEventsServer) .persistentDataStore(store) .hooks(Components.hooks().addHook(dedupingHook)) diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java index 3be3906f..d934b842 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java @@ -194,7 +194,7 @@ public void repeatedEvaluationsReachAHookThatAskedForNoDedupe() throws Exception @Test public void repeatedEvaluationsAreDeduplicatedWithinTheHooksWindow() throws Exception { - testHook.evaluationExposureDeduper(60_000, 100); + testHook.evaluationExposureDeduper(60_000); try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testHook)), ldContext, 1)) { for (int i = 0; i < 3; i++) { ldClient.boolVariation("test-flag", false); @@ -208,7 +208,7 @@ public void repeatedEvaluationsAreDeduplicatedWithinTheHooksWindow() throws Exce @Test public void identifyResetsEvaluationExposureDedupeCache() throws Exception { - testHook.evaluationExposureDeduper(60_000, 100); + testHook.evaluationExposureDeduper(60_000); try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testHook)), ldContext, 1)) { ldClient.boolVariation("test-flag", false); ldClient.boolVariation("test-flag", false); @@ -225,7 +225,7 @@ public void identifyResetsEvaluationExposureDedupeCache() throws Exception { @Test public void evaluationsOfDifferentFlagsReachHooksSeparately() throws Exception { - testHook.evaluationExposureDeduper(60_000, 100); + testHook.evaluationExposureDeduper(60_000); try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testHook)), ldContext, 1)) { ldClient.boolVariation("test-flag", false); ldClient.boolVariation("other-flag", false); @@ -238,7 +238,7 @@ public void evaluationsOfDifferentFlagsReachHooksSeparately() throws Exception { @Test public void hooksWithDifferentWindowsSuppressIndependently() throws Exception { MockHook deduping = new MockHook(); - deduping.evaluationExposureDeduper(60_000, 100); + deduping.evaluationExposureDeduper(60_000); MockHook reportingEverything = new MockHook(); reportingEverything.evaluationExposureDeduper(EvaluationExposureDeduper.disabled()); LDConfig config = makeOfflineConfigBuilder(null) @@ -264,7 +264,7 @@ public void hooksWithDifferentWindowsSuppressIndependently() throws Exception { @Test public void environmentsSharingAHookDoNotSuppressEachOther() throws Exception { - testHook.evaluationExposureDeduper(60_000, 100); + testHook.evaluationExposureDeduper(60_000); LDConfig config = makeOfflineConfigBuilder(List.of(testHook)) .secondaryMobileKeys(Collections.singletonMap("other", "other-mobile-key")) .build(); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java index 21a62cdd..8de60b64 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.android.integrations; +import androidx.annotation.VisibleForTesting; + import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; @@ -9,23 +11,22 @@ * to the same result do not invoke the hook again within a time window. *

* Deduplication is opt-in per hook: a hook is told about every evaluation until you give it a - * deduper with {@link Hook#evaluationExposureDeduper(int, int)}. + * deduper with {@link Hook#evaluationExposureDeduper(int)}. * *


  *     Components.hooks()
  *         .addHook(new MetricsHook())                                // told about every evaluation
- *         .addHook(new ObservabilityHook().evaluationExposureDeduper())  // default window and cap
- *         .addHook(new TelemetryHook().evaluationExposureDeduper(30_000, 5_000))
+ *         .addHook(new ObservabilityHook().evaluationExposureDeduper())  // default window
+ *         .addHook(new TelemetryHook().evaluationExposureDeduper(30_000))
  *         .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper))
  * 
*

* This class is the SDK's implementation: it remembers the result each flag last reported, and tells * the hook about the flag again as soon as that result changes, or once the window elapses while it * stays the same. Tracking one result per flag rather than every result seen keeps a flag that flips - * back and forth from hiding the flips, and bounds the cache by the size of the flag set. The cap is - * a safety net on top of that, evicting the flag recorded longest ago. Subclass this to implement a - * different policy; only {@link #shouldRecord(EvaluationExposureKey, long)} and {@link #reset()} are - * called by the SDK. + * back and forth from hiding the flips, and bounds the cache by the size of the flag set, so the + * window is the only thing there is to configure. Subclass this to implement a different policy; only + * {@link #shouldRecord(EvaluationExposureKey, long)} and {@link #reset()} are called by the SDK. *

* A deduper is consulted once per evaluation, before the series opens, so a suppressed evaluation * invokes neither {@code beforeEvaluation} nor {@code afterEvaluation}. Implementations must be @@ -40,15 +41,15 @@ public class EvaluationExposureDeduper { */ public static final int DEFAULT_WINDOW_MILLIS = 600_000; - /** - * The number of flags tracked by a deduper built without a positive cap of its own: 2000. - */ - public static final int DEFAULT_MAX_SIZE = 2_000; + // Far more flags than an application evaluates, so this is never reached by tracking a flag set. + // It is here for an application that builds flag keys rather than naming them, which would + // otherwise grow the cache for as long as it kept generating them. + private static final int MAX_TRACKED_FLAGS = 2_000; private static final EvaluationExposureDeduper DISABLED = new Disabled(); private final long windowMillis; - private final int maxSize; + private final int maxTrackedFlags; // Insertion-ordered, and each recording re-inserts its flag, so the eldest entry is the flag // recorded longest ago. That makes it the right one to evict: if any tracked window has elapsed, @@ -58,27 +59,29 @@ public class EvaluationExposureDeduper { new LinkedHashMap() { @Override protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > maxSize; + return size() > maxTrackedFlags; } }; /** - * Creates a deduper with a window of {@link #DEFAULT_WINDOW_MILLIS} over at most - * {@link #DEFAULT_MAX_SIZE} exposure keys. + * Creates a deduper with a window of {@link #DEFAULT_WINDOW_MILLIS}. */ public EvaluationExposureDeduper() { - this(DEFAULT_WINDOW_MILLIS, DEFAULT_MAX_SIZE); + this(DEFAULT_WINDOW_MILLIS); } /** * @param windowMillis the dedupe window in milliseconds; zero or negative disables * deduplication, so every evaluation reaches the hook - * @param maxSize the maximum number of flags to track, counting a flag once per environment it is - * evaluated in; zero or negative falls back to {@link #DEFAULT_MAX_SIZE} */ - public EvaluationExposureDeduper(int windowMillis, int maxSize) { + public EvaluationExposureDeduper(int windowMillis) { + this(windowMillis, MAX_TRACKED_FLAGS); + } + + @VisibleForTesting + EvaluationExposureDeduper(int windowMillis, int maxTrackedFlags) { this.windowMillis = windowMillis; - this.maxSize = maxSize > 0 ? maxSize : DEFAULT_MAX_SIZE; + this.maxTrackedFlags = maxTrackedFlags; } /** @@ -210,7 +213,7 @@ boolean isSameResultAs(EvaluationExposureKey key) { private static final class Disabled extends EvaluationExposureDeduper { Disabled() { - super(0, 0); + super(0); } @Override diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java index 1e7ee22f..eee933a2 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java @@ -37,8 +37,7 @@ public Hook(String name) { /** * Deduplicates this hook's evaluation series with the SDK's implementation, using a window of - * {@link EvaluationExposureDeduper#DEFAULT_WINDOW_MILLIS} over at most - * {@link EvaluationExposureDeduper#DEFAULT_MAX_SIZE} exposure keys. + * {@link EvaluationExposureDeduper#DEFAULT_WINDOW_MILLIS}. * *


      *     Components.hooks()
@@ -46,7 +45,7 @@ public Hook(String name) {
      * 
* * @return this hook - * @see #evaluationExposureDeduper(int, int) + * @see #evaluationExposureDeduper(int) */ public Hook evaluationExposureDeduper() { return evaluationExposureDeduper(new EvaluationExposureDeduper()); @@ -56,24 +55,21 @@ public Hook evaluationExposureDeduper() { * Deduplicates this hook's evaluation series with the SDK's implementation, so that repeated * evaluations resolving to the same result reach it at most once per window. *

- * Within the window, this hook observes only a single evaluation per unique combination of - * environment, flag key, variation, flag version, experiment status, and evaluation context. This - * is useful for reducing the telemetry volume produced by frequent re-evaluations, for example a - * flag that is read on every redraw of a view. + * This hook observes a flag when its result changes, and at most once per window while the result + * stays the same. This is useful for reducing the telemetry volume produced by frequent + * re-evaluations, for example a flag that is read on every redraw of a view. * *


      *     Components.hooks()
-     *         .addHook(new ObservabilityHook().evaluationExposureDeduper(60_000, 2_000))
+     *         .addHook(new ObservabilityHook().evaluationExposureDeduper(60_000))
      * 
* * @param windowMillis the dedupe window in milliseconds; zero or negative reports every * evaluation - * @param maxSize the maximum number of exposure keys to track; zero or negative uses - * {@link EvaluationExposureDeduper#DEFAULT_MAX_SIZE} * @return this hook */ - public Hook evaluationExposureDeduper(int windowMillis, int maxSize) { - return evaluationExposureDeduper(new EvaluationExposureDeduper(windowMillis, maxSize)); + public Hook evaluationExposureDeduper(int windowMillis) { + return evaluationExposureDeduper(new EvaluationExposureDeduper(windowMillis)); } /** diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java index 94b034e2..abb545c2 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java @@ -31,7 +31,7 @@ * Components.hooks() * .addHook(new MetricsHook()) * .addHook(new ObservabilityHook().evaluationExposureDeduper()) - * .addHook(new TelemetryHook().evaluationExposureDeduper(60_000, 2_000)) + * .addHook(new TelemetryHook().evaluationExposureDeduper(60_000)) * .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper)) * *

diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java index c1cc690f..3d3ad5a6 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java @@ -107,7 +107,7 @@ private void evaluate(HookRunner runner) { @Test public void hookDeduperSkipsBothStagesOfARepeatedEvaluation() { RecordingHook hook = new RecordingHook("deduping"); - hook.evaluationExposureDeduper(60_000, 10); + hook.evaluationExposureDeduper(60_000); HookRunner runner = new HookRunner(logging.logger, List.of(hook), (flagKey, context) -> EXPOSURE_KEY); @@ -146,7 +146,7 @@ public void hookWithoutADeduperObservesEveryEvaluation() { @Test public void hooksAreDeduplicatedIndependentlyOfEachOther() { RecordingHook deduping = new RecordingHook("deduping"); - deduping.evaluationExposureDeduper(60_000, 10); + deduping.evaluationExposureDeduper(60_000); RecordingHook reportingEverything = new RecordingHook("reporting-everything"); HookRunner runner = new HookRunner(logging.logger, List.of(deduping, reportingEverything), (flagKey, context) -> EXPOSURE_KEY); @@ -161,9 +161,9 @@ public void hooksAreDeduplicatedIndependentlyOfEachOther() { @Test public void hooksGivenSeparateDedupersDoNotSuppressEachOther() { RecordingHook first = new RecordingHook("first"); - first.evaluationExposureDeduper(60_000, 10); + first.evaluationExposureDeduper(60_000); RecordingHook second = new RecordingHook("second"); - second.evaluationExposureDeduper(60_000, 10); + second.evaluationExposureDeduper(60_000); HookRunner runner = new HookRunner(logging.logger, List.of(first, second), (flagKey, context) -> EXPOSURE_KEY); @@ -177,7 +177,7 @@ public void hooksGivenSeparateDedupersDoNotSuppressEachOther() { @Test public void hooksSharingOneDeduperShareItsWindow() { - EvaluationExposureDeduper shared = new EvaluationExposureDeduper(60_000, 10); + EvaluationExposureDeduper shared = new EvaluationExposureDeduper(60_000); RecordingHook first = new RecordingHook("first"); first.evaluationExposureDeduper(shared); RecordingHook second = new RecordingHook("second"); @@ -195,7 +195,7 @@ public void hooksSharingOneDeduperShareItsWindow() { @Test public void resettingDedupersReportsTheSameEvaluationAgain() { RecordingHook hook = new RecordingHook("deduping"); - hook.evaluationExposureDeduper(60_000, 10); + hook.evaluationExposureDeduper(60_000); HookRunner runner = new HookRunner(logging.logger, List.of(hook), (flagKey, context) -> EXPOSURE_KEY); @@ -209,9 +209,9 @@ public void resettingDedupersReportsTheSameEvaluationAgain() { @Test public void buildsTheExposureKeyOncePerEvaluationRegardlessOfHookCount() { RecordingHook first = new RecordingHook("first"); - first.evaluationExposureDeduper(60_000, 10); + first.evaluationExposureDeduper(60_000); RecordingHook second = new RecordingHook("second"); - second.evaluationExposureDeduper(60_000, 10); + second.evaluationExposureDeduper(60_000); List keyRequests = new ArrayList<>(); HookRunner runner = new HookRunner(logging.logger, List.of(first, second), (flagKey, context) -> { @@ -244,7 +244,7 @@ public void doesNotBuildTheExposureKeyWhenNoHookCanSuppress() { @Test public void hookAddedLaterCarriesItsOwnDeduper() { RecordingHook added = new RecordingHook("added"); - added.evaluationExposureDeduper(60_000, 10); + added.evaluationExposureDeduper(60_000); HookRunner runner = new HookRunner(logging.logger, List.of(), (flagKey, context) -> EXPOSURE_KEY); runner.addHook(added); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduperTest.java similarity index 91% rename from launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java rename to launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduperTest.java index d62c5591..73c2ff3b 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduperTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.sdk.android; +package com.launchdarkly.sdk.android.integrations; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -6,11 +6,13 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; -import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; -import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; - import org.junit.Test; +/** + * Lives in the {@code integrations} package to reach the constructor that takes the bound on how many + * flags are tracked, which the SDK sets for itself rather than exposing. + */ + public class EvaluationExposureDeduperTest { /** * An exposure key that differs from every other one this test builds only by its flag key, so @@ -166,30 +168,29 @@ public void evictionPrefersFlagsWhoseWindowHasElapsed() { } @Test - public void usesDefaultWindowAndCapWhenBuiltWithoutParameters() { - // Ten minutes over 2000 keys. + public void usesTheDefaultWindowWhenBuiltWithoutOne() { + // Ten minutes. assertEquals(600_000, EvaluationExposureDeduper.DEFAULT_WINDOW_MILLIS); - assertEquals(2_000, EvaluationExposureDeduper.DEFAULT_MAX_SIZE); EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(); assertTrue(deduper.shouldRecord(key("a"), 1000)); assertFalse(deduper.shouldRecord(key("a"), 600_999)); assertTrue(deduper.shouldRecord(key("a"), 601_000)); - - for (int i = 0; i < EvaluationExposureDeduper.DEFAULT_MAX_SIZE - 1; i++) { - assertTrue(deduper.shouldRecord(key("key-" + i), 601_000)); - } - // "a" and these flags fill the cap exactly, so nothing has been evicted yet. - assertFalse(deduper.shouldRecord(key("key-0"), 601_000)); } @Test - public void fallsBackToDefaultCapForNonPositiveMaxSize() { - EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(10_000, 0); - for (int i = 0; i < EvaluationExposureDeduper.DEFAULT_MAX_SIZE; i++) { + public void boundsHowManyFlagsItTracks() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(600_000); + for (int i = 0; i < 2_000; i++) { assertTrue(deduper.shouldRecord(key("key-" + i), 1000)); } - assertFalse(deduper.shouldRecord(key("key-0"), 1000)); + + // Nothing about the bound is configurable, because tracking one result per flag already keeps + // the cache to the size of the flag set. It is only reached by an application that generates + // flag keys, and then it evicts the flag recorded longest ago. + assertFalse(deduper.shouldRecord(key("key-1999"), 1000)); + assertTrue(deduper.shouldRecord(key("key-2000"), 1000)); + assertTrue(deduper.shouldRecord(key("key-0"), 1000)); } @Test From 48c8b515e303a1bdc2b30ef964d329ffdb00e0be Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 7 Aug 2026 16:52:14 -0700 Subject: [PATCH 17/29] chore: drop the tracked-result cap from the example app Follows the deduper no longer taking one. Co-authored-by: Cursor --- .../java/com/launchdarkly/example/ExposureCountingHook.java | 2 +- .../src/main/java/com/launchdarkly/example/MainActivity.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java b/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java index 76752c3f..a2a971ed 100644 --- a/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java +++ b/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java @@ -12,7 +12,7 @@ /** * Counts the evaluation series stages it observes, so the example can show what exposure * deduplication does. Deduplication is configured at registration with - * {@link Hook#evaluationExposureDeduper(int, int)}, the same way a customer would configure any + * {@link Hook#evaluationExposureDeduper(int)}, the same way a customer would configure any * other hook. *

* Deduplication skips the whole series, so both counts stay equal and both stop climbing while diff --git a/example/src/main/java/com/launchdarkly/example/MainActivity.java b/example/src/main/java/com/launchdarkly/example/MainActivity.java index b4780337..2c144b84 100644 --- a/example/src/main/java/com/launchdarkly/example/MainActivity.java +++ b/example/src/main/java/com/launchdarkly/example/MainActivity.java @@ -122,8 +122,8 @@ public void onCreate(Bundle savedInstanceState) { // Same fluent shape a customer uses for any hook: configure the deduper // at registration. Each hook has its own window, so neither suppresses the other. Components.hooks() - .addHook(fastHook.evaluationExposureDeduper(FAST_DEDUPE_WINDOW_MILLIS, 2_000)) - .addHook(slowHook.evaluationExposureDeduper(SLOW_DEDUPE_WINDOW_MILLIS, 2_000)) + .addHook(fastHook.evaluationExposureDeduper(FAST_DEDUPE_WINDOW_MILLIS)) + .addHook(slowHook.evaluationExposureDeduper(SLOW_DEDUPE_WINDOW_MILLIS)) ); if (isStaging()) { From d23db67bbaa52b52a3bd660cf2488d1af6a2f916 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 7 Aug 2026 17:03:16 -0700 Subject: [PATCH 18/29] refactor: stop bounding how many flags a deduper tracks A record per flag, in each environment it is evaluated in, is the flag set the environments serve, which LaunchDarkly already bounds. Evicting from it only cost the hook a suppression it should have had, so the map is now a plain HashMap and the record for a flag is updated in place. Co-authored-by: Cursor --- .../EvaluationExposureDeduper.java | 45 +++-------- .../EvaluationExposureDeduperTest.java | 75 ++++--------------- 2 files changed, 26 insertions(+), 94 deletions(-) rename launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/{integrations => }/EvaluationExposureDeduperTest.java (73%) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java index 8de60b64..20d80693 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -1,8 +1,6 @@ package com.launchdarkly.sdk.android.integrations; -import androidx.annotation.VisibleForTesting; - -import java.util.LinkedHashMap; +import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -24,9 +22,10 @@ * This class is the SDK's implementation: it remembers the result each flag last reported, and tells * the hook about the flag again as soon as that result changes, or once the window elapses while it * stays the same. Tracking one result per flag rather than every result seen keeps a flag that flips - * back and forth from hiding the flips, and bounds the cache by the size of the flag set, so the - * window is the only thing there is to configure. Subclass this to implement a different policy; only - * {@link #shouldRecord(EvaluationExposureKey, long)} and {@link #reset()} are called by the SDK. + * back and forth from hiding the flips, and holds one record per flag the application evaluates, so + * the window is the only thing there is to configure. Subclass this to implement a different policy; + * only {@link #shouldRecord(EvaluationExposureKey, long)} and {@link #reset()} are called by the + * SDK. *

* A deduper is consulted once per evaluation, before the series opens, so a suppressed evaluation * invokes neither {@code beforeEvaluation} nor {@code afterEvaluation}. Implementations must be @@ -41,27 +40,14 @@ public class EvaluationExposureDeduper { */ public static final int DEFAULT_WINDOW_MILLIS = 600_000; - // Far more flags than an application evaluates, so this is never reached by tracking a flag set. - // It is here for an application that builds flag keys rather than naming them, which would - // otherwise grow the cache for as long as it kept generating them. - private static final int MAX_TRACKED_FLAGS = 2_000; - private static final EvaluationExposureDeduper DISABLED = new Disabled(); private final long windowMillis; - private final int maxTrackedFlags; - - // Insertion-ordered, and each recording re-inserts its flag, so the eldest entry is the flag - // recorded longest ago. That makes it the right one to evict: if any tracked window has elapsed, - // the eldest entry's has, and dropping it costs nothing because an elapsed window no longer - // suppresses anything. Guarded by the instance lock, as is every access below. - private final LinkedHashMap lastReported = - new LinkedHashMap() { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > maxTrackedFlags; - } - }; + + // Holds one record per flag the application evaluates, in each environment it evaluates it in. + // Nothing is evicted, because that set is the flags the environment serves. Guarded by the + // instance lock, as is every access below. + private final Map lastReported = new HashMap<>(); /** * Creates a deduper with a window of {@link #DEFAULT_WINDOW_MILLIS}. @@ -75,13 +61,7 @@ public EvaluationExposureDeduper() { * deduplication, so every evaluation reaches the hook */ public EvaluationExposureDeduper(int windowMillis) { - this(windowMillis, MAX_TRACKED_FLAGS); - } - - @VisibleForTesting - EvaluationExposureDeduper(int windowMillis, int maxTrackedFlags) { this.windowMillis = windowMillis; - this.maxTrackedFlags = maxTrackedFlags; } /** @@ -125,12 +105,7 @@ public synchronized boolean shouldRecord(EvaluationExposureKey key, long nowMill return false; } - // The flag is being reported again, so its record is reused rather than replaced, and - // re-inserted to move it to the most recent end of the iteration order. The map evicts the - // eldest entry itself if that ever takes it past the cap. reported.update(key, nowMillis); - lastReported.remove(flag); - lastReported.put(flag, reported); return true; } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java similarity index 73% rename from launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduperTest.java rename to launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java index 73c2ff3b..d77216b5 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.sdk.android.integrations; +package com.launchdarkly.sdk.android; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -6,12 +6,11 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; + import org.junit.Test; -/** - * Lives in the {@code integrations} package to reach the constructor that takes the bound on how many - * flags are tracked, which the SDK sets for itself rather than exposing. - */ public class EvaluationExposureDeduperTest { /** @@ -32,7 +31,7 @@ private static EvaluationExposureKey otherResult(String flagKey) { @Test public void recordsEverythingForNonPositiveWindow() { for (int window : new int[] { 0, -1 }) { - EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(window, 10); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(window); assertTrue(deduper.shouldRecord(key("a"), 0)); assertTrue(deduper.shouldRecord(key("a"), 0)); } @@ -68,7 +67,7 @@ public void exposureKeyDistinguishesEveryComponent() { @Test public void suppressesRepeatsWithinWindow() { - EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); assertTrue(deduper.shouldRecord(key("a"), 1000)); assertFalse(deduper.shouldRecord(key("a"), 1000)); assertFalse(deduper.shouldRecord(key("a"), 1099)); @@ -76,7 +75,7 @@ public void suppressesRepeatsWithinWindow() { @Test public void recordsAgainOnceWindowElapses() { - EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); assertTrue(deduper.shouldRecord(key("a"), 1000)); assertTrue(deduper.shouldRecord(key("a"), 1100)); // Recording restarts the window rather than extending the original one. @@ -86,7 +85,7 @@ public void recordsAgainOnceWindowElapses() { @Test public void tracksFlagsIndependently() { - EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); assertTrue(deduper.shouldRecord(key("a"), 1000)); assertTrue(deduper.shouldRecord(key("b"), 1000)); assertFalse(deduper.shouldRecord(key("a"), 1000)); @@ -95,7 +94,7 @@ public void tracksFlagsIndependently() { @Test public void reportsAFlagAgainAsSoonAsItsResultChanges() { - EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); assertTrue(deduper.shouldRecord(key("a"), 1000)); assertTrue(deduper.shouldRecord(otherResult("a"), 1010)); assertFalse(deduper.shouldRecord(otherResult("a"), 1020)); @@ -107,7 +106,7 @@ public void reportsAFlagAgainAsSoonAsItsResultChanges() { @Test public void tracksTheSameFlagSeparatelyPerEnvironment() { - EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); EvaluationExposureKey primary = new EvaluationExposureKey("default", "flag", 1, 2, false, "user-key"); EvaluationExposureKey secondary = new EvaluationExposureKey("other", "flag", 3, 4, false, "user-key"); @@ -121,52 +120,12 @@ public void tracksTheSameFlagSeparatelyPerEnvironment() { @Test public void recordsAgainAfterReset() { - EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 10); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); assertTrue(deduper.shouldRecord(key("a"), 1000)); deduper.reset(); assertTrue(deduper.shouldRecord(key("a"), 1000)); } - @Test - public void evictsTheFlagRecordedLongestAgoPastCap() { - EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(10_000, 4); - for (int i = 0; i < 5; i++) { - assertTrue(deduper.shouldRecord(key("key-" + i), 1000 + i)); - } - // "key-0" was recorded first, so it is the one dropped and can be recorded again, while the - // most recently recorded flag is still being tracked. - assertTrue(deduper.shouldRecord(key("key-0"), 1010)); - assertFalse(deduper.shouldRecord(key("key-4"), 1010)); - } - - @Test - public void reRecordingMovesAFlagToMostRecentEndOfEvictionOrder() { - EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 2); - assertTrue(deduper.shouldRecord(key("a"), 1000)); - assertTrue(deduper.shouldRecord(key("b"), 1000)); - // "a" is re-recorded once its window elapses, which makes "b" the oldest tracked flag. - assertTrue(deduper.shouldRecord(key("a"), 1100)); - assertTrue(deduper.shouldRecord(key("c"), 1100)); - assertFalse(deduper.shouldRecord(key("a"), 1100)); - } - - @Test - public void evictionPrefersFlagsWhoseWindowHasElapsed() { - EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100, 8); - for (int i = 0; i < 2; i++) { - assertTrue(deduper.shouldRecord(key("expired-" + i), 1000)); - } - // The 7th of these takes the map past the cap. The flags recorded longest ago are the two - // whose window has since elapsed, so those are the ones evicted and every live flag is still - // tracked. - for (int i = 0; i < 7; i++) { - assertTrue(deduper.shouldRecord(key("live-" + i), 1150)); - } - for (int i = 0; i < 7; i++) { - assertFalse(deduper.shouldRecord(key("live-" + i), 1150)); - } - } - @Test public void usesTheDefaultWindowWhenBuiltWithoutOne() { // Ten minutes. @@ -179,23 +138,21 @@ public void usesTheDefaultWindowWhenBuiltWithoutOne() { } @Test - public void boundsHowManyFlagsItTracks() { + public void tracksEveryFlagTheApplicationEvaluates() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(600_000); for (int i = 0; i < 2_000; i++) { assertTrue(deduper.shouldRecord(key("key-" + i), 1000)); } - // Nothing about the bound is configurable, because tracking one result per flag already keeps - // the cache to the size of the flag set. It is only reached by an application that generates - // flag keys, and then it evicts the flag recorded longest ago. + // Nothing is dropped to make room, so the flag recorded first is suppressed just like the + // flag recorded last. What the deduper holds is the flag set, which the environment bounds. + assertFalse(deduper.shouldRecord(key("key-0"), 1000)); assertFalse(deduper.shouldRecord(key("key-1999"), 1000)); - assertTrue(deduper.shouldRecord(key("key-2000"), 1000)); - assertTrue(deduper.shouldRecord(key("key-0"), 1000)); } @Test public void recordsOnceWhenSameKeyIsCheckedConcurrently() throws Exception { - EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(60_000, 100); + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(60_000); EvaluationExposureKey key = key("a"); int threadCount = 10; Thread[] threads = new Thread[threadCount]; From 716ab11f4bbaf7d232d130cd110d26504e93896f Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 10 Aug 2026 13:37:10 -0700 Subject: [PATCH 19/29] refactor: opt into exposure dedupe by wrapping a hook rather than configuring it A hook carried a deduper as mutable state that the SDK read once at registration, which left the wiring invisible at the call site and gave every Hook subclass a field it mostly did not use. Deduplication is now a decorator: DedupingHook wraps the hook it dedupes for, and HookDecorator is the base any decorator extends, so decorators stack. Deciding inside the decorator means it needs the identity of the result an evaluation is about to return, which the hook API did not carry. An evaluation series context now resolves that on demand, so an application whose hooks do not dedupe never pays for the flag lookup it takes. Co-authored-by: Cursor --- .../example/ExposureCountingHook.java | 6 +- .../launchdarkly/example/MainActivity.java | 9 +- .../sdk/android/LDClientEventTest.java | 4 +- .../sdk/android/LDClientHooksTest.java | 32 +- .../launchdarkly/sdk/android/HookRunner.java | 120 ++----- .../launchdarkly/sdk/android/LDClient.java | 5 - .../android/integrations/DedupingHook.java | 138 ++++++++ .../EvaluationExposureDeduper.java | 52 +-- .../EvaluationExposureKeySupplier.java | 20 ++ .../integrations/EvaluationSeriesContext.java | 47 +++ .../sdk/android/integrations/Hook.java | 83 +---- .../android/integrations/HookDecorator.java | 116 +++++++ .../HooksConfigurationBuilder.java | 11 +- .../sdk/android/DedupingHookTest.java | 304 ++++++++++++++++++ .../EvaluationExposureDeduperTest.java | 12 - .../sdk/android/HookRunnerTest.java | 147 ++------- 16 files changed, 724 insertions(+), 382 deletions(-) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookDecorator.java create mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DedupingHookTest.java diff --git a/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java b/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java index a2a971ed..4b3eef67 100644 --- a/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java +++ b/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java @@ -11,9 +11,9 @@ /** * Counts the evaluation series stages it observes, so the example can show what exposure - * deduplication does. Deduplication is configured at registration with - * {@link Hook#evaluationExposureDeduper(int)}, the same way a customer would configure any - * other hook. + * deduplication does. Deduplication is configured at registration by wrapping this hook in a + * {@link com.launchdarkly.sdk.android.integrations.DedupingHook}, the same way a customer would + * wrap any other hook. *

* Deduplication skips the whole series, so both counts stay equal and both stop climbing while * repeated evaluations resolve to the same result. diff --git a/example/src/main/java/com/launchdarkly/example/MainActivity.java b/example/src/main/java/com/launchdarkly/example/MainActivity.java index 2c144b84..9b604324 100644 --- a/example/src/main/java/com/launchdarkly/example/MainActivity.java +++ b/example/src/main/java/com/launchdarkly/example/MainActivity.java @@ -22,6 +22,7 @@ import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; import com.launchdarkly.sdk.android.LDFailure; import com.launchdarkly.sdk.android.LDStatusListener; +import com.launchdarkly.sdk.android.integrations.DedupingHook; import java.util.Date; import java.util.Locale; @@ -119,11 +120,11 @@ public void onCreate(Bundle savedInstanceState) { // change useReport to `true` if the request is to be REPORT'ed instead of GET'ed ) .hooks( - // Same fluent shape a customer uses for any hook: configure the deduper - // at registration. Each hook has its own window, so neither suppresses the other. + // Same shape a customer uses for any hook: wrap it at registration. Each + // wrapper has its own window, so neither suppresses the other. Components.hooks() - .addHook(fastHook.evaluationExposureDeduper(FAST_DEDUPE_WINDOW_MILLIS)) - .addHook(slowHook.evaluationExposureDeduper(SLOW_DEDUPE_WINDOW_MILLIS)) + .addHook(new DedupingHook(fastHook, FAST_DEDUPE_WINDOW_MILLIS)) + .addHook(new DedupingHook(slowHook, SLOW_DEDUPE_WINDOW_MILLIS)) ); if (isStaging()) { diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java index ad76e193..544c416b 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java @@ -16,6 +16,7 @@ import com.launchdarkly.sdk.ObjectBuilder; import com.launchdarkly.sdk.android.DataModel.Flag; import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; +import com.launchdarkly.sdk.android.integrations.DedupingHook; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.subsystems.PersistentDataStore; import com.launchdarkly.sdk.internal.GsonHelpers; @@ -252,8 +253,7 @@ public void exposureDeduplicationDoesNotSuppressEvaluationEvents() throws IOExce TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flag); // Deduplication applies to hooks only, so a hook given a window wide enough to suppress // every repeat must still leave the analytics events untouched. - Hook dedupingHook = new Hook("deduping-hook") {}; - dedupingHook.evaluationExposureDeduper(60_000); + Hook dedupingHook = new DedupingHook(new Hook("deduping-hook") {}, 60_000); LDConfig ldConfig = baseConfigBuilder(mockEventsServer) .persistentDataStore(store) .hooks(Components.hooks().addHook(dedupingHook)) diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java index d934b842..9d8b3570 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java @@ -10,7 +10,7 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; +import com.launchdarkly.sdk.android.integrations.DedupingHook; import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext; @@ -194,8 +194,8 @@ public void repeatedEvaluationsReachAHookThatAskedForNoDedupe() throws Exception @Test public void repeatedEvaluationsAreDeduplicatedWithinTheHooksWindow() throws Exception { - testHook.evaluationExposureDeduper(60_000); - try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testHook)), ldContext, 1)) { + Hook deduping = new DedupingHook(testHook, 60_000); + try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(deduping)), ldContext, 1)) { for (int i = 0; i < 3; i++) { ldClient.boolVariation("test-flag", false); } @@ -208,8 +208,8 @@ public void repeatedEvaluationsAreDeduplicatedWithinTheHooksWindow() throws Exce @Test public void identifyResetsEvaluationExposureDedupeCache() throws Exception { - testHook.evaluationExposureDeduper(60_000); - try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testHook)), ldContext, 1)) { + Hook deduping = new DedupingHook(testHook, 60_000); + try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(deduping)), ldContext, 1)) { ldClient.boolVariation("test-flag", false); ldClient.boolVariation("test-flag", false); assertEquals(1, testHook.afterEvaluationCalls.size()); @@ -225,8 +225,8 @@ public void identifyResetsEvaluationExposureDedupeCache() throws Exception { @Test public void evaluationsOfDifferentFlagsReachHooksSeparately() throws Exception { - testHook.evaluationExposureDeduper(60_000); - try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testHook)), ldContext, 1)) { + Hook deduping = new DedupingHook(testHook, 60_000); + try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(deduping)), ldContext, 1)) { ldClient.boolVariation("test-flag", false); ldClient.boolVariation("other-flag", false); ldClient.boolVariation("test-flag", false); @@ -238,24 +238,25 @@ public void evaluationsOfDifferentFlagsReachHooksSeparately() throws Exception { @Test public void hooksWithDifferentWindowsSuppressIndependently() throws Exception { MockHook deduping = new MockHook(); - deduping.evaluationExposureDeduper(60_000); MockHook reportingEverything = new MockHook(); - reportingEverything.evaluationExposureDeduper(EvaluationExposureDeduper.disabled()); LDConfig config = makeOfflineConfigBuilder(null) - .hooks(Components.hooks().addHook(testHook).addHook(deduping).addHook(reportingEverything)) + .hooks(Components.hooks() + .addHook(testHook) + .addHook(new DedupingHook(deduping, 60_000)) + .addHook(new DedupingHook(reportingEverything, 0))) .build(); try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { for (int i = 0; i < 3; i++) { ldClient.boolVariation("test-flag", false); } - // Only the hook that asked for a window suppresses. Carrying no deduper and carrying the - // disabled one behave the same way. + // Only the hook wrapped in a window suppresses. Being unwrapped and being wrapped in a + // window of zero behave the same way. assertEquals(3, testHook.afterEvaluationCalls.size()); assertEquals(1, deduping.afterEvaluationCalls.size()); assertEquals(3, reportingEverything.afterEvaluationCalls.size()); - // identify clears the cache of every hook that has one. + // identify clears the cache of every wrapped hook. ldClient.identify(ldContext).get(); ldClient.boolVariation("test-flag", false); assertEquals(2, deduping.afterEvaluationCalls.size()); @@ -264,8 +265,7 @@ public void hooksWithDifferentWindowsSuppressIndependently() throws Exception { @Test public void environmentsSharingAHookDoNotSuppressEachOther() throws Exception { - testHook.evaluationExposureDeduper(60_000); - LDConfig config = makeOfflineConfigBuilder(List.of(testHook)) + LDConfig config = makeOfflineConfigBuilder(List.of(new DedupingHook(testHook, 60_000))) .secondaryMobileKeys(Collections.singletonMap("other", "other-mobile-key")) .build(); try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { @@ -273,7 +273,7 @@ public void environmentsSharingAHookDoNotSuppressEachOther() throws Exception { LDClient.getForMobileKey("other").boolVariation("test-flag", false); // Both environments resolve the flag identically, but the hook they share, and so the - // deduper it carries, is told about each of them. + // one deduper wrapped around it, is told about each of them. assertEquals(2, testHook.afterEvaluationCalls.size()); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java index d5295fa3..edf91539 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java @@ -4,14 +4,12 @@ import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; -import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureKeySupplier; import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext; import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; import com.launchdarkly.sdk.android.integrations.TrackSeriesContext; -import com.launchdarkly.sdk.android.subsystems.EventProcessor; import java.util.ArrayList; import java.util.Collections; @@ -29,47 +27,26 @@ public interface AfterIdentifyMethod { void invoke(IdentifySeriesResult result); } - /** - * Builds the key that the per-hook dedupers use to recognize a repeated evaluation. Consulted - * once per evaluation, before the series opens, and only when at least one hook can suppress. - */ - @FunctionalInterface - public interface ExposureKeySupplier { - EvaluationExposureKey exposureKey(String flagKey, LDContext context); - } - - /** - * A registered hook together with the deduper that decides which evaluations reach it. Kept as - * one value so adding a hook never leaves the two out of sync. - */ - private static final class RegisteredHook { - final Hook hook; - final EvaluationExposureDeduper deduper; - - RegisteredHook(Hook hook, EvaluationExposureDeduper deduper) { - this.hook = hook; - this.deduper = deduper; - } - } - private static final String UNKNOWN_HOOK_NAME = "unknown hook"; private final LDLogger logger; - private final List hooks = new ArrayList<>(); - private final ExposureKeySupplier exposureKeySupplier; + private final List hooks = new ArrayList<>(); - // False while every registered hook wants every evaluation, which is the default. Lets the - // evaluation path skip building the exposure key, which costs more than the checks it feeds. - private volatile boolean anyDedupeActive = false; + // Handed to every evaluation series context, which resolves it only if a hook asks what the + // evaluation's result identifies. Dedupe is the only thing that asks, and only a hook that has + // been wrapped in a DedupingHook dedupes, so an application without one never pays for this. + private final EvaluationExposureKeySupplier exposureKeySupplier; + /** + * Builds a runner whose evaluations describe no result, so a hook that asks what one identifies + * is told nothing and treats every evaluation as its own. + */ public HookRunner(LDLogger logger, List initialHooks) { - this(logger, initialHooks, (flagKey, context) -> new EvaluationExposureKey( - LDConfig.primaryEnvironmentName, flagKey, EvaluationDetail.NO_VARIATION, - EventProcessor.NO_VERSION, false, context.getFullyQualifiedKey())); + this(logger, initialHooks, null); } public HookRunner(LDLogger logger, List initialHooks, - ExposureKeySupplier exposureKeySupplier) { + EvaluationExposureKeySupplier exposureKeySupplier) { this.logger = logger; this.exposureKeySupplier = exposureKeySupplier; for (Hook hook : initialHooks) { @@ -87,69 +64,20 @@ private String getHookName(Hook hook) { } } - /** - * Adds a hook, resolving now which evaluations will reach it: the deduper the hook carries, or - * every evaluation if it carries none. - * - * @param hook the hook to add - */ public void addHook(Hook hook) { - EvaluationExposureDeduper declared = hook.getEvaluationExposureDeduper(); - EvaluationExposureDeduper deduper = - declared == null ? EvaluationExposureDeduper.disabled() : declared; - if (deduper != EvaluationExposureDeduper.disabled()) { - anyDedupeActive = true; - } - hooks.add(new RegisteredHook(hook, deduper)); - } - - /** - * Clears every hook's record of the evaluations it has already observed, so that the next - * evaluation of each reaches the hook again. Called when the evaluation context changes. - */ - public void resetEvaluationExposureDedupers() { - for (RegisteredHook registered : hooks) { - registered.deduper.reset(); - } - } - - /** - * Returns the hooks that should observe this evaluation. - *

- * The decision is made before the series opens rather than after the evaluation completes, - * because hooks pair their stages: the observability plugin starts a span in - * {@code beforeEvaluation} and ends it in {@code afterEvaluation}, so suppressing only the after - * stage would leave that span open until something else closed it. - */ - private List hooksForEvaluation(String flagKey, LDContext context) { - if (!anyDedupeActive || hooks.isEmpty()) { - return hooks; - } - - EvaluationExposureKey exposureKey = exposureKeySupplier.exposureKey(flagKey, context); - long nowMillis = System.currentTimeMillis(); - List reporting = new ArrayList<>(hooks.size()); - for (RegisteredHook registered : hooks) { - if (registered.deduper.shouldRecord(exposureKey, nowMillis)) { - reporting.add(registered); - } else { - logger.debug("Deduplicated exposure of flag \"{}\" for hook \"{}\"", flagKey, - getHookName(registered.hook)); - } - } - return reporting; + hooks.add(hook); } public EvaluationDetail withEvaluation(String method, String key, LDContext context, LDValue defaultValue, EvaluationMethod evalMethod) { - List reportingHooks = hooksForEvaluation(key, context); - if (reportingHooks.isEmpty()) { + if (hooks.isEmpty()) { return evalMethod.evaluate(); } - List> seriesDataList = new ArrayList<>(reportingHooks.size()); - EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue); - for (int i = 0; i < reportingHooks.size(); i++) { - Hook currentHook = reportingHooks.get(i).hook; + List> seriesDataList = new ArrayList<>(hooks.size()); + EvaluationSeriesContext seriesContext = + new EvaluationSeriesContext(method, key, context, defaultValue, exposureKeySupplier); + for (int i = 0; i < hooks.size(); i++) { + Hook currentHook = hooks.get(i); try { Map seriesData = currentHook.beforeEvaluation(seriesContext, Collections.unmodifiableMap(Collections.emptyMap())); seriesDataList.add(Collections.unmodifiableMap(seriesData)); @@ -162,8 +90,8 @@ public EvaluationDetail withEvaluation(String method, String key, LDCon EvaluationDetail result = evalMethod.evaluate(); // Invoke hooks in reverse order and give them back the series data they gave us. - for (int i = reportingHooks.size() - 1; i >= 0; i--) { - Hook currentHook = reportingHooks.get(i).hook; + for (int i = hooks.size() - 1; i >= 0; i--) { + Hook currentHook = hooks.get(i); try { currentHook.afterEvaluation(seriesContext, seriesDataList.get(i), result); } catch (Exception e) { @@ -182,7 +110,7 @@ public AfterIdentifyMethod identify(LDContext context, Integer timeout) { List> seriesDataList = new ArrayList<>(hooks.size()); IdentifySeriesContext seriesContext = new IdentifySeriesContext(context, timeout); for (int i = 0; i < hooks.size(); i++) { - Hook currentHook = hooks.get(i).hook; + Hook currentHook = hooks.get(i); try { Map seriesData = currentHook.beforeIdentify(seriesContext, Collections.unmodifiableMap(Collections.emptyMap())); seriesDataList.add(Collections.unmodifiableMap(seriesData)); @@ -195,7 +123,7 @@ public AfterIdentifyMethod identify(LDContext context, Integer timeout) { return (IdentifySeriesResult result) -> { // Invoke hooks in reverse order and give them back the series data they gave us. for (int i = hooks.size() - 1; i >= 0; i--) { - Hook currentHook = hooks.get(i).hook; + Hook currentHook = hooks.get(i); try { currentHook.afterIdentify(seriesContext, seriesDataList.get(i), result); } catch (Exception e) { @@ -214,7 +142,7 @@ public void afterTrack(String key, LDContext context, LDValue data, Double metri // The track series has only an "after" stage, so hooks run in registration order, as required by // the shared SDK contract tests (unlike afterEvaluation/afterIdentify, which run in reverse). for (int i = 0; i < hooks.size(); i++) { - Hook currentHook = hooks.get(i).hook; + Hook currentHook = hooks.get(i); try { currentHook.afterTrack(seriesContext); } catch (Exception e) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index 6ac08516..ca9587c6 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -500,11 +500,6 @@ private void identifyInternal(@NonNull LDContext context, clientContextImpl = clientContextImpl.setEvaluationContext(context); - // Exposures observed before this point describe an earlier point in the app's lifecycle, so - // let them be reported again. This happens even when the context is unchanged, so that - // identify is a reliable way for an app to mark a new phase of a session. - hookRunner.resetEvaluationExposureDedupers(); - // Load cached flags for the new context so they're available in case initialization // times out or otherwise fails. This does not short-circuit initialization — the data // source still performs its network request regardless. diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java new file mode 100644 index 00000000..c77431a8 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java @@ -0,0 +1,138 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDValue; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +/** + * Wraps a hook so that repeated evaluations resolving to the same result do not reach it again within + * a time window. + *

+ * The wrapped hook is told about a flag when its result changes, and at most once per window while the + * result stays the same. This is useful for reducing the telemetry volume produced by frequent + * re-evaluations, for example a flag that is read on every redraw of a view. Deduplication is opt-in: + * a hook that is registered unwrapped observes every evaluation. + * + *


+ *     Components.hooks()
+ *         .addHook(new MetricsHook())                                  // observes every evaluation
+ *         .addHook(new DedupingHook(new ObservabilityHook()))           // default window
+ *         .addHook(new DedupingHook(new TelemetryHook(), 60_000))
+ *         .addHook(new DedupingHook(new ExperimentHook(), myCustomDeduper))
+ * 
+ *

+ * Two evaluations resolve to the same result when they agree on everything + * {@link EvaluationExposureKey} describes. Pass your own {@link EvaluationExposureDeduper} subclass to + * decide that differently. + *

+ * A suppressed evaluation reaches neither + * {@link Hook#beforeEvaluation(EvaluationSeriesContext, Map)} nor + * {@link Hook#afterEvaluation(EvaluationSeriesContext, Map, EvaluationDetail)}, because hooks pair + * their stages. The identify and track stages are always forwarded. Analytics events are unaffected: + * feature, debug, and summary events are still recorded for every evaluation, so the evaluation counts + * LaunchDarkly reports for your flags do not change. + *

+ * What the wrapped hook has been told about is cleared by + * {@link com.launchdarkly.sdk.android.LDClient#identify(com.launchdarkly.sdk.LDContext)}, so the first + * evaluation of each flag after an identify always reaches it. + *

+ * Give each hook its own instance unless you intend hooks to share a window: the first hook to be told + * about an evaluation starts the window that suppresses the rest. + */ +public final class DedupingHook extends HookDecorator { + + // Namespaced because it travels in series data that the wrapped hook may also write to. + private static final String SUPPRESSED = "com.launchdarkly.sdk.android.DedupingHook.suppressed"; + + private final EvaluationExposureDeduper deduper; + + // Returned in place of the wrapped hook's series data when an evaluation is suppressed, and + // recognized by identity so that stacked instances each recognize only their own suppressions. + private final Map suppressedSeriesData = + Collections.singletonMap(SUPPRESSED, this); + + /** + * Wraps a hook with a window of {@link EvaluationExposureDeduper#DEFAULT_WINDOW_MILLIS}. + * + * @param delegate the hook to wrap + */ + public DedupingHook(Hook delegate) { + this(delegate, new EvaluationExposureDeduper()); + } + + /** + * @param delegate the hook to wrap + * @param windowMillis the dedupe window in milliseconds; zero or negative forwards every + * evaluation + */ + public DedupingHook(Hook delegate, int windowMillis) { + this(delegate, new EvaluationExposureDeduper(windowMillis)); + } + + /** + * @param delegate the hook to wrap + * @param deduper decides which evaluations reach the wrapped hook + */ + public DedupingHook(Hook delegate, EvaluationExposureDeduper deduper) { + super(delegate); + this.deduper = Objects.requireNonNull(deduper, "a deduping hook must have a deduper"); + } + + /** + * Forwards the evaluation unless the wrapped hook has just been told about the same result. + *

+ * The decision is made here, before the evaluation runs, so that a suppressed evaluation reaches + * neither stage of the wrapped hook. An evaluation whose result the SDK did not describe, which is + * to say a series context built by something other than the SDK, is always forwarded. + * + * @param seriesContext container of parameters associated with this evaluation + * @param seriesData immutable data from the previous stage in the evaluation series + * @return the wrapped hook's series data, or data marking the series as suppressed + */ + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + EvaluationExposureKey key = seriesContext.getEvaluationExposureKey(); + if (key != null && !deduper.shouldRecord(key, System.currentTimeMillis())) { + return suppressedSeriesData; + } + return super.beforeEvaluation(seriesContext, seriesData); + } + + /** + * Forwards the result unless this instance suppressed the series in its before stage. + * + * @param seriesContext container of parameters associated with this evaluation + * @param seriesData the data returned by this hook's before stage + * @param evaluationDetail the result of the evaluation + * @return the wrapped hook's series data, unchanged if the series was suppressed + */ + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, + EvaluationDetail evaluationDetail) { + if (seriesData != null && seriesData.get(SUPPRESSED) == this) { + return seriesData; + } + return super.afterEvaluation(seriesContext, seriesData, evaluationDetail); + } + + /** + * Forgets which results the wrapped hook has been told about, then forwards the stage. + *

+ * Evaluations observed before an identify describe an earlier point in the application's + * lifecycle, so they are reported again afterwards. This happens even when the context is + * unchanged, so that identify is a reliable way for an application to mark a new phase of a + * session. + * + * @param seriesContext container of parameters associated with this identify + * @param seriesData immutable data from the previous stage in the identify series + * @return the wrapped hook's series data + */ + @Override + public Map beforeIdentify(IdentifySeriesContext seriesContext, Map seriesData) { + deduper.reset(); + return super.beforeIdentify(seriesContext, seriesData); + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java index 20d80693..599ac930 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -8,15 +8,15 @@ * Decides whether a hook should be told about an evaluation, so that repeated evaluations resolving * to the same result do not invoke the hook again within a time window. *

- * Deduplication is opt-in per hook: a hook is told about every evaluation until you give it a - * deduper with {@link Hook#evaluationExposureDeduper(int)}. + * Deduplication is opt-in per hook: a hook is told about every evaluation until you wrap it in a + * {@link DedupingHook}, which is what consults a deduper. * *


  *     Components.hooks()
- *         .addHook(new MetricsHook())                                // told about every evaluation
- *         .addHook(new ObservabilityHook().evaluationExposureDeduper())  // default window
- *         .addHook(new TelemetryHook().evaluationExposureDeduper(30_000))
- *         .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper))
+ *         .addHook(new MetricsHook())                        // told about every evaluation
+ *         .addHook(new DedupingHook(new ObservabilityHook())) // default window
+ *         .addHook(new DedupingHook(new TelemetryHook(), 30_000))
+ *         .addHook(new DedupingHook(new ExperimentHook(), myCustomDeduper))
  * 
*

* This class is the SDK's implementation: it remembers the result each flag last reported, and tells @@ -24,8 +24,8 @@ * stays the same. Tracking one result per flag rather than every result seen keeps a flag that flips * back and forth from hiding the flips, and holds one record per flag the application evaluates, so * the window is the only thing there is to configure. Subclass this to implement a different policy; - * only {@link #shouldRecord(EvaluationExposureKey, long)} and {@link #reset()} are called by the - * SDK. + * only {@link #shouldRecord(EvaluationExposureKey, long)} and {@link #reset()} are called by + * {@link DedupingHook}. *

* A deduper is consulted once per evaluation, before the series opens, so a suppressed evaluation * invokes neither {@code beforeEvaluation} nor {@code afterEvaluation}. Implementations must be @@ -40,8 +40,6 @@ public class EvaluationExposureDeduper { */ public static final int DEFAULT_WINDOW_MILLIS = 600_000; - private static final EvaluationExposureDeduper DISABLED = new Disabled(); - private final long windowMillis; // Holds one record per flag the application evaluates, in each environment it evaluates it in. @@ -64,24 +62,11 @@ public EvaluationExposureDeduper(int windowMillis) { this.windowMillis = windowMillis; } - /** - * Returns a deduper that suppresses nothing, so its hook is told about every evaluation. - *

- * This is what a hook gets when it is registered without a deduper, so passing it is only useful - * to state that intent explicitly. The returned instance holds no state and may be given to any - * number of hooks. - * - * @return a deduper that never suppresses an evaluation - */ - public static EvaluationExposureDeduper disabled() { - return DISABLED; - } - /** * Returns whether the hook should be told about the evaluation identified by the given key, and * if so starts a new dedupe window for the flag. *

- * The SDK calls this once per evaluation per hook. This implementation answers true when the flag + * {@link DedupingHook} calls this once per evaluation. This implementation answers true when the flag * is reporting a different result than it last did, and when the window has elapsed on the result * it is repeating. See {@link EvaluationExposureKey} for what makes two evaluations the same * result. @@ -110,8 +95,8 @@ public synchronized boolean shouldRecord(EvaluationExposureKey key, long nowMill } /** - * Clears all recorded exposures, so the next evaluation of each is reported again. The SDK calls - * this when the evaluation context changes. + * Clears all recorded exposures, so the next evaluation of each is reported again. + * {@link DedupingHook} calls this when the evaluation context changes. */ public synchronized void reset() { lastReported.clear(); @@ -185,19 +170,4 @@ boolean isSameResultAs(EvaluationExposureKey key) { && Objects.equals(fullyQualifiedContextKey, key.getFullyQualifiedContextKey()); } } - - private static final class Disabled extends EvaluationExposureDeduper { - Disabled() { - super(0); - } - - @Override - public boolean shouldRecord(EvaluationExposureKey key, long nowMillis) { - return true; - } - - @Override - public void reset() { - } - } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java new file mode 100644 index 00000000..daf1dab6 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java @@ -0,0 +1,20 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.LDContext; + +/** + * Builds the key identifying the result an evaluation is about to return. + *

+ * The SDK gives one of these to each {@link EvaluationSeriesContext} it builds, so that a hook which + * needs the identity of an evaluation can ask for it without the SDK computing it for hooks that do + * not. {@link DedupingHook} is the hook that needs it. + */ +@FunctionalInterface +public interface EvaluationExposureKeySupplier { + /** + * @param flagKey the key of the flag being evaluated + * @param context the context the evaluation is for + * @return the key identifying the result the evaluation will return + */ + EvaluationExposureKey exposureKey(String flagKey, LDContext context); +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java index d2043c70..ebbccfd0 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.android.integrations; +import androidx.annotation.Nullable; + import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; @@ -33,6 +35,13 @@ public class EvaluationSeriesContext { */ public final LDValue defaultValue; + private final EvaluationExposureKeySupplier exposureKeySupplier; + + // Resolved on demand and remembered, so that an evaluation costs a flag lookup only when a hook + // asks what its result identifies, and only once however many hooks ask. Guarded by the instance + // lock, because a hook may ask from any thread. + private EvaluationExposureKey exposureKey; + /** * @param method the variation method that was used to invoke the evaluation. * @param key the key of the feature flag being evaluated. @@ -40,10 +49,48 @@ public class EvaluationSeriesContext { * @param defaultValue the user-provided default value for the evaluation. */ public EvaluationSeriesContext(String method, String key, LDContext context, LDValue defaultValue) { + this(method, key, context, defaultValue, null); + } + + /** + * Used by the SDK, which knows the result the evaluation will return. Application code has no use + * for this constructor: a context built with the four-argument one has no exposure key, and + * {@link #getEvaluationExposureKey()} explains what that means for a hook that wanted one. + * + * @param method the variation method that was used to invoke the evaluation. + * @param key the key of the feature flag being evaluated. + * @param context the context the evaluation was for. + * @param defaultValue the user-provided default value for the evaluation. + * @param exposureKeySupplier resolves the key identifying the result of this evaluation, or null + * if the result is not known + */ + public EvaluationSeriesContext(String method, String key, LDContext context, LDValue defaultValue, + @Nullable EvaluationExposureKeySupplier exposureKeySupplier) { this.flagKey = key; this.context = context; this.defaultValue = defaultValue; this.method = method; + this.exposureKeySupplier = exposureKeySupplier; + } + + /** + * Returns the key identifying the result this evaluation will return, for a hook that decides what + * to do with an evaluation by whether it has seen the same result before. {@link DedupingHook} is + * such a hook. + *

+ * The key describes the result as the SDK has it stored, which is what the evaluation is about to + * return, so it is available to {@link Hook#beforeEvaluation(EvaluationSeriesContext, Map)} as + * well as to the after stage. + * + * @return the key identifying this evaluation's result, or null if this context was not built by + * the SDK and so has no result to describe + */ + @Nullable + public synchronized EvaluationExposureKey getEvaluationExposureKey() { + if (exposureKey == null && exposureKeySupplier != null) { + exposureKey = exposureKeySupplier.exposureKey(flagKey, context); + } + return exposureKey; } @Override diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java index eee933a2..4dda0057 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java @@ -12,13 +12,14 @@ * Multiple hooks may be configured in the SDK. By default, the SDK will execute each hook's before * stages in the order they were configured, and each hook's after stages in reverse order. (i.e. * myHook1.beforeEvaluation, myHook2.beforeEvaluation, myHook2.afterEvaluation, myHook1.afterEvaluation) + *

+ * To add behavior to a hook without changing it, such as the deduplication of repeated evaluations + * that {@link DedupingHook} performs, wrap it in a {@link HookDecorator} and register the wrapper. */ public abstract class Hook { private final HookMetadata metadata; - private EvaluationExposureDeduper evaluationExposureDeduper; - /** * @return the hooks metadata */ @@ -35,84 +36,6 @@ public Hook(String name) { metadata = new HookMetadata(name) {}; } - /** - * Deduplicates this hook's evaluation series with the SDK's implementation, using a window of - * {@link EvaluationExposureDeduper#DEFAULT_WINDOW_MILLIS}. - * - *


-     *     Components.hooks()
-     *         .addHook(new ObservabilityHook().evaluationExposureDeduper())
-     * 
- * - * @return this hook - * @see #evaluationExposureDeduper(int) - */ - public Hook evaluationExposureDeduper() { - return evaluationExposureDeduper(new EvaluationExposureDeduper()); - } - - /** - * Deduplicates this hook's evaluation series with the SDK's implementation, so that repeated - * evaluations resolving to the same result reach it at most once per window. - *

- * This hook observes a flag when its result changes, and at most once per window while the result - * stays the same. This is useful for reducing the telemetry volume produced by frequent - * re-evaluations, for example a flag that is read on every redraw of a view. - * - *


-     *     Components.hooks()
-     *         .addHook(new ObservabilityHook().evaluationExposureDeduper(60_000))
-     * 
- * - * @param windowMillis the dedupe window in milliseconds; zero or negative reports every - * evaluation - * @return this hook - */ - public Hook evaluationExposureDeduper(int windowMillis) { - return evaluationExposureDeduper(new EvaluationExposureDeduper(windowMillis)); - } - - /** - * Sets which evaluations reach this hook. It affects only this hook. - *

- * Pass your own subclass of {@link EvaluationExposureDeduper} to implement a policy other than - * the SDK's, or {@link EvaluationExposureDeduper#disabled()} to state explicitly that this hook - * observes every evaluation, which is what it does anyway when no deduper is set. - * - *


-     *     Components.hooks()
-     *         .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper))
-     * 
- *

- * Deduplication applies to the whole evaluation series, so a suppressed evaluation invokes - * neither {@link #beforeEvaluation(EvaluationSeriesContext, Map)} nor - * {@link #afterEvaluation(EvaluationSeriesContext, Map, EvaluationDetail)}. Analytics events are - * unaffected: feature, debug, and summary events are still recorded for every evaluation, so the - * evaluation counts LaunchDarkly reports for your flags do not change. What the hook has - * observed is cleared by {@link com.launchdarkly.sdk.android.LDClient#identify(com.launchdarkly.sdk.LDContext)}, - * so the first evaluation after an identify always reaches it. - *

- * The SDK reads this once, when the hook is registered, so call it before passing the hook to - * the SDK. Give each hook its own deduper unless you intend hooks to share a window: the first - * hook to observe an evaluation starts the window that suppresses the rest. - * - * @param evaluationExposureDeduper the deduper for this hook, or null to observe every - * evaluation - * @return this hook - */ - public Hook evaluationExposureDeduper(EvaluationExposureDeduper evaluationExposureDeduper) { - this.evaluationExposureDeduper = evaluationExposureDeduper; - return this; - } - - /** - * @return the deduper deciding which evaluations reach this hook, or null if it observes every - * evaluation - */ - public final EvaluationExposureDeduper getEvaluationExposureDeduper() { - return evaluationExposureDeduper; - } - /** * {@link #beforeEvaluation(EvaluationSeriesContext, Map)} is executed by the SDK at the start of the evaluation of * a feature flag. It will not be executed as part of a call to diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookDecorator.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookDecorator.java new file mode 100644 index 00000000..bfc1d664 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookDecorator.java @@ -0,0 +1,116 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDValue; + +import java.util.Map; +import java.util.Objects; + +/** + * A hook that wraps another hook, forwarding every stage to it. Extend this to add behavior to a hook + * without changing it, and register the wrapper in place of the hook it wraps. + *

+ * Each stage forwards to the wrapped hook, so a subclass overrides only the stages it changes. + * {@link DedupingHook} is the decorator the SDK ships: it forwards an evaluation series only when the + * flag's result is one its hook has not just been told about. + * + *


+ *     public final class FlagFilteringHook extends HookDecorator {
+ *         private final Set<String> flagKeys;
+ *
+ *         public FlagFilteringHook(Hook delegate, Set<String> flagKeys) {
+ *             super(delegate);
+ *             this.flagKeys = flagKeys;
+ *         }
+ *
+ *         @Override
+ *         public Map<String, Object> beforeEvaluation(EvaluationSeriesContext seriesContext,
+ *                                                     Map<String, Object> seriesData) {
+ *             return flagKeys.contains(seriesContext.flagKey)
+ *                     ? super.beforeEvaluation(seriesContext, seriesData)
+ *                     : seriesData;
+ *         }
+ *     }
+ * 
+ *

+ * Decorators stack, so a hook may be wrapped in as many as it needs, each wrapping the one inside it: + * + *


+ *     Components.hooks()
+ *         .addHook(new DedupingHook(new FlagFilteringHook(new ObservabilityHook(), myFlagKeys)))
+ * 
+ *

+ * A decorator reports the wrapped hook's metadata as its own, so the SDK names the hook that a stage + * belongs to rather than the wrappers around it. + *

+ * A decorator that suppresses a stage must suppress the whole evaluation series, because hooks pair + * their stages: an observability hook opens a span in + * {@link Hook#beforeEvaluation(EvaluationSeriesContext, Map)} and closes it in + * {@link Hook#afterEvaluation(EvaluationSeriesContext, Map, EvaluationDetail)}, so suppressing only + * the after stage leaves that span open. To carry the decision from one stage to the other, return + * series data the after stage recognizes, the way {@link DedupingHook} does. + */ +public abstract class HookDecorator extends Hook { + + private final Hook delegate; + + /** + * @param delegate the hook to forward each stage to + */ + protected HookDecorator(Hook delegate) { + super(nameOf(delegate)); + this.delegate = Objects.requireNonNull(delegate, "a decorator must wrap a hook"); + } + + /** + * @return the hook each stage is forwarded to + */ + protected final Hook getDelegate() { + return delegate; + } + + /** + * @return the wrapped hook's metadata, so that the SDK names the hook a stage belongs to + */ + @Override + public HookMetadata getMetadata() { + return delegate.getMetadata(); + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + return delegate.beforeEvaluation(seriesContext, seriesData); + } + + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, + EvaluationDetail evaluationDetail) { + return delegate.afterEvaluation(seriesContext, seriesData, evaluationDetail); + } + + @Override + public Map beforeIdentify(IdentifySeriesContext seriesContext, Map seriesData) { + return delegate.beforeIdentify(seriesContext, seriesData); + } + + @Override + public Map afterIdentify(IdentifySeriesContext seriesContext, Map seriesData, + IdentifySeriesResult result) { + return delegate.afterIdentify(seriesContext, seriesData, result); + } + + @Override + public void afterTrack(TrackSeriesContext seriesContext) { + delegate.afterTrack(seriesContext); + } + + // Static because it runs in the super() call, before this instance exists. Tolerates a hook whose + // metadata throws, which the SDK reports rather than propagates. + private static String nameOf(Hook delegate) { + try { + return delegate == null ? null : delegate.getMetadata().getName(); + } catch (Exception e) { + return null; + } + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java index abb545c2..f775ff29 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java @@ -23,16 +23,15 @@ * .build(); * *

- * A hook observes every evaluation unless it carries an exposure deduplication policy, which limits - * how often repeated evaluations resolving to the same result reach it. See - * {@link Hook#evaluationExposureDeduper()}. + * A hook observes every evaluation unless it is wrapped in a {@link DedupingHook}, which limits how + * often repeated evaluations resolving to the same result reach it. * *


  *     Components.hooks()
  *         .addHook(new MetricsHook())
- *         .addHook(new ObservabilityHook().evaluationExposureDeduper())
- *         .addHook(new TelemetryHook().evaluationExposureDeduper(60_000))
- *         .addHook(new ExperimentHook().evaluationExposureDeduper(myCustomDeduper))
+ *         .addHook(new DedupingHook(new ObservabilityHook()))
+ *         .addHook(new DedupingHook(new TelemetryHook(), 60_000))
+ *         .addHook(new DedupingHook(new ExperimentHook(), myCustomDeduper))
  * 
*

* Note that this class is abstract; the actual implementation is created by calling {@link Components#hooks()}. diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DedupingHookTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DedupingHookTest.java new file mode 100644 index 00000000..d9c70028 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DedupingHookTest.java @@ -0,0 +1,304 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.DedupingHook; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.android.integrations.Hook; +import com.launchdarkly.sdk.android.integrations.HookDecorator; +import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext; +import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; +import com.launchdarkly.sdk.android.integrations.TrackSeriesContext; + +import org.junit.Rule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Drives hooks through {@link HookRunner} rather than calling the decorator directly, because how the + * runner carries series data from one stage to the next is what a decorator has to work with. + */ +public class DedupingHookTest { + private static final EvaluationExposureKey EXPOSURE_KEY = + new EvaluationExposureKey("default", "test-flag", 1, 2, false, "user-123"); + private static final EvaluationExposureKey OTHER_RESULT = + new EvaluationExposureKey("default", "test-flag", 2, 2, false, "user-123"); + + @Rule + public LogCaptureRule logging = new LogCaptureRule(); + + /** + * Records the stages it observes, so a test can tell a suppressed evaluation (no stages) from a + * reported one. + */ + private static class RecordingHook extends Hook { + final List stages = new ArrayList<>(); + + RecordingHook(String name) { + super(name); + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + stages.add("before"); + return seriesData; + } + + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, + EvaluationDetail evaluationDetail) { + stages.add("after"); + return seriesData; + } + + @Override + public Map beforeIdentify(IdentifySeriesContext seriesContext, Map seriesData) { + stages.add("beforeIdentify"); + return seriesData; + } + + @Override + public Map afterIdentify(IdentifySeriesContext seriesContext, Map seriesData, + IdentifySeriesResult result) { + stages.add("afterIdentify"); + return seriesData; + } + + @Override + public void afterTrack(TrackSeriesContext seriesContext) { + stages.add("afterTrack"); + } + } + + /** A decorator with its own behavior, to check that decorators compose. */ + private static class CountingDecorator extends HookDecorator { + int evaluationsForwarded = 0; + int resultsForwarded = 0; + + CountingDecorator(Hook delegate) { + super(delegate); + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + evaluationsForwarded++; + return super.beforeEvaluation(seriesContext, seriesData); + } + + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, + EvaluationDetail evaluationDetail) { + resultsForwarded++; + return super.afterEvaluation(seriesContext, seriesData, evaluationDetail); + } + } + + private HookRunner runner(EvaluationExposureKey key, Hook... hooks) { + return new HookRunner(logging.logger, List.of(hooks), (flagKey, context) -> key); + } + + private void evaluate(HookRunner runner) { + runner.withEvaluation("testMethod", "test-flag", LDContext.create("user-123"), LDValue.of(false), + () -> EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off())); + } + + private void identify(HookRunner runner) { + runner.identify(LDContext.create("user-123"), null).invoke(new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.COMPLETED)); + } + + @Test + public void skipsBothStagesOfARepeatedEvaluation() { + RecordingHook hook = new RecordingHook("deduping"); + HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(hook, 60_000)); + + evaluate(runner); + evaluate(runner); + + // A suppressed evaluation must not leave a beforeEvaluation unmatched by its afterEvaluation. + assertEquals(List.of("before", "after"), hook.stages); + } + + @Test + public void usesTheDefaultWindowWhenWrappedWithoutOne() { + RecordingHook hook = new RecordingHook("deduping"); + HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(hook)); + + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("before", "after"), hook.stages); + } + + @Test + public void anUnwrappedHookObservesEveryEvaluation() { + RecordingHook hook = new RecordingHook("unwrapped"); + HookRunner runner = runner(EXPOSURE_KEY, hook); + + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("before", "after", "before", "after"), hook.stages); + } + + @Test + public void reportsAnEvaluationWhoseResultChanged() { + RecordingHook hook = new RecordingHook("deduping"); + List keys = new ArrayList<>(List.of(EXPOSURE_KEY, EXPOSURE_KEY, OTHER_RESULT)); + HookRunner runner = new HookRunner(logging.logger, List.of(new DedupingHook(hook, 60_000)), + (flagKey, context) -> keys.remove(0)); + + evaluate(runner); + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("before", "after", "before", "after"), hook.stages); + } + + @Test + public void wrappedHooksAreDeduplicatedIndependentlyOfEachOther() { + RecordingHook deduping = new RecordingHook("deduping"); + RecordingHook reportingEverything = new RecordingHook("reporting-everything"); + HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(deduping, 60_000), reportingEverything); + + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("before", "after"), deduping.stages); + assertEquals(List.of("before", "after", "before", "after"), reportingEverything.stages); + } + + @Test + public void hooksWrappedSeparatelyDoNotSuppressEachOther() { + RecordingHook first = new RecordingHook("first"); + RecordingHook second = new RecordingHook("second"); + HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(first, 60_000), new DedupingHook(second, 60_000)); + + evaluate(runner); + evaluate(runner); + + // Sharing one deduper would have let the first hook's report suppress the second hook's. + assertEquals(List.of("before", "after"), first.stages); + assertEquals(List.of("before", "after"), second.stages); + } + + @Test + public void hooksSharingOneDeduperShareItsWindow() { + EvaluationExposureDeduper shared = new EvaluationExposureDeduper(60_000); + RecordingHook first = new RecordingHook("first"); + RecordingHook second = new RecordingHook("second"); + HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(first, shared), new DedupingHook(second, shared)); + + evaluate(runner); + + // The first hook's report starts the window, which suppresses the second hook's. + assertEquals(List.of("before", "after"), first.stages); + assertEquals(List.of(), second.stages); + } + + @Test + public void identifyReportsTheSameEvaluationAgain() { + RecordingHook hook = new RecordingHook("deduping"); + HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(hook, 60_000)); + + evaluate(runner); + identify(runner); + evaluate(runner); + + assertEquals(List.of("before", "after", "beforeIdentify", "afterIdentify", "before", "after"), + hook.stages); + } + + @Test + public void forwardsTheStagesItDoesNotDeduplicate() { + RecordingHook hook = new RecordingHook("deduping"); + HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(hook, 60_000)); + + identify(runner); + runner.afterTrack("event-key", LDContext.create("user-123"), LDValue.ofNull(), null); + + assertEquals(List.of("beforeIdentify", "afterIdentify", "afterTrack"), hook.stages); + } + + @Test + public void forwardsAnEvaluationWhoseResultTheSdkDidNotDescribe() { + RecordingHook hook = new RecordingHook("deduping"); + // A series context built by something other than the SDK has no result to recognize repeats + // by, so nothing is suppressed. + HookRunner runner = new HookRunner(logging.logger, List.of(new DedupingHook(hook, 60_000)), + (flagKey, context) -> null); + + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("before", "after", "before", "after"), hook.stages); + } + + @Test + public void deduperStacksInsideAnotherDecorator() { + RecordingHook hook = new RecordingHook("wrapped-twice"); + CountingDecorator counting = new CountingDecorator(new DedupingHook(hook, 60_000)); + HookRunner runner = runner(EXPOSURE_KEY, counting); + + evaluate(runner); + evaluate(runner); + + // The outer decorator sees both evaluations, and the deduper inside it passes on one. + assertEquals(2, counting.evaluationsForwarded); + assertEquals(List.of("before", "after"), hook.stages); + } + + @Test + public void deduperStacksAroundAnotherDecorator() { + RecordingHook hook = new RecordingHook("wrapped-twice"); + CountingDecorator counting = new CountingDecorator(hook); + HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(counting, 60_000)); + + evaluate(runner); + evaluate(runner); + + // The deduper is outermost this time, so the decorator inside it sees only what it forwards. + assertEquals(1, counting.evaluationsForwarded); + assertEquals(List.of("before", "after"), hook.stages); + } + + @Test + public void aDeduperDoesNotSwallowTheStagesOfADeduperInsideIt() { + RecordingHook hook = new RecordingHook("deduped-twice"); + CountingDecorator counting = new CountingDecorator(new DedupingHook(hook, 60_000)); + // The outer deduper reports everything, so what the inner one suppresses has to travel back + // out through the decorator between them, which each stage of still belongs to. + HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(counting, 0)); + + evaluate(runner); + evaluate(runner); + + assertEquals(2, counting.evaluationsForwarded); + assertEquals(2, counting.resultsForwarded); + assertEquals(List.of("before", "after"), hook.stages); + } + + @Test + public void namesTheWrappedHookWhenItReportsAnError() { + Hook throwing = new Hook("throwing-hook") { + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + throw new RuntimeException("Hook error"); + } + }; + HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(throwing, 60_000)); + + evaluate(runner); + + logging.assertErrorLogged("During evaluation of flag \"test-flag\". Stage \"beforeEvaluation\" " + + "of hook \"throwing-hook\" reported error: java.lang.RuntimeException: Hook error"); + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java index d77216b5..92b71fd6 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java @@ -3,7 +3,6 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; @@ -37,17 +36,6 @@ public void recordsEverythingForNonPositiveWindow() { } } - @Test - public void disabledRecordsEverythingAndIsSharedAcrossHooks() { - EvaluationExposureDeduper deduper = EvaluationExposureDeduper.disabled(); - assertTrue(deduper.shouldRecord(key("a"), 1000)); - assertTrue(deduper.shouldRecord(key("a"), 1000)); - deduper.reset(); - assertTrue(deduper.shouldRecord(key("a"), 1000)); - // The runner recognizes it by identity to skip building exposure keys altogether. - assertSame(deduper, EvaluationExposureDeduper.disabled()); - } - @Test public void exposureKeyDistinguishesEveryComponent() { EvaluationExposureKey key = new EvaluationExposureKey("default", "flag", 1, 2, false, "user-key"); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java index 3d3ad5a6..2f426028 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java @@ -10,7 +10,6 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; @@ -104,155 +103,69 @@ private void evaluate(HookRunner runner) { () -> EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off())); } - @Test - public void hookDeduperSkipsBothStagesOfARepeatedEvaluation() { - RecordingHook hook = new RecordingHook("deduping"); - hook.evaluationExposureDeduper(60_000); - HookRunner runner = new HookRunner(logging.logger, List.of(hook), - (flagKey, context) -> EXPOSURE_KEY); - - evaluate(runner); - evaluate(runner); - - // A suppressed evaluation must not leave a beforeEvaluation unmatched by its afterEvaluation. - assertEquals(List.of("before", "after"), hook.stages); - } - - @Test - public void hookDeduperWithoutParametersSkipsARepeatedEvaluation() { - RecordingHook hook = new RecordingHook("deduping"); - hook.evaluationExposureDeduper(); - HookRunner runner = new HookRunner(logging.logger, List.of(hook), - (flagKey, context) -> EXPOSURE_KEY); - - evaluate(runner); - evaluate(runner); - - assertEquals(List.of("before", "after"), hook.stages); - } - - @Test - public void hookWithoutADeduperObservesEveryEvaluation() { - RecordingHook hook = new RecordingHook("no-deduper"); - HookRunner runner = new HookRunner(logging.logger, List.of(hook), - (flagKey, context) -> EXPOSURE_KEY); - - evaluate(runner); - evaluate(runner); - - assertEquals(List.of("before", "after", "before", "after"), hook.stages); - } - - @Test - public void hooksAreDeduplicatedIndependentlyOfEachOther() { - RecordingHook deduping = new RecordingHook("deduping"); - deduping.evaluationExposureDeduper(60_000); - RecordingHook reportingEverything = new RecordingHook("reporting-everything"); - HookRunner runner = new HookRunner(logging.logger, List.of(deduping, reportingEverything), - (flagKey, context) -> EXPOSURE_KEY); - - evaluate(runner); - evaluate(runner); - - assertEquals(List.of("before", "after"), deduping.stages); - assertEquals(List.of("before", "after", "before", "after"), reportingEverything.stages); - } - - @Test - public void hooksGivenSeparateDedupersDoNotSuppressEachOther() { - RecordingHook first = new RecordingHook("first"); - first.evaluationExposureDeduper(60_000); - RecordingHook second = new RecordingHook("second"); - second.evaluationExposureDeduper(60_000); - HookRunner runner = new HookRunner(logging.logger, List.of(first, second), - (flagKey, context) -> EXPOSURE_KEY); - - evaluate(runner); - evaluate(runner); - - // Sharing one deduper would have let the first hook's report suppress the second hook's. - assertEquals(List.of("before", "after"), first.stages); - assertEquals(List.of("before", "after"), second.stages); - } - - @Test - public void hooksSharingOneDeduperShareItsWindow() { - EvaluationExposureDeduper shared = new EvaluationExposureDeduper(60_000); - RecordingHook first = new RecordingHook("first"); - first.evaluationExposureDeduper(shared); - RecordingHook second = new RecordingHook("second"); - second.evaluationExposureDeduper(shared); - HookRunner runner = new HookRunner(logging.logger, List.of(first, second), - (flagKey, context) -> EXPOSURE_KEY); + /** + * Reads what an evaluation's result identifies, the way a deduping hook does, so a test can tell + * when the runner resolved that and how often. + */ + private static class KeyReadingHook extends Hook { + final List keys = new ArrayList<>(); - evaluate(runner); + KeyReadingHook(String name) { + super(name); + } - // The first hook's report starts the window, which suppresses the second hook's. - assertEquals(List.of("before", "after"), first.stages); - assertEquals(List.of(), second.stages); + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + keys.add(seriesContext.getEvaluationExposureKey()); + return seriesData; + } } @Test - public void resettingDedupersReportsTheSameEvaluationAgain() { - RecordingHook hook = new RecordingHook("deduping"); - hook.evaluationExposureDeduper(60_000); + public void tellsAHookWhatTheEvaluationsResultIdentifies() { + KeyReadingHook hook = new KeyReadingHook("key-reading"); HookRunner runner = new HookRunner(logging.logger, List.of(hook), (flagKey, context) -> EXPOSURE_KEY); evaluate(runner); - runner.resetEvaluationExposureDedupers(); - evaluate(runner); - assertEquals(List.of("before", "after", "before", "after"), hook.stages); + assertEquals(List.of(EXPOSURE_KEY), hook.keys); } @Test - public void buildsTheExposureKeyOncePerEvaluationRegardlessOfHookCount() { - RecordingHook first = new RecordingHook("first"); - first.evaluationExposureDeduper(60_000); - RecordingHook second = new RecordingHook("second"); - second.evaluationExposureDeduper(60_000); + public void doesNotBuildTheExposureKeyUnlessAHookAsksForIt() { + RecordingHook hook = new RecordingHook("reporting-everything"); List keyRequests = new ArrayList<>(); - HookRunner runner = new HookRunner(logging.logger, List.of(first, second), + HookRunner runner = new HookRunner(logging.logger, List.of(hook), (flagKey, context) -> { keyRequests.add(flagKey); return EXPOSURE_KEY; }); evaluate(runner); - evaluate(runner); - assertEquals(List.of("test-flag", "test-flag"), keyRequests); + assertEquals(List.of(), keyRequests); + assertEquals(List.of("before", "after"), hook.stages); } @Test - public void doesNotBuildTheExposureKeyWhenNoHookCanSuppress() { - RecordingHook hook = new RecordingHook("reporting-everything"); + public void buildsTheExposureKeyOncePerEvaluationHoweverManyHooksAskForIt() { + KeyReadingHook first = new KeyReadingHook("first"); + KeyReadingHook second = new KeyReadingHook("second"); List keyRequests = new ArrayList<>(); - HookRunner runner = new HookRunner(logging.logger, List.of(hook), + HookRunner runner = new HookRunner(logging.logger, List.of(first, second), (flagKey, context) -> { keyRequests.add(flagKey); return EXPOSURE_KEY; }); - evaluate(runner); - - assertEquals(List.of(), keyRequests); - assertEquals(List.of("before", "after"), hook.stages); - } - - @Test - public void hookAddedLaterCarriesItsOwnDeduper() { - RecordingHook added = new RecordingHook("added"); - added.evaluationExposureDeduper(60_000); - HookRunner runner = new HookRunner(logging.logger, List.of(), - (flagKey, context) -> EXPOSURE_KEY); - runner.addHook(added); - evaluate(runner); evaluate(runner); - assertEquals(List.of("before", "after"), added.stages); + // Both hooks in an evaluation share the one key the series context resolved for it. + assertEquals(List.of("test-flag", "test-flag"), keyRequests); + assertEquals(List.of(EXPOSURE_KEY, EXPOSURE_KEY), first.keys); + assertEquals(List.of(EXPOSURE_KEY, EXPOSURE_KEY), second.keys); } @Test From c47f3948c87f8b1458a3153da2d939281295f044 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 10 Aug 2026 15:18:35 -0700 Subject: [PATCH 20/29] refactor: pass the whole evaluation to the exposure key supplier The supplier took the flag key and context, which are the parts an exposure key is built from today. Taking the series context instead means a component added to the key later that the call site knows, such as the method name or the default value, does not change a public signature. --- .../java/com/launchdarkly/sdk/android/LDClient.java | 6 ++++-- .../integrations/EvaluationExposureKeySupplier.java | 10 +++++----- .../android/integrations/EvaluationSeriesContext.java | 2 +- .../com/launchdarkly/sdk/android/DedupingHookTest.java | 6 +++--- .../com/launchdarkly/sdk/android/HookRunnerTest.java | 10 +++++----- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index ca9587c6..159b7e3c 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -16,6 +16,7 @@ import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata; import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; import com.launchdarkly.sdk.android.integrations.Plugin; @@ -759,7 +760,8 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ * after stage would leave that span open until something else closed it. The stored flag * identifies the same exposure the result would, since the result is derived from it. */ - private EvaluationExposureKey exposureKey(String flagKey, LDContext context) { + private EvaluationExposureKey exposureKey(EvaluationSeriesContext seriesContext) { + String flagKey = seriesContext.flagKey; Flag flag = contextDataManager.getNonDeletedFlag(flagKey); int variation = flag == null || flag.getVariation() == null ? EvaluationDetail.NO_VARIATION : flag.getVariation(); @@ -767,7 +769,7 @@ private EvaluationExposureKey exposureKey(String flagKey, LDContext context) { boolean inExperiment = flag != null && flag.getReason() != null && flag.getReason().isInExperiment(); return new EvaluationExposureKey(clientContextImpl.getEnvironmentName(), flagKey, variation, - flagVersion, inExperiment, context.getFullyQualifiedKey()); + flagVersion, inExperiment, seriesContext.context.getFullyQualifiedKey()); } /** diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java index daf1dab6..14832807 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java @@ -1,20 +1,20 @@ package com.launchdarkly.sdk.android.integrations; -import com.launchdarkly.sdk.LDContext; - /** * Builds the key identifying the result an evaluation is about to return. *

* The SDK gives one of these to each {@link EvaluationSeriesContext} it builds, so that a hook which * needs the identity of an evaluation can ask for it without the SDK computing it for hooks that do * not. {@link DedupingHook} is the hook that needs it. + *

+ * The whole evaluation is the parameter, rather than the parts of it a key is built from today, so + * that a component added to {@link EvaluationExposureKey} later does not change this signature. */ @FunctionalInterface public interface EvaluationExposureKeySupplier { /** - * @param flagKey the key of the flag being evaluated - * @param context the context the evaluation is for + * @param seriesContext the evaluation whose result is to be identified * @return the key identifying the result the evaluation will return */ - EvaluationExposureKey exposureKey(String flagKey, LDContext context); + EvaluationExposureKey exposureKey(EvaluationSeriesContext seriesContext); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java index ebbccfd0..9831a89d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java @@ -88,7 +88,7 @@ public EvaluationSeriesContext(String method, String key, LDContext context, LDV @Nullable public synchronized EvaluationExposureKey getEvaluationExposureKey() { if (exposureKey == null && exposureKeySupplier != null) { - exposureKey = exposureKeySupplier.exposureKey(flagKey, context); + exposureKey = exposureKeySupplier.exposureKey(this); } return exposureKey; } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DedupingHookTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DedupingHookTest.java index d9c70028..738488ea 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DedupingHookTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DedupingHookTest.java @@ -103,7 +103,7 @@ public Map afterEvaluation(EvaluationSeriesContext seriesContext } private HookRunner runner(EvaluationExposureKey key, Hook... hooks) { - return new HookRunner(logging.logger, List.of(hooks), (flagKey, context) -> key); + return new HookRunner(logging.logger, List.of(hooks), seriesContext -> key); } private void evaluate(HookRunner runner) { @@ -154,7 +154,7 @@ public void reportsAnEvaluationWhoseResultChanged() { RecordingHook hook = new RecordingHook("deduping"); List keys = new ArrayList<>(List.of(EXPOSURE_KEY, EXPOSURE_KEY, OTHER_RESULT)); HookRunner runner = new HookRunner(logging.logger, List.of(new DedupingHook(hook, 60_000)), - (flagKey, context) -> keys.remove(0)); + seriesContext -> keys.remove(0)); evaluate(runner); evaluate(runner); @@ -234,7 +234,7 @@ public void forwardsAnEvaluationWhoseResultTheSdkDidNotDescribe() { // A series context built by something other than the SDK has no result to recognize repeats // by, so nothing is suppressed. HookRunner runner = new HookRunner(logging.logger, List.of(new DedupingHook(hook, 60_000)), - (flagKey, context) -> null); + seriesContext -> null); evaluate(runner); evaluate(runner); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java index 2f426028..30a9a90c 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java @@ -125,7 +125,7 @@ public Map beforeEvaluation(EvaluationSeriesContext seriesContex public void tellsAHookWhatTheEvaluationsResultIdentifies() { KeyReadingHook hook = new KeyReadingHook("key-reading"); HookRunner runner = new HookRunner(logging.logger, List.of(hook), - (flagKey, context) -> EXPOSURE_KEY); + seriesContext -> EXPOSURE_KEY); evaluate(runner); @@ -137,8 +137,8 @@ public void doesNotBuildTheExposureKeyUnlessAHookAsksForIt() { RecordingHook hook = new RecordingHook("reporting-everything"); List keyRequests = new ArrayList<>(); HookRunner runner = new HookRunner(logging.logger, List.of(hook), - (flagKey, context) -> { - keyRequests.add(flagKey); + seriesContext -> { + keyRequests.add(seriesContext.flagKey); return EXPOSURE_KEY; }); @@ -154,8 +154,8 @@ public void buildsTheExposureKeyOncePerEvaluationHoweverManyHooksAskForIt() { KeyReadingHook second = new KeyReadingHook("second"); List keyRequests = new ArrayList<>(); HookRunner runner = new HookRunner(logging.logger, List.of(first, second), - (flagKey, context) -> { - keyRequests.add(flagKey); + seriesContext -> { + keyRequests.add(seriesContext.flagKey); return EXPOSURE_KEY; }); From 4c3467a33b1780237c30787ab46dc9f14c82ff9d Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 10 Aug 2026 16:57:40 -0700 Subject: [PATCH 21/29] fix: measure a dedupe window against a clock a time correction cannot move Windows were measured against System.currentTimeMillis(). A correction that moves the device clock backwards leaves every recorded time in the future, so those flags stay suppressed until real time catches up with them, which for a large correction is hours of dropped exposures. elapsedRealtime() counts from boot, so no correction reaches it, and unlike System.nanoTime() it advances while the device sleeps, so a window is an interval of real time rather than of awake time. The decorator takes a clock so that a unit test can control it, which also lets the tests cover a window elapsing. --- .../android/integrations/DedupingHook.java | 30 ++++++- .../EvaluationExposureDeduper.java | 6 +- .../{ => integrations}/DedupingHookTest.java | 83 +++++++++++++------ 3 files changed, 91 insertions(+), 28 deletions(-) rename launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/{ => integrations}/DedupingHookTest.java (79%) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java index c77431a8..5d71dbd3 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java @@ -1,5 +1,9 @@ package com.launchdarkly.sdk.android.integrations; +import android.os.SystemClock; + +import androidx.annotation.VisibleForTesting; + import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDValue; @@ -44,10 +48,28 @@ */ public final class DedupingHook extends HookDecorator { + /** + * Reads the clock a window is measured against. Exists so that tests can control it; the SDK has + * one implementation. + */ + interface Clock { + long elapsedMillis(); + } + + /** + * Counts from boot rather than from the epoch, so that correcting the device clock cannot stretch + * a window: were this wall clock time, a correction that moved the clock backwards would leave + * every recorded time in the future and suppress those flags until real time caught up. It also + * advances while the device sleeps, unlike {@code System.nanoTime()}, so a window is an interval + * of real time rather than of awake time. + */ + private static final Clock ELAPSED_REALTIME = SystemClock::elapsedRealtime; + // Namespaced because it travels in series data that the wrapped hook may also write to. private static final String SUPPRESSED = "com.launchdarkly.sdk.android.DedupingHook.suppressed"; private final EvaluationExposureDeduper deduper; + private final Clock clock; // Returned in place of the wrapped hook's series data when an evaluation is suppressed, and // recognized by identity so that stacked instances each recognize only their own suppressions. @@ -77,8 +99,14 @@ public DedupingHook(Hook delegate, int windowMillis) { * @param deduper decides which evaluations reach the wrapped hook */ public DedupingHook(Hook delegate, EvaluationExposureDeduper deduper) { + this(delegate, deduper, ELAPSED_REALTIME); + } + + @VisibleForTesting + DedupingHook(Hook delegate, EvaluationExposureDeduper deduper, Clock clock) { super(delegate); this.deduper = Objects.requireNonNull(deduper, "a deduping hook must have a deduper"); + this.clock = clock; } /** @@ -95,7 +123,7 @@ public DedupingHook(Hook delegate, EvaluationExposureDeduper deduper) { @Override public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { EvaluationExposureKey key = seriesContext.getEvaluationExposureKey(); - if (key != null && !deduper.shouldRecord(key, System.currentTimeMillis())) { + if (key != null && !deduper.shouldRecord(key, clock.elapsedMillis())) { return suppressedSeriesData; } return super.beforeEvaluation(seriesContext, seriesData); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java index 599ac930..710fef4b 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -72,7 +72,11 @@ public EvaluationExposureDeduper(int windowMillis) { * result. * * @param key the key identifying the evaluation result - * @param nowMillis the current time in milliseconds since the epoch + * @param nowMillis a reading of a clock that counts from an arbitrary point, in milliseconds. + * {@link DedupingHook} passes {@code SystemClock.elapsedRealtime()}, so that + * correcting the device clock cannot stretch a window. Only differences between + * readings are meaningful: this is not a time of day, and comparing it with + * {@code System.currentTimeMillis()} is a mistake. * @return true if the hook should observe this evaluation, false if it should be suppressed */ public synchronized boolean shouldRecord(EvaluationExposureKey key, long nowMillis) { diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DedupingHookTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java similarity index 79% rename from launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DedupingHookTest.java rename to launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java index 738488ea..540f2884 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DedupingHookTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.sdk.android; +package com.launchdarkly.sdk.android.integrations; import static org.junit.Assert.assertEquals; @@ -6,15 +6,8 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.android.integrations.DedupingHook; -import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; -import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; -import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; -import com.launchdarkly.sdk.android.integrations.Hook; -import com.launchdarkly.sdk.android.integrations.HookDecorator; -import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext; -import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; -import com.launchdarkly.sdk.android.integrations.TrackSeriesContext; +import com.launchdarkly.sdk.android.HookRunner; +import com.launchdarkly.sdk.android.LogCaptureRule; import org.junit.Rule; import org.junit.Test; @@ -26,6 +19,9 @@ /** * Drives hooks through {@link HookRunner} rather than calling the decorator directly, because how the * runner carries series data from one stage to the next is what a decorator has to work with. + *

+ * Lives in the decorator's own package so that it can hand it a clock, since what the SDK reads is + * {@code SystemClock}, which a unit test cannot call. */ public class DedupingHookTest { private static final EvaluationExposureKey EXPOSURE_KEY = @@ -36,6 +32,18 @@ public class DedupingHookTest { @Rule public LogCaptureRule logging = new LogCaptureRule(); + /** Lets a test decide how much time has passed, and how much has not. */ + private static final class FakeClock implements DedupingHook.Clock { + long millis = 1_000; + + @Override + public long elapsedMillis() { + return millis; + } + } + + private final FakeClock clock = new FakeClock(); + /** * Records the stages it observes, so a test can tell a suppressed evaluation (no stages) from a * reported one. @@ -102,6 +110,14 @@ public Map afterEvaluation(EvaluationSeriesContext seriesContext } } + private DedupingHook deduping(Hook delegate, int windowMillis) { + return new DedupingHook(delegate, new EvaluationExposureDeduper(windowMillis), clock); + } + + private DedupingHook deduping(Hook delegate, EvaluationExposureDeduper deduper) { + return new DedupingHook(delegate, deduper, clock); + } + private HookRunner runner(EvaluationExposureKey key, Hook... hooks) { return new HookRunner(logging.logger, List.of(hooks), seriesContext -> key); } @@ -118,7 +134,7 @@ private void identify(HookRunner runner) { @Test public void skipsBothStagesOfARepeatedEvaluation() { RecordingHook hook = new RecordingHook("deduping"); - HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(hook, 60_000)); + HookRunner runner = runner(EXPOSURE_KEY, deduping(hook, 60_000)); evaluate(runner); evaluate(runner); @@ -127,12 +143,27 @@ public void skipsBothStagesOfARepeatedEvaluation() { assertEquals(List.of("before", "after"), hook.stages); } + @Test + public void reportsTheSameResultAgainOnceTheWindowElapses() { + RecordingHook hook = new RecordingHook("deduping"); + HookRunner runner = runner(EXPOSURE_KEY, deduping(hook, 60_000)); + + evaluate(runner); + clock.millis += 59_999; + evaluate(runner); + clock.millis += 1; + evaluate(runner); + + assertEquals(List.of("before", "after", "before", "after"), hook.stages); + } + @Test public void usesTheDefaultWindowWhenWrappedWithoutOne() { RecordingHook hook = new RecordingHook("deduping"); - HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(hook)); + HookRunner runner = runner(EXPOSURE_KEY, deduping(hook, new EvaluationExposureDeduper())); evaluate(runner); + clock.millis += EvaluationExposureDeduper.DEFAULT_WINDOW_MILLIS - 1; evaluate(runner); assertEquals(List.of("before", "after"), hook.stages); @@ -153,7 +184,7 @@ public void anUnwrappedHookObservesEveryEvaluation() { public void reportsAnEvaluationWhoseResultChanged() { RecordingHook hook = new RecordingHook("deduping"); List keys = new ArrayList<>(List.of(EXPOSURE_KEY, EXPOSURE_KEY, OTHER_RESULT)); - HookRunner runner = new HookRunner(logging.logger, List.of(new DedupingHook(hook, 60_000)), + HookRunner runner = new HookRunner(logging.logger, List.of(deduping(hook, 60_000)), seriesContext -> keys.remove(0)); evaluate(runner); @@ -165,14 +196,14 @@ public void reportsAnEvaluationWhoseResultChanged() { @Test public void wrappedHooksAreDeduplicatedIndependentlyOfEachOther() { - RecordingHook deduping = new RecordingHook("deduping"); + RecordingHook wrapped = new RecordingHook("deduping"); RecordingHook reportingEverything = new RecordingHook("reporting-everything"); - HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(deduping, 60_000), reportingEverything); + HookRunner runner = runner(EXPOSURE_KEY, deduping(wrapped, 60_000), reportingEverything); evaluate(runner); evaluate(runner); - assertEquals(List.of("before", "after"), deduping.stages); + assertEquals(List.of("before", "after"), wrapped.stages); assertEquals(List.of("before", "after", "before", "after"), reportingEverything.stages); } @@ -180,7 +211,7 @@ public void wrappedHooksAreDeduplicatedIndependentlyOfEachOther() { public void hooksWrappedSeparatelyDoNotSuppressEachOther() { RecordingHook first = new RecordingHook("first"); RecordingHook second = new RecordingHook("second"); - HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(first, 60_000), new DedupingHook(second, 60_000)); + HookRunner runner = runner(EXPOSURE_KEY, deduping(first, 60_000), deduping(second, 60_000)); evaluate(runner); evaluate(runner); @@ -195,7 +226,7 @@ public void hooksSharingOneDeduperShareItsWindow() { EvaluationExposureDeduper shared = new EvaluationExposureDeduper(60_000); RecordingHook first = new RecordingHook("first"); RecordingHook second = new RecordingHook("second"); - HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(first, shared), new DedupingHook(second, shared)); + HookRunner runner = runner(EXPOSURE_KEY, deduping(first, shared), deduping(second, shared)); evaluate(runner); @@ -207,7 +238,7 @@ public void hooksSharingOneDeduperShareItsWindow() { @Test public void identifyReportsTheSameEvaluationAgain() { RecordingHook hook = new RecordingHook("deduping"); - HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(hook, 60_000)); + HookRunner runner = runner(EXPOSURE_KEY, deduping(hook, 60_000)); evaluate(runner); identify(runner); @@ -220,7 +251,7 @@ public void identifyReportsTheSameEvaluationAgain() { @Test public void forwardsTheStagesItDoesNotDeduplicate() { RecordingHook hook = new RecordingHook("deduping"); - HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(hook, 60_000)); + HookRunner runner = runner(EXPOSURE_KEY, deduping(hook, 60_000)); identify(runner); runner.afterTrack("event-key", LDContext.create("user-123"), LDValue.ofNull(), null); @@ -233,7 +264,7 @@ public void forwardsAnEvaluationWhoseResultTheSdkDidNotDescribe() { RecordingHook hook = new RecordingHook("deduping"); // A series context built by something other than the SDK has no result to recognize repeats // by, so nothing is suppressed. - HookRunner runner = new HookRunner(logging.logger, List.of(new DedupingHook(hook, 60_000)), + HookRunner runner = new HookRunner(logging.logger, List.of(deduping(hook, 60_000)), seriesContext -> null); evaluate(runner); @@ -245,7 +276,7 @@ public void forwardsAnEvaluationWhoseResultTheSdkDidNotDescribe() { @Test public void deduperStacksInsideAnotherDecorator() { RecordingHook hook = new RecordingHook("wrapped-twice"); - CountingDecorator counting = new CountingDecorator(new DedupingHook(hook, 60_000)); + CountingDecorator counting = new CountingDecorator(deduping(hook, 60_000)); HookRunner runner = runner(EXPOSURE_KEY, counting); evaluate(runner); @@ -260,7 +291,7 @@ public void deduperStacksInsideAnotherDecorator() { public void deduperStacksAroundAnotherDecorator() { RecordingHook hook = new RecordingHook("wrapped-twice"); CountingDecorator counting = new CountingDecorator(hook); - HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(counting, 60_000)); + HookRunner runner = runner(EXPOSURE_KEY, deduping(counting, 60_000)); evaluate(runner); evaluate(runner); @@ -273,10 +304,10 @@ public void deduperStacksAroundAnotherDecorator() { @Test public void aDeduperDoesNotSwallowTheStagesOfADeduperInsideIt() { RecordingHook hook = new RecordingHook("deduped-twice"); - CountingDecorator counting = new CountingDecorator(new DedupingHook(hook, 60_000)); + CountingDecorator counting = new CountingDecorator(deduping(hook, 60_000)); // The outer deduper reports everything, so what the inner one suppresses has to travel back // out through the decorator between them, which each stage of still belongs to. - HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(counting, 0)); + HookRunner runner = runner(EXPOSURE_KEY, deduping(counting, 0)); evaluate(runner); evaluate(runner); @@ -294,7 +325,7 @@ public Map beforeEvaluation(EvaluationSeriesContext seriesContex throw new RuntimeException("Hook error"); } }; - HookRunner runner = runner(EXPOSURE_KEY, new DedupingHook(throwing, 60_000)); + HookRunner runner = runner(EXPOSURE_KEY, deduping(throwing, 60_000)); evaluate(runner); From 2c29f469efa66a580067939eda3f74db4f9cb707 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 10 Aug 2026 17:13:27 -0700 Subject: [PATCH 22/29] perf: hash an exposure key only when something asks for its hash The hash was computed when a key was built, from when the deduper held every exposure it had seen in a map keyed by the whole key. It now recognizes a repeat by the flag a key belongs to and the result it describes, so it never hashes a key, and every evaluation a deduping hook saw was paying for a value nothing read. Only a deduper of your own that holds keys in a map or a set hashes one now, so compute it there. --- .../integrations/EvaluationExposureKey.java | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java index 1bd1019e..2e3459f9 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java @@ -15,7 +15,7 @@ * configured on {@code LDConfig} is one instance shared by the clients for every environment in * {@code secondaryMobileKeys}, and so is its deduper. *

- * Instances are immutable, and their hash code is computed once, when the key is built. + * Instances are immutable, and their hash code is computed once, the first time one is asked for. */ public final class EvaluationExposureKey { private final String environmentName; @@ -24,7 +24,12 @@ public final class EvaluationExposureKey { private final int flagVersion; private final boolean inExperiment; private final String fullyQualifiedContextKey; - private final int hashCode; + + // Computed on demand, because the SDK's own deduper recognizes a repeat by the flag a key belongs + // to and the result it describes, and so never hashes a whole key: only a deduper of your own + // that holds keys in a map or a set does. Races are benign, as every thread computes the same + // value from fields that cannot change. + private int hashCode; /** * @param environmentName the name of the environment the evaluation was made against @@ -43,13 +48,6 @@ public EvaluationExposureKey(String environmentName, String flagKey, int variati this.flagVersion = flagVersion; this.inExperiment = inExperiment; this.fullyQualifiedContextKey = fullyQualifiedContextKey; - - int hash = Objects.hashCode(environmentName); - hash = 31 * hash + Objects.hashCode(flagKey); - hash = 31 * hash + variation; - hash = 31 * hash + flagVersion; - hash = 31 * hash + (inExperiment ? 1 : 0); - this.hashCode = 31 * hash + Objects.hashCode(fullyQualifiedContextKey); } /** @@ -104,9 +102,8 @@ public boolean equals(Object other) { } EvaluationExposureKey o = (EvaluationExposureKey) other; - // The cached hash codes and the primitives reject unequal keys without touching the strings. - return hashCode == o.hashCode - && variation == o.variation + // The primitives reject most unequal keys without touching the strings. + return variation == o.variation && flagVersion == o.flagVersion && inExperiment == o.inExperiment && Objects.equals(flagKey, o.flagKey) @@ -116,7 +113,17 @@ public boolean equals(Object other) { @Override public int hashCode() { - return hashCode; + int hash = hashCode; + if (hash == 0) { + hash = Objects.hashCode(environmentName); + hash = 31 * hash + Objects.hashCode(flagKey); + hash = 31 * hash + variation; + hash = 31 * hash + flagVersion; + hash = 31 * hash + (inExperiment ? 1 : 0); + hash = 31 * hash + Objects.hashCode(fullyQualifiedContextKey); + hashCode = hash; + } + return hash; } @Override From 3f968fc03abcfc7151ec036158de48c7bd05cff5 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 10 Aug 2026 17:32:49 -0700 Subject: [PATCH 23/29] docs: say that a deduping hook belongs outermost when decorators stack Suppressing an evaluation means returning series data that says so in place of what the stage was given, so a decorator outside the deduper does not get back what it stored in its own before stage. Documented rather than fixed: preserving that data would mean copying a map on the suppression path, which is the path the feature exists to keep cheap. Co-authored-by: Cursor --- .../launchdarkly/sdk/android/integrations/DedupingHook.java | 5 +++++ .../launchdarkly/sdk/android/integrations/HookDecorator.java | 3 +++ .../sdk/android/integrations/DedupingHookTest.java | 4 +++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java index 5d71dbd3..87315f38 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java @@ -45,6 +45,11 @@ *

* Give each hook its own instance unless you intend hooks to share a window: the first hook to be told * about an evaluation starts the window that suppresses the rest. + *

+ * Wrap outermost when you stack decorators. Suppressing an evaluation means returning series data that + * says so in place of what the stage was given, so a decorator outside this one does not get back what + * it stored in its own before stage. A decorator inside this one is unaffected, since a suppressed + * evaluation never reaches it. */ public final class DedupingHook extends HookDecorator { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookDecorator.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookDecorator.java index bfc1d664..4b29ee37 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookDecorator.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookDecorator.java @@ -49,6 +49,9 @@ * {@link Hook#afterEvaluation(EvaluationSeriesContext, Map, EvaluationDetail)}, so suppressing only * the after stage leaves that span open. To carry the decision from one stage to the other, return * series data the after stage recognizes, the way {@link DedupingHook} does. + *

+ * A decorator that does that belongs outermost, because the series data it returns replaces what it + * was given: a decorator outside it does not get back what it stored in its own before stage. */ public abstract class HookDecorator extends Hook { diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java index 540f2884..9f3a54ec 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java @@ -282,7 +282,9 @@ public void deduperStacksInsideAnotherDecorator() { evaluate(runner); evaluate(runner); - // The outer decorator sees both evaluations, and the deduper inside it passes on one. + // The outer decorator sees both evaluations, and the deduper inside it passes on one. This is + // the arrangement the documentation advises against, and it works as long as the decorator + // outside the deduper does not store series data, which a suppressed evaluation replaces. assertEquals(2, counting.evaluationsForwarded); assertEquals(List.of("before", "after"), hook.stages); } From 5d721f2f0e7271a0d66ef26d70c086e5f8db3427 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 10 Aug 2026 18:35:17 -0700 Subject: [PATCH 24/29] refactor: read the flag once per evaluation, for the hooks and the result The exposure key supplier read the store a second time, so a flag update landing between the two reads left a deduping hook told about a result the evaluation did not return. The evaluation's own read is now handed to the supplier, which also retires the memoization the second read needed, since every hook asking about one fixed flag is told about one result. The ten variation methods collapse onto a helper that does that read, so there is one place expressing the order of the read, the hooks and the evaluation. Co-authored-by: Cursor --- .../launchdarkly/sdk/android/HookRunner.java | 18 ++- .../launchdarkly/sdk/android/LDClient.java | 120 +++++++----------- .../EvaluationExposureKeySupplier.java | 16 ++- .../integrations/EvaluationSeriesContext.java | 39 +++--- .../sdk/android/HookRunnerTest.java | 28 ++-- .../integrations/DedupingHookTest.java | 6 +- 6 files changed, 111 insertions(+), 116 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java index edf91539..6415aff0 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java @@ -32,7 +32,7 @@ public interface AfterIdentifyMethod { private final LDLogger logger; private final List hooks = new ArrayList<>(); - // Handed to every evaluation series context, which resolves it only if a hook asks what the + // Handed to every evaluation series context, which calls it only if a hook asks what the // evaluation's result identifies. Dedupe is the only thing that asks, and only a hook that has // been wrapped in a DedupingHook dedupes, so an application without one never pays for this. private final EvaluationExposureKeySupplier exposureKeySupplier; @@ -68,14 +68,28 @@ public void addHook(Hook hook) { hooks.add(hook); } + /** + * Runs the evaluation series around an evaluation of a flag the caller has not read, so a hook + * that asks what the evaluation's result identifies is told nothing. + */ public EvaluationDetail withEvaluation(String method, String key, LDContext context, LDValue defaultValue, EvaluationMethod evalMethod) { + return withEvaluation(method, key, context, defaultValue, null, evalMethod); + } + + /** + * Runs the evaluation series around an evaluation, against the caller's own read of the flag. + * + * @param flag the read the evaluation derives its result from, so that a hook is told about the + * result the evaluation returns rather than about a later read of the store + */ + public EvaluationDetail withEvaluation(String method, String key, LDContext context, LDValue defaultValue, DataModel.Flag flag, EvaluationMethod evalMethod) { if (hooks.isEmpty()) { return evalMethod.evaluate(); } List> seriesDataList = new ArrayList<>(hooks.size()); EvaluationSeriesContext seriesContext = - new EvaluationSeriesContext(method, key, context, defaultValue, exposureKeySupplier); + new EvaluationSeriesContext(method, key, context, defaultValue, exposureKeySupplier, flag); for (int i = 0; i < hooks.size(); i++) { Hook currentHook = hooks.get(i); try { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index 159b7e3c..bb193892 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -3,6 +3,7 @@ import android.app.Application; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.launchdarkly.logging.LDLogger; @@ -555,123 +556,89 @@ public Map allFlags() { @Override public boolean boolVariation(@NonNull String key, boolean defaultValue) { - return hookRunner.withEvaluation( - "LDClient.boolVariation", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, false) - ).getValue().booleanValue(); + return evaluateWithHooks("LDClient.boolVariation", key, LDValue.of(defaultValue), true, false) + .getValue().booleanValue(); } @Override public EvaluationDetail boolVariationDetail(@NonNull String key, boolean defaultValue) { return convertDetailType( - hookRunner.withEvaluation( - "LDClient.boolVariationDetail", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, true) - ), + evaluateWithHooks("LDClient.boolVariationDetail", key, LDValue.of(defaultValue), true, true), LDValue.Convert.Boolean ); } @Override public int intVariation(@NonNull String key, int defaultValue) { - return hookRunner.withEvaluation( - "LDClient.intVariation", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, false) - ).getValue().intValue(); + return evaluateWithHooks("LDClient.intVariation", key, LDValue.of(defaultValue), true, false) + .getValue().intValue(); } @Override public EvaluationDetail intVariationDetail(@NonNull String key, int defaultValue) { return convertDetailType( - hookRunner.withEvaluation( - "LDClient.intVariationDetail", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, true) - ), + evaluateWithHooks("LDClient.intVariationDetail", key, LDValue.of(defaultValue), true, true), LDValue.Convert.Integer ); } @Override public double doubleVariation(@NonNull String key, double defaultValue) { - return hookRunner.withEvaluation( - "LDClient.doubleVariation", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, false) - ).getValue().doubleValue(); + return evaluateWithHooks("LDClient.doubleVariation", key, LDValue.of(defaultValue), true, false) + .getValue().doubleValue(); } @Override public EvaluationDetail doubleVariationDetail(@NonNull String key, double defaultValue) { return convertDetailType( - hookRunner.withEvaluation( - "LDClient.doubleVariationDetail", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, true) - ), + evaluateWithHooks("LDClient.doubleVariationDetail", key, LDValue.of(defaultValue), true, true), LDValue.Convert.Double ); } @Override public String stringVariation(@NonNull String key, String defaultValue) { - return hookRunner.withEvaluation( - "LDClient.stringVariation", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, false) - ).getValue().stringValue(); + return evaluateWithHooks("LDClient.stringVariation", key, LDValue.of(defaultValue), true, false) + .getValue().stringValue(); } @Override public EvaluationDetail stringVariationDetail(@NonNull String key, String defaultValue) { return convertDetailType( - hookRunner.withEvaluation( - "LDClient.stringVariationDetail", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, true) - ), + evaluateWithHooks("LDClient.stringVariationDetail", key, LDValue.of(defaultValue), true, true), LDValue.Convert.String ); } @Override public LDValue jsonValueVariation(@NonNull String key, LDValue defaultValue) { - return hookRunner.withEvaluation( - "LDClient.jsonValueVariation", - key, - clientContextImpl.getEvaluationContext(), - LDValue.normalize(defaultValue), - () -> variationDetailInternal(key, LDValue.normalize(defaultValue), false, false) - ).getValue(); + return evaluateWithHooks("LDClient.jsonValueVariation", key, LDValue.normalize(defaultValue), false, false) + .getValue(); } @Override public EvaluationDetail jsonValueVariationDetail(@NonNull String key, LDValue defaultValue) { + return evaluateWithHooks("LDClient.jsonValueVariationDetail", key, LDValue.normalize(defaultValue), false, true); + } + + /** + * Runs an evaluation, and the hooks around it, against one read of the flag. + *

+ * The read is done here rather than left to the evaluation so that the result a hook is told the + * evaluation is about to return is the result it does return: were the store read a second time to + * describe the evaluation to hooks, an update landing in between would leave the two describing + * different results. + */ + private EvaluationDetail evaluateWithHooks(String method, String key, LDValue defaultValue, + boolean checkType, boolean needsReason) { + Flag flag = contextDataManager.getNonDeletedFlag(key); // returns null for nonexistent *or* deleted flag return hookRunner.withEvaluation( - "LDClient.jsonValueVariationDetail", + method, key, clientContextImpl.getEvaluationContext(), - LDValue.normalize(defaultValue), - () -> variationDetailInternal(key, LDValue.normalize(defaultValue), false, true) + defaultValue, + flag, + () -> variationDetailInternal(key, defaultValue, checkType, needsReason, null, flag) ); } @@ -679,13 +646,14 @@ private EvaluationDetail convertDetailType(EvaluationDetail deta return EvaluationDetail.fromValue(converter.toType(detail.getValue()), detail.getVariationIndex(), detail.getReason()); } - private EvaluationDetail variationDetailInternal(@NonNull String key, @NonNull LDValue defaultValue, boolean checkType, boolean needsReason) { - return variationDetailInternal(key, defaultValue, checkType, needsReason, null); + private EvaluationDetail variationDetailInternal(@NonNull String key, @NonNull LDValue defaultValue, boolean checkType, boolean needsReason, Set visited) { + // returns null for nonexistent *or* deleted flag + return variationDetailInternal(key, defaultValue, checkType, needsReason, visited, + contextDataManager.getNonDeletedFlag(key)); } - private EvaluationDetail variationDetailInternal(@NonNull String key, @NonNull LDValue defaultValue, boolean checkType, boolean needsReason, Set visited) { + private EvaluationDetail variationDetailInternal(@NonNull String key, @NonNull LDValue defaultValue, boolean checkType, boolean needsReason, Set visited, @Nullable Flag flag) { LDContext context = clientContextImpl.getEvaluationContext(); - Flag flag = contextDataManager.getNonDeletedFlag(key); // returns null for nonexistent *or* deleted flag EvaluationDetail result; if (flag == null) { @@ -754,15 +722,15 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ * Identifies the evaluation a hook is about to be told about, so that a deduper can recognize a * repeat of it. *

- * This reads the stored flag rather than the evaluation result because the decision is made - * before the series opens: hooks pair their stages, so the observability plugin starts a span in + * This describes the flag rather than the evaluation result because the decision is made before + * the series opens: hooks pair their stages, so the observability plugin starts a span in * {@code beforeEvaluation} and ends it in {@code afterEvaluation}, and suppressing only the - * after stage would leave that span open until something else closed it. The stored flag - * identifies the same exposure the result would, since the result is derived from it. + * after stage would leave that span open until something else closed it. It is given the + * evaluation's own read of the flag, so it identifies the result that evaluation goes on to + * return. */ - private EvaluationExposureKey exposureKey(EvaluationSeriesContext seriesContext) { + private EvaluationExposureKey exposureKey(EvaluationSeriesContext seriesContext, @Nullable Flag flag) { String flagKey = seriesContext.flagKey; - Flag flag = contextDataManager.getNonDeletedFlag(flagKey); int variation = flag == null || flag.getVariation() == null ? EvaluationDetail.NO_VARIATION : flag.getVariation(); int flagVersion = flag == null ? EventProcessor.NO_VERSION : flag.getVersionForEvents(); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java index 14832807..2969a133 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java @@ -1,20 +1,28 @@ package com.launchdarkly.sdk.android.integrations; +import androidx.annotation.Nullable; + +import com.launchdarkly.sdk.android.DataModel; + /** * Builds the key identifying the result an evaluation is about to return. *

* The SDK gives one of these to each {@link EvaluationSeriesContext} it builds, so that a hook which - * needs the identity of an evaluation can ask for it without the SDK computing it for hooks that do + * needs the identity of an evaluation can ask for it without the SDK building one for hooks that do * not. {@link DedupingHook} is the hook that needs it. *

- * The whole evaluation is the parameter, rather than the parts of it a key is built from today, so - * that a component added to {@link EvaluationExposureKey} later does not change this signature. + * The whole evaluation is a parameter, rather than the parts of it a key is built from today, so that + * a component added to {@link EvaluationExposureKey} later does not change this signature. The flag is + * the second parameter because the evaluation has already read it: reading it again here would let an + * update landing in between describe a result the evaluation does not return. */ @FunctionalInterface public interface EvaluationExposureKeySupplier { /** * @param seriesContext the evaluation whose result is to be identified + * @param flag the evaluation's own read of the flag, which its result is derived from, or + * null if the flag was not found * @return the key identifying the result the evaluation will return */ - EvaluationExposureKey exposureKey(EvaluationSeriesContext seriesContext); + EvaluationExposureKey exposureKey(EvaluationSeriesContext seriesContext, @Nullable DataModel.Flag flag); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java index 9831a89d..263b89a1 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java @@ -4,6 +4,7 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.DataModel; import java.util.Map; import java.util.Objects; @@ -37,10 +38,9 @@ public class EvaluationSeriesContext { private final EvaluationExposureKeySupplier exposureKeySupplier; - // Resolved on demand and remembered, so that an evaluation costs a flag lookup only when a hook - // asks what its result identifies, and only once however many hooks ask. Guarded by the instance - // lock, because a hook may ask from any thread. - private EvaluationExposureKey exposureKey; + // The evaluation's own read of the flag, held rather than the key describing it so that an + // evaluation reaching only hooks that never ask what its result is builds nothing to describe it. + private final DataModel.Flag flag; /** * @param method the variation method that was used to invoke the evaluation. @@ -49,28 +49,31 @@ public class EvaluationSeriesContext { * @param defaultValue the user-provided default value for the evaluation. */ public EvaluationSeriesContext(String method, String key, LDContext context, LDValue defaultValue) { - this(method, key, context, defaultValue, null); + this(method, key, context, defaultValue, null, null); } /** - * Used by the SDK, which knows the result the evaluation will return. Application code has no use - * for this constructor: a context built with the four-argument one has no exposure key, and - * {@link #getEvaluationExposureKey()} explains what that means for a hook that wanted one. + * Used by the SDK, which has read the flag the evaluation will return a result from. Application + * code has no use for this constructor: a context built with the four-argument one has no exposure + * key, and {@link #getEvaluationExposureKey()} explains what that means for a hook that wanted one. * * @param method the variation method that was used to invoke the evaluation. * @param key the key of the feature flag being evaluated. * @param context the context the evaluation was for. * @param defaultValue the user-provided default value for the evaluation. - * @param exposureKeySupplier resolves the key identifying the result of this evaluation, or null - * if the result is not known + * @param exposureKeySupplier builds the key identifying the result of this evaluation, or null if + * the result is not known + * @param flag the evaluation's own read of the flag, or null if it was not found */ public EvaluationSeriesContext(String method, String key, LDContext context, LDValue defaultValue, - @Nullable EvaluationExposureKeySupplier exposureKeySupplier) { + @Nullable EvaluationExposureKeySupplier exposureKeySupplier, + @Nullable DataModel.Flag flag) { this.flagKey = key; this.context = context; this.defaultValue = defaultValue; this.method = method; this.exposureKeySupplier = exposureKeySupplier; + this.flag = flag; } /** @@ -78,19 +81,17 @@ public EvaluationSeriesContext(String method, String key, LDContext context, LDV * to do with an evaluation by whether it has seen the same result before. {@link DedupingHook} is * such a hook. *

- * The key describes the result as the SDK has it stored, which is what the evaluation is about to - * return, so it is available to {@link Hook#beforeEvaluation(EvaluationSeriesContext, Map)} as - * well as to the after stage. + * The key describes the evaluation's own read of the flag, the one its result is derived from, so + * it is available to {@link Hook#beforeEvaluation(EvaluationSeriesContext, Map)} as well as to the + * after stage, and every hook that asks is told about the same result. It is built on the ask, so + * an evaluation whose hooks never ask does not pay for one. * * @return the key identifying this evaluation's result, or null if this context was not built by * the SDK and so has no result to describe */ @Nullable - public synchronized EvaluationExposureKey getEvaluationExposureKey() { - if (exposureKey == null && exposureKeySupplier != null) { - exposureKey = exposureKeySupplier.exposureKey(this); - } - return exposureKey; + public EvaluationExposureKey getEvaluationExposureKey() { + return exposureKeySupplier == null ? null : exposureKeySupplier.exposureKey(this, flag); } @Override diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java index 30a9a90c..6abc83f8 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java @@ -10,6 +10,7 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.DataModel.Flag; import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; @@ -125,7 +126,7 @@ public Map beforeEvaluation(EvaluationSeriesContext seriesContex public void tellsAHookWhatTheEvaluationsResultIdentifies() { KeyReadingHook hook = new KeyReadingHook("key-reading"); HookRunner runner = new HookRunner(logging.logger, List.of(hook), - seriesContext -> EXPOSURE_KEY); + (seriesContext, flag) -> EXPOSURE_KEY); evaluate(runner); @@ -137,7 +138,7 @@ public void doesNotBuildTheExposureKeyUnlessAHookAsksForIt() { RecordingHook hook = new RecordingHook("reporting-everything"); List keyRequests = new ArrayList<>(); HookRunner runner = new HookRunner(logging.logger, List.of(hook), - seriesContext -> { + (seriesContext, flag) -> { keyRequests.add(seriesContext.flagKey); return EXPOSURE_KEY; }); @@ -149,23 +150,26 @@ public void doesNotBuildTheExposureKeyUnlessAHookAsksForIt() { } @Test - public void buildsTheExposureKeyOncePerEvaluationHoweverManyHooksAskForIt() { + public void describesTheEvaluationsOwnReadOfTheFlagToEveryHookThatAsks() { KeyReadingHook first = new KeyReadingHook("first"); KeyReadingHook second = new KeyReadingHook("second"); - List keyRequests = new ArrayList<>(); + List described = new ArrayList<>(); HookRunner runner = new HookRunner(logging.logger, List.of(first, second), - seriesContext -> { - keyRequests.add(seriesContext.flagKey); + (seriesContext, flag) -> { + described.add(flag); return EXPOSURE_KEY; }); + Flag flag = new FlagBuilder("test-flag").version(2).build(); - evaluate(runner); - evaluate(runner); + runner.withEvaluation("testMethod", "test-flag", LDContext.create("user-123"), LDValue.of(false), flag, + () -> EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off())); - // Both hooks in an evaluation share the one key the series context resolved for it. - assertEquals(List.of("test-flag", "test-flag"), keyRequests); - assertEquals(List.of(EXPOSURE_KEY, EXPOSURE_KEY), first.keys); - assertEquals(List.of(EXPOSURE_KEY, EXPOSURE_KEY), second.keys); + // Both hooks describe the read handed to the evaluation, rather than a later look at the store. + assertEquals(2, described.size()); + assertSame(flag, described.get(0)); + assertSame(flag, described.get(1)); + assertEquals(List.of(EXPOSURE_KEY), first.keys); + assertEquals(List.of(EXPOSURE_KEY), second.keys); } @Test diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java index 9f3a54ec..f4466e36 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java @@ -119,7 +119,7 @@ private DedupingHook deduping(Hook delegate, EvaluationExposureDeduper deduper) } private HookRunner runner(EvaluationExposureKey key, Hook... hooks) { - return new HookRunner(logging.logger, List.of(hooks), seriesContext -> key); + return new HookRunner(logging.logger, List.of(hooks), (seriesContext, flag) -> key); } private void evaluate(HookRunner runner) { @@ -185,7 +185,7 @@ public void reportsAnEvaluationWhoseResultChanged() { RecordingHook hook = new RecordingHook("deduping"); List keys = new ArrayList<>(List.of(EXPOSURE_KEY, EXPOSURE_KEY, OTHER_RESULT)); HookRunner runner = new HookRunner(logging.logger, List.of(deduping(hook, 60_000)), - seriesContext -> keys.remove(0)); + (seriesContext, flag) -> keys.remove(0)); evaluate(runner); evaluate(runner); @@ -265,7 +265,7 @@ public void forwardsAnEvaluationWhoseResultTheSdkDidNotDescribe() { // A series context built by something other than the SDK has no result to recognize repeats // by, so nothing is suppressed. HookRunner runner = new HookRunner(logging.logger, List.of(deduping(hook, 60_000)), - seriesContext -> null); + (seriesContext, flag) -> null); evaluate(runner); evaluate(runner); From 9e88483205c7dcb75644930d8a9cc98d2cd29f80 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 10 Aug 2026 20:11:37 -0700 Subject: [PATCH 25/29] fix: attribute an evaluation to the context it read the flag for evaluateWithHooks snapshotted the flag before hooks ran, but variationDetailInternal still re-read the evaluation context when recording events. An identify landing in between left the returned value from the prior context's flag attributed to the new context. Both are now read together and threaded through the evaluation so the series, the result and the events all describe one pair. Co-authored-by: Cursor --- .../launchdarkly/sdk/android/LDClient.java | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index bb193892..b2db387a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -622,23 +622,26 @@ public EvaluationDetail jsonValueVariationDetail(@NonNull String key, L } /** - * Runs an evaluation, and the hooks around it, against one read of the flag. + * Runs an evaluation, and the hooks around it, against one read of the flag and of the evaluation + * context. *

- * The read is done here rather than left to the evaluation so that the result a hook is told the - * evaluation is about to return is the result it does return: were the store read a second time to - * describe the evaluation to hooks, an update landing in between would leave the two describing - * different results. + * Both are read here rather than left to the evaluation so that the result a hook is told the + * evaluation is about to return is the result it does return, and so that the events for that + * result are attributed to the same context. Reading only the flag here and letting the evaluation + * re-read the context would leave an identify landing in between attributing the prior context's + * flag to the new context in events. */ private EvaluationDetail evaluateWithHooks(String method, String key, LDValue defaultValue, boolean checkType, boolean needsReason) { + LDContext context = clientContextImpl.getEvaluationContext(); Flag flag = contextDataManager.getNonDeletedFlag(key); // returns null for nonexistent *or* deleted flag return hookRunner.withEvaluation( method, key, - clientContextImpl.getEvaluationContext(), + context, defaultValue, flag, - () -> variationDetailInternal(key, defaultValue, checkType, needsReason, null, flag) + () -> variationDetailInternal(key, defaultValue, checkType, needsReason, null, flag, context) ); } @@ -646,14 +649,7 @@ private EvaluationDetail convertDetailType(EvaluationDetail deta return EvaluationDetail.fromValue(converter.toType(detail.getValue()), detail.getVariationIndex(), detail.getReason()); } - private EvaluationDetail variationDetailInternal(@NonNull String key, @NonNull LDValue defaultValue, boolean checkType, boolean needsReason, Set visited) { - // returns null for nonexistent *or* deleted flag - return variationDetailInternal(key, defaultValue, checkType, needsReason, visited, - contextDataManager.getNonDeletedFlag(key)); - } - - private EvaluationDetail variationDetailInternal(@NonNull String key, @NonNull LDValue defaultValue, boolean checkType, boolean needsReason, Set visited, @Nullable Flag flag) { - LDContext context = clientContextImpl.getEvaluationContext(); + private EvaluationDetail variationDetailInternal(@NonNull String key, @NonNull LDValue defaultValue, boolean checkType, boolean needsReason, Set visited, @Nullable Flag flag, @NonNull LDContext context) { EvaluationDetail result; if (flag == null) { @@ -681,7 +677,9 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ // value and reason (below) are unchanged. continue; } - variationDetailInternal(prereqKey, LDValue.ofNull(), false, false, visited); + // The prerequisite is evaluated as part of the same call, so it is attributed to the same context. + variationDetailInternal(prereqKey, LDValue.ofNull(), false, false, visited, + contextDataManager.getNonDeletedFlag(prereqKey), context); } } finally { visited.remove(key); From 02c19b7b3fcb69bf52838f86217c25f93170d519 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 11 Aug 2026 07:17:39 -0700 Subject: [PATCH 26/29] fix comment --- .../sdk/android/integrations/EvaluationExposureDeduper.java | 5 ++--- .../sdk/android/EvaluationExposureDeduperTest.java | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java index 710fef4b..26dd2e9a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -42,9 +42,8 @@ public class EvaluationExposureDeduper { private final long windowMillis; - // Holds one record per flag the application evaluates, in each environment it evaluates it in. - // Nothing is evicted, because that set is the flags the environment serves. Guarded by the - // instance lock, as is every access below. + // Last result reported for each flag, per environment. Entries stay until reset(). + // Accessed only from the synchronized methods on this instance. private final Map lastReported = new HashMap<>(); /** diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java index 92b71fd6..21942a74 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java @@ -132,8 +132,7 @@ public void tracksEveryFlagTheApplicationEvaluates() { assertTrue(deduper.shouldRecord(key("key-" + i), 1000)); } - // Nothing is dropped to make room, so the flag recorded first is suppressed just like the - // flag recorded last. What the deduper holds is the flag set, which the environment bounds. + // Records accumulate; the first flag is still suppressed after two thousand others have been recorded. assertFalse(deduper.shouldRecord(key("key-0"), 1000)); assertFalse(deduper.shouldRecord(key("key-1999"), 1000)); } From c8ff699d46136821ba92a19466a7c73bc9fcbc49 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 11 Aug 2026 08:23:12 -0700 Subject: [PATCH 27/29] add value --- .../launchdarkly/sdk/android/LDClient.java | 3 +- .../EvaluationExposureDeduper.java | 7 ++- .../integrations/EvaluationExposureKey.java | 48 +++++++++++++++---- .../EvaluationExposureDeduperTest.java | 46 ++++++++++++++---- 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index b2db387a..b4bb9e2c 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -734,7 +734,8 @@ private EvaluationExposureKey exposureKey(EvaluationSeriesContext seriesContext, int flagVersion = flag == null ? EventProcessor.NO_VERSION : flag.getVersionForEvents(); boolean inExperiment = flag != null && flag.getReason() != null && flag.getReason().isInExperiment(); - return new EvaluationExposureKey(clientContextImpl.getEnvironmentName(), flagKey, variation, + return new EvaluationExposureKey(clientContextImpl.getEnvironmentName(), flagKey, + flag == null ? LDValue.ofNull() : flag.getValue(), variation, flagVersion, inExperiment, seriesContext.context.getFullyQualifiedKey()); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java index 26dd2e9a..3e08f4a5 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.android.integrations; +import com.launchdarkly.sdk.LDValue; + import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -148,6 +150,7 @@ public int hashCode() { * as it is tracked, however often its result changes. */ private static final class LastReported { + private LDValue value; private int variation; private int flagVersion; private boolean inExperiment; @@ -159,6 +162,7 @@ private static final class LastReported { } void update(EvaluationExposureKey key, long atMillis) { + this.value = key.getValue(); this.variation = key.getVariation(); this.flagVersion = key.getFlagVersion(); this.inExperiment = key.isInExperiment(); @@ -167,7 +171,8 @@ void update(EvaluationExposureKey key, long atMillis) { } boolean isSameResultAs(EvaluationExposureKey key) { - return variation == key.getVariation() + return Objects.equals(value, key.getValue()) + && variation == key.getVariation() && flagVersion == key.getFlagVersion() && inExperiment == key.isInExperiment() && Objects.equals(fullyQualifiedContextKey, key.getFullyQualifiedContextKey()); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java index 2e3459f9..aad59be2 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java @@ -1,25 +1,27 @@ package com.launchdarkly.sdk.android.integrations; +import com.launchdarkly.sdk.LDValue; + import java.util.Objects; /** * Identifies the evaluation result a hook is about to be told about, so that an * {@link EvaluationExposureDeduper} can recognize a repeat of it. *

- * Two evaluations are the same exposure when every component here matches. The variation and version - * pair is the same identity LaunchDarkly uses to bucket evaluations in summary events, so two - * evaluations sharing that pair report identical data. Experiment status needs its own component - * because the version reported on events is the flag's own version, which only moves when the flag - * itself changes: a prerequisite flipping can move an evaluation into or out of an experiment while it - * lands on the same variation of the same flag version. The environment is a component because a hook - * configured on {@code LDConfig} is one instance shared by the clients for every environment in - * {@code secondaryMobileKeys}, and so is its deduper. + * Two evaluations are the same exposure when every component here matches. The value is included + * directly rather than inferred from the variation and version: those are the identity LaunchDarkly + * uses to bucket summary events, but neither by itself guarantees that the payload is unchanged. + * Experiment status needs its own component because a prerequisite flipping can move an evaluation + * into or out of an experiment while it lands on the same value, variation, and flag version. The + * environment is a component because a hook configured on {@code LDConfig} is one instance shared by + * the clients for every environment in {@code secondaryMobileKeys}, and so is its deduper. *

* Instances are immutable, and their hash code is computed once, the first time one is asked for. */ public final class EvaluationExposureKey { private final String environmentName; private final String flagKey; + private final LDValue value; private final int variation; private final int flagVersion; private final boolean inExperiment; @@ -32,6 +34,9 @@ public final class EvaluationExposureKey { private int hashCode; /** + * Creates a key with a JSON null flag value. Prefer the overload accepting {@code value} when + * the evaluation's flag payload is available. + * * @param environmentName the name of the environment the evaluation was made against * @param flagKey the flag key * @param variation the variation index of the result @@ -42,8 +47,25 @@ public final class EvaluationExposureKey { public EvaluationExposureKey(String environmentName, String flagKey, int variation, int flagVersion, boolean inExperiment, String fullyQualifiedContextKey) { + this(environmentName, flagKey, LDValue.ofNull(), variation, flagVersion, inExperiment, + fullyQualifiedContextKey); + } + + /** + * @param environmentName the name of the environment the evaluation was made against + * @param flagKey the flag key + * @param value the value in the flag payload, or JSON null if the flag was not found + * @param variation the variation index of the result + * @param flagVersion the flag version reported on events + * @param inExperiment whether the evaluation was part of an experiment rollout + * @param fullyQualifiedContextKey the fully qualified key of the evaluation context + */ + public EvaluationExposureKey(String environmentName, String flagKey, LDValue value, int variation, + int flagVersion, boolean inExperiment, + String fullyQualifiedContextKey) { this.environmentName = environmentName; this.flagKey = flagKey; + this.value = value; this.variation = variation; this.flagVersion = flagVersion; this.inExperiment = inExperiment; @@ -64,6 +86,13 @@ public String getFlagKey() { return flagKey; } + /** + * @return the value in the flag payload, or JSON null if the flag was not found + */ + public LDValue getValue() { + return value; + } + /** * @return the variation index of the result */ @@ -108,6 +137,7 @@ public boolean equals(Object other) { && inExperiment == o.inExperiment && Objects.equals(flagKey, o.flagKey) && Objects.equals(environmentName, o.environmentName) + && Objects.equals(value, o.value) && Objects.equals(fullyQualifiedContextKey, o.fullyQualifiedContextKey); } @@ -117,6 +147,7 @@ public int hashCode() { if (hash == 0) { hash = Objects.hashCode(environmentName); hash = 31 * hash + Objects.hashCode(flagKey); + hash = 31 * hash + Objects.hashCode(value); hash = 31 * hash + variation; hash = 31 * hash + flagVersion; hash = 31 * hash + (inExperiment ? 1 : 0); @@ -130,6 +161,7 @@ public int hashCode() { public String toString() { return "EvaluationExposureKey(environmentName=" + environmentName + ", flagKey=" + flagKey + + ", value=" + value + ", variation=" + variation + ", flagVersion=" + flagVersion + ", inExperiment=" + inExperiment diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java index 21942a74..444617ce 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java @@ -5,6 +5,7 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; +import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; @@ -17,14 +18,16 @@ public class EvaluationExposureDeduperTest { * that a test can talk about "the exposure of a" without spelling out the whole key. */ private static EvaluationExposureKey key(String flagKey) { - return new EvaluationExposureKey("default", flagKey, 1, 2, false, "user-key"); + return new EvaluationExposureKey( + "default", flagKey, LDValue.of("value"), 1, 2, false, "user-key"); } /** * The same flag as {@link #key(String)}, resolved to a different variation. */ private static EvaluationExposureKey otherResult(String flagKey) { - return new EvaluationExposureKey("default", flagKey, 3, 2, false, "user-key"); + return new EvaluationExposureKey( + "default", flagKey, LDValue.of("other-value"), 3, 2, false, "user-key"); } @Test @@ -38,19 +41,29 @@ public void recordsEverythingForNonPositiveWindow() { @Test public void exposureKeyDistinguishesEveryComponent() { - EvaluationExposureKey key = new EvaluationExposureKey("default", "flag", 1, 2, false, "user-key"); - EvaluationExposureKey same = new EvaluationExposureKey("default", "flag", 1, 2, false, "user-key"); + EvaluationExposureKey key = new EvaluationExposureKey( + "default", "flag", LDValue.of("value"), 1, 2, false, "user-key"); + EvaluationExposureKey same = new EvaluationExposureKey( + "default", "flag", LDValue.of("value"), 1, 2, false, "user-key"); assertEquals(key, same); assertEquals(key.hashCode(), same.hashCode()); - assertNotEquals(key, new EvaluationExposureKey("default", "other-flag", 1, 2, false, "user-key")); - assertNotEquals(key, new EvaluationExposureKey("default", "flag", 3, 2, false, "user-key")); - assertNotEquals(key, new EvaluationExposureKey("default", "flag", 1, 4, false, "user-key")); - assertNotEquals(key, new EvaluationExposureKey("default", "flag", 1, 2, false, "other-user-key")); + assertNotEquals(key, new EvaluationExposureKey( + "default", "flag", LDValue.of("other-value"), 1, 2, false, "user-key")); + assertNotEquals(key, new EvaluationExposureKey( + "default", "other-flag", LDValue.of("value"), 1, 2, false, "user-key")); + assertNotEquals(key, new EvaluationExposureKey( + "default", "flag", LDValue.of("value"), 3, 2, false, "user-key")); + assertNotEquals(key, new EvaluationExposureKey( + "default", "flag", LDValue.of("value"), 1, 4, false, "user-key")); + assertNotEquals(key, new EvaluationExposureKey( + "default", "flag", LDValue.of("value"), 1, 2, false, "other-user-key")); // Moving into an experiment on the same variation of the same flag version reports again. - assertNotEquals(key, new EvaluationExposureKey("default", "flag", 1, 2, true, "user-key")); + assertNotEquals(key, new EvaluationExposureKey( + "default", "flag", LDValue.of("value"), 1, 2, true, "user-key")); // A hook shared across environments observes the same result once per environment. - assertNotEquals(key, new EvaluationExposureKey("other-env", "flag", 1, 2, false, "user-key")); + assertNotEquals(key, new EvaluationExposureKey( + "other-env", "flag", LDValue.of("value"), 1, 2, false, "user-key")); } @Test @@ -92,6 +105,19 @@ public void reportsAFlagAgainAsSoonAsItsResultChanges() { assertFalse(deduper.shouldRecord(key("a"), 1040)); } + @Test + public void reportsAgainWhenOnlyTheFlagValueChanges() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); + EvaluationExposureKey first = new EvaluationExposureKey( + "default", "flag", LDValue.of("first"), 1, 2, false, "user-key"); + EvaluationExposureKey second = new EvaluationExposureKey( + "default", "flag", LDValue.of("second"), 1, 2, false, "user-key"); + + assertTrue(deduper.shouldRecord(first, 1000)); + assertTrue(deduper.shouldRecord(second, 1010)); + assertFalse(deduper.shouldRecord(second, 1020)); + } + @Test public void tracksTheSameFlagSeparatelyPerEnvironment() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); From 3cba0c460b6e568407b5abe8dd70d008640b7b31 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 11 Aug 2026 13:04:32 -0700 Subject: [PATCH 28/29] Feedback fixes --- .../launchdarkly/sdk/android/LDClient.java | 9 +- .../android/integrations/DedupingHook.java | 45 ++++++- .../EvaluationExposureDeduper.java | 26 ++-- .../integrations/EvaluationExposureKey.java | 48 ++++--- .../sdk/android/integrations/Hook.java | 4 +- .../android/integrations/HookDecorator.java | 119 ------------------ .../EvaluationExposureDeduperTest.java | 81 +++++++++--- .../sdk/android/HookRunnerTest.java | 2 +- .../integrations/DedupingHookTest.java | 21 ++-- 9 files changed, 156 insertions(+), 199 deletions(-) delete mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookDecorator.java diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index b4bb9e2c..0b7f4bfe 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -732,11 +732,12 @@ private EvaluationExposureKey exposureKey(EvaluationSeriesContext seriesContext, int variation = flag == null || flag.getVariation() == null ? EvaluationDetail.NO_VARIATION : flag.getVariation(); int flagVersion = flag == null ? EventProcessor.NO_VERSION : flag.getVersionForEvents(); - boolean inExperiment = flag != null && flag.getReason() != null && flag.getReason().isInExperiment(); + // The value the evaluation returns, which for a flag the SDK has no data for is the default + // value, as it is on the event the evaluation records. + LDValue value = flag == null ? seriesContext.defaultValue : flag.getValue(); - return new EvaluationExposureKey(clientContextImpl.getEnvironmentName(), flagKey, - flag == null ? LDValue.ofNull() : flag.getValue(), variation, - flagVersion, inExperiment, seriesContext.context.getFullyQualifiedKey()); + return new EvaluationExposureKey(clientContextImpl.getEnvironmentName(), flagKey, value, + variation, flagVersion, seriesContext.context.getFullyQualifiedKey()); } /** diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java index 87315f38..60adc19e 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java @@ -32,6 +32,10 @@ * {@link EvaluationExposureKey} describes. Pass your own {@link EvaluationExposureDeduper} subclass to * decide that differently. *

+ * An evaluation the SDK has no flag data for resolves to the default value, and is the same result as + * another that does. Evaluations made before the client has flags are of that kind, so the wrapped + * hook is told about one of them and then told about the flag again as soon as its data arrives. + *

* A suppressed evaluation reaches neither * {@link Hook#beforeEvaluation(EvaluationSeriesContext, Map)} nor * {@link Hook#afterEvaluation(EvaluationSeriesContext, Map, EvaluationDetail)}, because hooks pair @@ -51,7 +55,7 @@ * it stored in its own before stage. A decorator inside this one is unaffected, since a suppressed * evaluation never reaches it. */ -public final class DedupingHook extends HookDecorator { +public final class DedupingHook extends Hook { /** * Reads the clock a window is measured against. Exists so that tests can control it; the SDK has @@ -73,6 +77,7 @@ interface Clock { // Namespaced because it travels in series data that the wrapped hook may also write to. private static final String SUPPRESSED = "com.launchdarkly.sdk.android.DedupingHook.suppressed"; + private final Hook delegate; private final EvaluationExposureDeduper deduper; private final Clock clock; @@ -109,11 +114,20 @@ public DedupingHook(Hook delegate, EvaluationExposureDeduper deduper) { @VisibleForTesting DedupingHook(Hook delegate, EvaluationExposureDeduper deduper, Clock clock) { - super(delegate); + super(nameOf(delegate)); + this.delegate = Objects.requireNonNull(delegate, "a deduping hook must wrap a hook"); this.deduper = Objects.requireNonNull(deduper, "a deduping hook must have a deduper"); this.clock = clock; } + /** + * @return the wrapped hook's metadata, so that the SDK names the hook a stage belongs to + */ + @Override + public HookMetadata getMetadata() { + return delegate.getMetadata(); + } + /** * Forwards the evaluation unless the wrapped hook has just been told about the same result. *

@@ -131,7 +145,7 @@ public Map beforeEvaluation(EvaluationSeriesContext seriesContex if (key != null && !deduper.shouldRecord(key, clock.elapsedMillis())) { return suppressedSeriesData; } - return super.beforeEvaluation(seriesContext, seriesData); + return delegate.beforeEvaluation(seriesContext, seriesData); } /** @@ -148,7 +162,7 @@ public Map afterEvaluation(EvaluationSeriesContext seriesContext if (seriesData != null && seriesData.get(SUPPRESSED) == this) { return seriesData; } - return super.afterEvaluation(seriesContext, seriesData, evaluationDetail); + return delegate.afterEvaluation(seriesContext, seriesData, evaluationDetail); } /** @@ -166,6 +180,27 @@ public Map afterEvaluation(EvaluationSeriesContext seriesContext @Override public Map beforeIdentify(IdentifySeriesContext seriesContext, Map seriesData) { deduper.reset(); - return super.beforeIdentify(seriesContext, seriesData); + return delegate.beforeIdentify(seriesContext, seriesData); + } + + @Override + public Map afterIdentify(IdentifySeriesContext seriesContext, Map seriesData, + IdentifySeriesResult result) { + return delegate.afterIdentify(seriesContext, seriesData, result); + } + + @Override + public void afterTrack(TrackSeriesContext seriesContext) { + delegate.afterTrack(seriesContext); + } + + // Static because it runs in the super() call, before this instance exists. Tolerates a hook whose + // metadata throws, which the SDK reports rather than propagates. + private static String nameOf(Hook delegate) { + try { + return delegate == null ? null : delegate.getMetadata().getName(); + } catch (Exception e) { + return null; + } } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java index 3e08f4a5..9ba10305 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -1,7 +1,5 @@ package com.launchdarkly.sdk.android.integrations; -import com.launchdarkly.sdk.LDValue; - import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -150,11 +148,7 @@ public int hashCode() { * as it is tracked, however often its result changes. */ private static final class LastReported { - private LDValue value; - private int variation; - private int flagVersion; - private boolean inExperiment; - private String fullyQualifiedContextKey; + private EvaluationExposureKey key; private long atMillis; LastReported(EvaluationExposureKey key, long atMillis) { @@ -162,20 +156,18 @@ private static final class LastReported { } void update(EvaluationExposureKey key, long atMillis) { - this.value = key.getValue(); - this.variation = key.getVariation(); - this.flagVersion = key.getFlagVersion(); - this.inExperiment = key.isInExperiment(); - this.fullyQualifiedContextKey = key.getFullyQualifiedContextKey(); + this.key = key; this.atMillis = atMillis; } + /** + * Holding the key rather than a copy of the components that describe its result is what keeps + * this from having to be revisited whenever {@link EvaluationExposureKey} gains one. The + * environment and flag key it also compares are equal by the time this is asked, since a + * record is only ever found under the {@link TrackedFlag} they make up. + */ boolean isSameResultAs(EvaluationExposureKey key) { - return Objects.equals(value, key.getValue()) - && variation == key.getVariation() - && flagVersion == key.getFlagVersion() - && inExperiment == key.isInExperiment() - && Objects.equals(fullyQualifiedContextKey, key.getFullyQualifiedContextKey()); + return this.key.equals(key); } } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java index aad59be2..ff7a3536 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java @@ -11,10 +11,19 @@ * Two evaluations are the same exposure when every component here matches. The value is included * directly rather than inferred from the variation and version: those are the identity LaunchDarkly * uses to bucket summary events, but neither by itself guarantees that the payload is unchanged. - * Experiment status needs its own component because a prerequisite flipping can move an evaluation - * into or out of an experiment while it lands on the same value, variation, and flag version. The - * environment is a component because a hook configured on {@code LDConfig} is one instance shared by - * the clients for every environment in {@code secondaryMobileKeys}, and so is its deduper. + * The environment is a component because a hook configured on {@code LDConfig} is one instance + * shared by the clients for every environment in {@code secondaryMobileKeys}, and so is its + * deduper. + *

+ * The components describe the result the evaluation returns, which is how the SDK identifies an + * evaluation on analytics events too. An evaluation the SDK has no flag data for returns the default + * value, and so is described by that value with the placeholder variation and version the SDK + * reports for a flag it did not find, the same identity it summarizes such an evaluation under. + * Evaluations made before the client has flags are of that kind, as are evaluations of a flag that + * does not exist, so the data arriving changes the value, variation, and version, and the hook is + * told about the flag again rather than waiting out a window. The environment name is never unknown + * this way: it names a mobile key in the configuration, so it is fixed before the client it belongs + * to exists. *

* Instances are immutable, and their hash code is computed once, the first time one is asked for. */ @@ -24,7 +33,6 @@ public final class EvaluationExposureKey { private final LDValue value; private final int variation; private final int flagVersion; - private final boolean inExperiment; private final String fullyQualifiedContextKey; // Computed on demand, because the SDK's own deduper recognizes a repeat by the flag a key belongs @@ -34,41 +42,37 @@ public final class EvaluationExposureKey { private int hashCode; /** - * Creates a key with a JSON null flag value. Prefer the overload accepting {@code value} when - * the evaluation's flag payload is available. + * Creates a key with a JSON null value. Prefer the overload accepting {@code value}, since the + * variation and version do not by themselves distinguish one result from another. * * @param environmentName the name of the environment the evaluation was made against * @param flagKey the flag key * @param variation the variation index of the result * @param flagVersion the flag version reported on events - * @param inExperiment whether the evaluation was part of an experiment rollout * @param fullyQualifiedContextKey the fully qualified key of the evaluation context */ public EvaluationExposureKey(String environmentName, String flagKey, int variation, - int flagVersion, boolean inExperiment, - String fullyQualifiedContextKey) { - this(environmentName, flagKey, LDValue.ofNull(), variation, flagVersion, inExperiment, + int flagVersion, String fullyQualifiedContextKey) { + this(environmentName, flagKey, LDValue.ofNull(), variation, flagVersion, fullyQualifiedContextKey); } /** * @param environmentName the name of the environment the evaluation was made against * @param flagKey the flag key - * @param value the value in the flag payload, or JSON null if the flag was not found + * @param value the value the evaluation returns, which is the default value if the flag was not + * found * @param variation the variation index of the result * @param flagVersion the flag version reported on events - * @param inExperiment whether the evaluation was part of an experiment rollout * @param fullyQualifiedContextKey the fully qualified key of the evaluation context */ public EvaluationExposureKey(String environmentName, String flagKey, LDValue value, int variation, - int flagVersion, boolean inExperiment, - String fullyQualifiedContextKey) { + int flagVersion, String fullyQualifiedContextKey) { this.environmentName = environmentName; this.flagKey = flagKey; this.value = value; this.variation = variation; this.flagVersion = flagVersion; - this.inExperiment = inExperiment; this.fullyQualifiedContextKey = fullyQualifiedContextKey; } @@ -87,7 +91,7 @@ public String getFlagKey() { } /** - * @return the value in the flag payload, or JSON null if the flag was not found + * @return the value the evaluation returns, which is the default value if the flag was not found */ public LDValue getValue() { return value; @@ -107,13 +111,6 @@ public int getFlagVersion() { return flagVersion; } - /** - * @return whether the evaluation was part of an experiment rollout - */ - public boolean isInExperiment() { - return inExperiment; - } - /** * @return the fully qualified key of the evaluation context */ @@ -134,7 +131,6 @@ public boolean equals(Object other) { // The primitives reject most unequal keys without touching the strings. return variation == o.variation && flagVersion == o.flagVersion - && inExperiment == o.inExperiment && Objects.equals(flagKey, o.flagKey) && Objects.equals(environmentName, o.environmentName) && Objects.equals(value, o.value) @@ -150,7 +146,6 @@ public int hashCode() { hash = 31 * hash + Objects.hashCode(value); hash = 31 * hash + variation; hash = 31 * hash + flagVersion; - hash = 31 * hash + (inExperiment ? 1 : 0); hash = 31 * hash + Objects.hashCode(fullyQualifiedContextKey); hashCode = hash; } @@ -164,7 +159,6 @@ public String toString() { + ", value=" + value + ", variation=" + variation + ", flagVersion=" + flagVersion - + ", inExperiment=" + inExperiment + ", fullyQualifiedContextKey=" + fullyQualifiedContextKey + ")"; } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java index 4dda0057..841ab606 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java @@ -13,8 +13,8 @@ * stages in the order they were configured, and each hook's after stages in reverse order. (i.e. * myHook1.beforeEvaluation, myHook2.beforeEvaluation, myHook2.afterEvaluation, myHook1.afterEvaluation) *

- * To add behavior to a hook without changing it, such as the deduplication of repeated evaluations - * that {@link DedupingHook} performs, wrap it in a {@link HookDecorator} and register the wrapper. + * To deduplicate the repeated evaluations observed by one hook, wrap it in a + * {@link DedupingHook} and register the wrapper. */ public abstract class Hook { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookDecorator.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookDecorator.java deleted file mode 100644 index 4b29ee37..00000000 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookDecorator.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.launchdarkly.sdk.android.integrations; - -import com.launchdarkly.sdk.EvaluationDetail; -import com.launchdarkly.sdk.LDValue; - -import java.util.Map; -import java.util.Objects; - -/** - * A hook that wraps another hook, forwarding every stage to it. Extend this to add behavior to a hook - * without changing it, and register the wrapper in place of the hook it wraps. - *

- * Each stage forwards to the wrapped hook, so a subclass overrides only the stages it changes. - * {@link DedupingHook} is the decorator the SDK ships: it forwards an evaluation series only when the - * flag's result is one its hook has not just been told about. - * - *


- *     public final class FlagFilteringHook extends HookDecorator {
- *         private final Set<String> flagKeys;
- *
- *         public FlagFilteringHook(Hook delegate, Set<String> flagKeys) {
- *             super(delegate);
- *             this.flagKeys = flagKeys;
- *         }
- *
- *         @Override
- *         public Map<String, Object> beforeEvaluation(EvaluationSeriesContext seriesContext,
- *                                                     Map<String, Object> seriesData) {
- *             return flagKeys.contains(seriesContext.flagKey)
- *                     ? super.beforeEvaluation(seriesContext, seriesData)
- *                     : seriesData;
- *         }
- *     }
- * 
- *

- * Decorators stack, so a hook may be wrapped in as many as it needs, each wrapping the one inside it: - * - *


- *     Components.hooks()
- *         .addHook(new DedupingHook(new FlagFilteringHook(new ObservabilityHook(), myFlagKeys)))
- * 
- *

- * A decorator reports the wrapped hook's metadata as its own, so the SDK names the hook that a stage - * belongs to rather than the wrappers around it. - *

- * A decorator that suppresses a stage must suppress the whole evaluation series, because hooks pair - * their stages: an observability hook opens a span in - * {@link Hook#beforeEvaluation(EvaluationSeriesContext, Map)} and closes it in - * {@link Hook#afterEvaluation(EvaluationSeriesContext, Map, EvaluationDetail)}, so suppressing only - * the after stage leaves that span open. To carry the decision from one stage to the other, return - * series data the after stage recognizes, the way {@link DedupingHook} does. - *

- * A decorator that does that belongs outermost, because the series data it returns replaces what it - * was given: a decorator outside it does not get back what it stored in its own before stage. - */ -public abstract class HookDecorator extends Hook { - - private final Hook delegate; - - /** - * @param delegate the hook to forward each stage to - */ - protected HookDecorator(Hook delegate) { - super(nameOf(delegate)); - this.delegate = Objects.requireNonNull(delegate, "a decorator must wrap a hook"); - } - - /** - * @return the hook each stage is forwarded to - */ - protected final Hook getDelegate() { - return delegate; - } - - /** - * @return the wrapped hook's metadata, so that the SDK names the hook a stage belongs to - */ - @Override - public HookMetadata getMetadata() { - return delegate.getMetadata(); - } - - @Override - public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { - return delegate.beforeEvaluation(seriesContext, seriesData); - } - - @Override - public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, - EvaluationDetail evaluationDetail) { - return delegate.afterEvaluation(seriesContext, seriesData, evaluationDetail); - } - - @Override - public Map beforeIdentify(IdentifySeriesContext seriesContext, Map seriesData) { - return delegate.beforeIdentify(seriesContext, seriesData); - } - - @Override - public Map afterIdentify(IdentifySeriesContext seriesContext, Map seriesData, - IdentifySeriesResult result) { - return delegate.afterIdentify(seriesContext, seriesData, result); - } - - @Override - public void afterTrack(TrackSeriesContext seriesContext) { - delegate.afterTrack(seriesContext); - } - - // Static because it runs in the super() call, before this instance exists. Tolerates a hook whose - // metadata throws, which the SDK reports rather than propagates. - private static String nameOf(Hook delegate) { - try { - return delegate == null ? null : delegate.getMetadata().getName(); - } catch (Exception e) { - return null; - } - } -} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java index 444617ce..085b786b 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java @@ -5,9 +5,11 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; +import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; +import com.launchdarkly.sdk.android.subsystems.EventProcessor; import org.junit.Test; @@ -19,7 +21,7 @@ public class EvaluationExposureDeduperTest { */ private static EvaluationExposureKey key(String flagKey) { return new EvaluationExposureKey( - "default", flagKey, LDValue.of("value"), 1, 2, false, "user-key"); + "default", flagKey, LDValue.of("value"), 1, 2, "user-key"); } /** @@ -27,7 +29,17 @@ private static EvaluationExposureKey key(String flagKey) { */ private static EvaluationExposureKey otherResult(String flagKey) { return new EvaluationExposureKey( - "default", flagKey, LDValue.of("other-value"), 3, 2, false, "user-key"); + "default", flagKey, LDValue.of("other-value"), 3, 2, "user-key"); + } + + /** + * The key the SDK builds for an evaluation it has no flag data for: one made before the client + * has flags, or one of a flag that does not exist. Such an evaluation returns the default value, + * so that is the value describing it, under the placeholders the SDK reports on events. + */ + private static EvaluationExposureKey unknownFlag(String flagKey, LDValue defaultValue) { + return new EvaluationExposureKey("default", flagKey, defaultValue, + EvaluationDetail.NO_VARIATION, EventProcessor.NO_VERSION, "user-key"); } @Test @@ -42,28 +54,35 @@ public void recordsEverythingForNonPositiveWindow() { @Test public void exposureKeyDistinguishesEveryComponent() { EvaluationExposureKey key = new EvaluationExposureKey( - "default", "flag", LDValue.of("value"), 1, 2, false, "user-key"); + "default", "flag", LDValue.of("value"), 1, 2, "user-key"); EvaluationExposureKey same = new EvaluationExposureKey( - "default", "flag", LDValue.of("value"), 1, 2, false, "user-key"); + "default", "flag", LDValue.of("value"), 1, 2, "user-key"); assertEquals(key, same); assertEquals(key.hashCode(), same.hashCode()); assertNotEquals(key, new EvaluationExposureKey( - "default", "flag", LDValue.of("other-value"), 1, 2, false, "user-key")); - assertNotEquals(key, new EvaluationExposureKey( - "default", "other-flag", LDValue.of("value"), 1, 2, false, "user-key")); + "default", "flag", LDValue.of("other-value"), 1, 2, "user-key")); assertNotEquals(key, new EvaluationExposureKey( - "default", "flag", LDValue.of("value"), 3, 2, false, "user-key")); + "default", "other-flag", LDValue.of("value"), 1, 2, "user-key")); assertNotEquals(key, new EvaluationExposureKey( - "default", "flag", LDValue.of("value"), 1, 4, false, "user-key")); + "default", "flag", LDValue.of("value"), 3, 2, "user-key")); assertNotEquals(key, new EvaluationExposureKey( - "default", "flag", LDValue.of("value"), 1, 2, false, "other-user-key")); - // Moving into an experiment on the same variation of the same flag version reports again. + "default", "flag", LDValue.of("value"), 1, 4, "user-key")); assertNotEquals(key, new EvaluationExposureKey( - "default", "flag", LDValue.of("value"), 1, 2, true, "user-key")); + "default", "flag", LDValue.of("value"), 1, 2, "other-user-key")); // A hook shared across environments observes the same result once per environment. assertNotEquals(key, new EvaluationExposureKey( - "other-env", "flag", LDValue.of("value"), 1, 2, false, "user-key")); + "other-env", "flag", LDValue.of("value"), 1, 2, "user-key")); + } + + @Test + public void exposureKeysWithNoFlagDataAreEqualWhenTheDefaultIs() { + EvaluationExposureKey key = unknownFlag("flag", LDValue.of(false)); + EvaluationExposureKey same = unknownFlag("flag", LDValue.of(false)); + assertEquals(key, same); + assertEquals(key.hashCode(), same.hashCode()); + + assertNotEquals(key, unknownFlag("flag", LDValue.of(true))); } @Test @@ -109,20 +128,48 @@ public void reportsAFlagAgainAsSoonAsItsResultChanges() { public void reportsAgainWhenOnlyTheFlagValueChanges() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); EvaluationExposureKey first = new EvaluationExposureKey( - "default", "flag", LDValue.of("first"), 1, 2, false, "user-key"); + "default", "flag", LDValue.of("first"), 1, 2, "user-key"); EvaluationExposureKey second = new EvaluationExposureKey( - "default", "flag", LDValue.of("second"), 1, 2, false, "user-key"); + "default", "flag", LDValue.of("second"), 1, 2, "user-key"); assertTrue(deduper.shouldRecord(first, 1000)); assertTrue(deduper.shouldRecord(second, 1010)); assertFalse(deduper.shouldRecord(second, 1020)); } + @Test + public void treatsEvaluationsWithNoFlagDataResolvingToTheSameDefaultAsOneExposure() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); + + // Evaluations the SDK has no flag data for return the default value, and repeats of that result + // are a repeat like any other. + assertTrue(deduper.shouldRecord(unknownFlag("a", LDValue.of(false)), 1000)); + assertFalse(deduper.shouldRecord(unknownFlag("a", LDValue.of(false)), 1010)); + + // A different default is a different result, because it is a different value returned to the + // application. + assertTrue(deduper.shouldRecord(unknownFlag("a", LDValue.of(true)), 1020)); + assertFalse(deduper.shouldRecord(unknownFlag("a", LDValue.of(true)), 1030)); + } + + @Test + public void reportsAgainWhenTheFlagBecomesKnown() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); + assertTrue(deduper.shouldRecord(unknownFlag("a", LDValue.of("value")), 1000)); + + // The data arriving is a change of result even when the flag resolves to the value the default + // had already produced, because the variation and version are no longer the placeholders the + // SDK uses for a flag it did not find. So is the flag going away again. + assertTrue(deduper.shouldRecord(key("a"), 1010)); + assertFalse(deduper.shouldRecord(key("a"), 1020)); + assertTrue(deduper.shouldRecord(unknownFlag("a", LDValue.of("value")), 1030)); + } + @Test public void tracksTheSameFlagSeparatelyPerEnvironment() { EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); - EvaluationExposureKey primary = new EvaluationExposureKey("default", "flag", 1, 2, false, "user-key"); - EvaluationExposureKey secondary = new EvaluationExposureKey("other", "flag", 3, 4, false, "user-key"); + EvaluationExposureKey primary = new EvaluationExposureKey("default", "flag", 1, 2, "user-key"); + EvaluationExposureKey secondary = new EvaluationExposureKey("other", "flag", 3, 4, "user-key"); // A hook set on the configuration is shared by the clients for every environment, so its // deduper sees both. Neither environment may look to the other like its result changing. diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java index 6abc83f8..efcca099 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java @@ -32,7 +32,7 @@ public class HookRunnerTest extends EasyMockSupport { // Every evaluation in these tests is the same exposure, so the runner's supplier returns this. private static final EvaluationExposureKey EXPOSURE_KEY = - new EvaluationExposureKey("default", "test-flag", 1, 2, false, "user-123"); + new EvaluationExposureKey("default", "test-flag", 1, 2, "user-123"); private HookRunner hookRunner; private Hook testHook; diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java index f4466e36..dfebf5e1 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java @@ -25,9 +25,9 @@ */ public class DedupingHookTest { private static final EvaluationExposureKey EXPOSURE_KEY = - new EvaluationExposureKey("default", "test-flag", 1, 2, false, "user-123"); + new EvaluationExposureKey("default", "test-flag", 1, 2, "user-123"); private static final EvaluationExposureKey OTHER_RESULT = - new EvaluationExposureKey("default", "test-flag", 2, 2, false, "user-123"); + new EvaluationExposureKey("default", "test-flag", 2, 2, "user-123"); @Rule public LogCaptureRule logging = new LogCaptureRule(); @@ -87,26 +87,33 @@ public void afterTrack(TrackSeriesContext seriesContext) { } } - /** A decorator with its own behavior, to check that decorators compose. */ - private static class CountingDecorator extends HookDecorator { + /** A wrapper with its own behavior, to check that hook wrappers compose. */ + private static class CountingDecorator extends Hook { + private final Hook delegate; int evaluationsForwarded = 0; int resultsForwarded = 0; CountingDecorator(Hook delegate) { - super(delegate); + super(delegate.getMetadata().getName()); + this.delegate = delegate; + } + + @Override + public HookMetadata getMetadata() { + return delegate.getMetadata(); } @Override public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { evaluationsForwarded++; - return super.beforeEvaluation(seriesContext, seriesData); + return delegate.beforeEvaluation(seriesContext, seriesData); } @Override public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, EvaluationDetail evaluationDetail) { resultsForwarded++; - return super.afterEvaluation(seriesContext, seriesData, evaluationDetail); + return delegate.afterEvaluation(seriesContext, seriesData, evaluationDetail); } } From 6e29cefec1742a6eba48730418ca3301f8f1dcb5 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 11 Aug 2026 14:13:19 -0700 Subject: [PATCH 29/29] fixes --- .../sdk/android/LDClientHooksTest.java | 42 +++++++++++++++++++ .../launchdarkly/sdk/android/LDClient.java | 10 +++-- .../integrations/EvaluationExposureKey.java | 8 ++-- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java index 9d8b3570..21b1a2e1 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java @@ -1,6 +1,8 @@ package com.launchdarkly.sdk.android; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import android.app.Application; @@ -16,6 +18,8 @@ import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext; import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; import com.launchdarkly.sdk.android.integrations.TrackSeriesContext; +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.DataSource; import org.junit.Before; import org.junit.Rule; @@ -235,6 +239,27 @@ public void evaluationsOfDifferentFlagsReachHooksSeparately() throws Exception { } } + @Test + public void evaluationsOfAFlagWithNoValueAreTheExposuresOfTheDefaultsTheyReturn() throws Exception { + // A flag that is off without an off variation arrives carrying no value, so every evaluation of + // it returns the default the caller passed. + EnvironmentData data = new DataSetBuilder() + .add(new FlagBuilder("test-flag").version(1).build()) + .build(); + try (LDClient ldClient = makeClientWithData(data, new DedupingHook(testHook, 60_000))) { + assertTrue(ldClient.boolVariation("test-flag", true)); + assertFalse(ldClient.boolVariation("test-flag", false)); + + // The two evaluations returned different values, so they are two exposures. Describing both + // by the value the flag holds would make the second look like a repeat of the first. + assertEquals(2, testHook.afterEvaluationCalls.size()); + + // Repeating one of them is still a repeat. + assertFalse(ldClient.boolVariation("test-flag", false)); + assertEquals(2, testHook.afterEvaluationCalls.size()); + } + } + @Test public void hooksWithDifferentWindowsSuppressIndependently() throws Exception { MockHook deduping = new MockHook(); @@ -278,6 +303,23 @@ public void environmentsSharingAHookDoNotSuppressEachOther() throws Exception { } } + // A client whose flags are the given data, for the tests that need an evaluation to resolve to + // something. The other tests here are offline, where every flag is unknown. + private LDClient makeClientWithData(EnvironmentData data, Hook hook) { + ComponentConfigurer dataSourceConfig = clientContext -> + MockComponents.successfulDataSource(clientContext, data, + ConnectionInformation.ConnectionMode.POLLING, null, null); + LDConfig config = new LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Disabled) + .mobileKey(mobileKey) + .dataSource(dataSourceConfig) + .events(Components.noEvents()) + .hooks(Components.hooks().setHooks(List.of(hook))) + .logAdapter(logging.logAdapter) + .persistentDataStore(new InMemoryPersistentDataStore()) + .build(); + return LDClient.init(application, config, ldContext, 5); + } + private LDConfig makeOfflineConfig() { return makeOfflineConfig(null); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index 0b7f4bfe..2338b821 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -732,9 +732,13 @@ private EvaluationExposureKey exposureKey(EvaluationSeriesContext seriesContext, int variation = flag == null || flag.getVariation() == null ? EvaluationDetail.NO_VARIATION : flag.getVariation(); int flagVersion = flag == null ? EventProcessor.NO_VERSION : flag.getVersionForEvents(); - // The value the evaluation returns, which for a flag the SDK has no data for is the default - // value, as it is on the event the evaluation records. - LDValue value = flag == null ? seriesContext.defaultValue : flag.getValue(); + // The value the evaluation returns, which is the default value when there is no value to + // return: a flag the SDK has no data for, and a flag whose data carries no value, both fall + // back to it, as they do on the event the evaluation records. A value the calling method + // rejects as the wrong type also falls back to the default, but is not recognized here, + // because the type that method wanted is not part of the series context. + LDValue flagValue = flag == null ? LDValue.ofNull() : flag.getValue(); + LDValue value = flagValue.isNull() ? seriesContext.defaultValue : flagValue; return new EvaluationExposureKey(clientContextImpl.getEnvironmentName(), flagKey, value, variation, flagVersion, seriesContext.context.getFullyQualifiedKey()); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java index ff7a3536..cb85b411 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java @@ -21,9 +21,11 @@ * reports for a flag it did not find, the same identity it summarizes such an evaluation under. * Evaluations made before the client has flags are of that kind, as are evaluations of a flag that * does not exist, so the data arriving changes the value, variation, and version, and the hook is - * told about the flag again rather than waiting out a window. The environment name is never unknown - * this way: it names a mobile key in the configuration, so it is fixed before the client it belongs - * to exists. + * told about the flag again rather than waiting out a window. A flag whose data carries no value, + * which is what a flag that is off without an off variation has, likewise returns the default value + * and is described by it, under the flag's own variation and version. The environment name is never + * unknown this way: it names a mobile key in the configuration, so it is fixed before the client it + * belongs to exists. *

* Instances are immutable, and their hash code is computed once, the first time one is asked for. */