feat: add per-hook evaluation exposure deduplication - #380
feat: add per-hook evaluation exposure deduplication#380abelonogov-ld wants to merge 14 commits into
Conversation
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
"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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
|
Some SDKs used to have functionality like this and it was removed. Not all data is available in the client as to which events matter when. Like an experiment iteration. So we don't dedupe events. We can offer ways and guidance about how to avoid these situations. And you are welcome to de-dupe obersavability data. |
@kinyoklion Sure, I am not going to dedup events at all, it should only dedup evaluation hook call. I will removes events from it. I feel this functionality where triggered by a customer story |
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 <cursoragent@cursor.com>
I'm not sure I agree with this. I think we should put this dedupe logic in as narrow a spot and as close to the consumer as possible since it does result in loss of information. If you want one hook to get all evals and another to get deduped, configuring it at the top level doesn't work. Can you make a hook decorator that does the deduping and you just wrap your hook in deduping decoration if you want it? |
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a6600c1. Configure here.
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 <cursoragent@cursor.com>
| */ | ||
| public Hook evaluationExposureDeduper() { | ||
| return evaluationExposureDeduper(new EvaluationExposureDeduper()); | ||
| } |
There was a problem hiding this comment.
I still prefer decoration with static helpers to increasing the amount of functionality in the Hook abstract class.
| * @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, |
There was a problem hiding this comment.
Making a string for this isn't efficient is it? Can't this just a be a class/struct depending on the language this will be done in?

Summary
Apps that evaluate a flag on every render or inside a loop invoke their hooks for each call, even when the evaluation resolves to the same result every time. This lets a hook ask for a dedupe window so those redundant evaluation series are collapsed.
Only hooks are affected. Analytics events are untouched — feature, debug, and summary events are still recorded for every evaluation, so the evaluation counts LaunchDarkly reports for your flags do not change.
Deduplication is opt-in per hook, and there is no client-wide setting. A hook observes every evaluation until it carries a deduper, so an audit hook can see everything while an observability hook on the same client keeps a long window:
Behavior when a hook carries a window:
beforeEvaluationnorafterEvaluation, so a hook that pairs its stages never sees an unmatchedbefore.identifyclears every hook's deduper, even when the context is unchanged, soidentifystays a reliable way for an app to mark a new phase of a session.This started as a port of
flagExposureDedupeWindowMillisfrom the Web Observability SDK, moved down to the flag SDK level. Companion PR for the iOS SDK: launchdarkly/ios-client-sdk#516What changed since the first revision
Reviewers who read an earlier description will find three design decisions reversed:
LDConfigoptions are gone entirely, and the default is now no deduplication. They survived the previous revision as the fallback for hooks that carried no policy, which meant registering any hook opted it into suppression decided elsewhere in the config. Deduplication is a property of what a hook does with an evaluation, so the hook is now the only place that decides.EvaluationExposureDeduper.disabled()is therefore equivalent to carrying no deduper. It stays because passing it states the intent explicitly, and becauseHookRunnerrecognizes it by identity to skip building exposure keys.Notes for reviewers
beforeEvaluationand end it inafterEvaluation, parking it in a bounded map keyed by an evaluation id. Suppressing only the after stage would leave those spans in the map until eviction force-ended them, producing spans with nonsense durations and nofeature_flagevent — the same span count as no deduplication, but mostly junk. SoHookRunnerpicks the hooks for an evaluation up front, reading the stored flag to identify the exposure. The stored flag identifies the same exposure the result would, since the result is derived from it.LDClient.exposureKeypre-reads the flag andHookRunner.ExposureKeySupplierexists as the seam. Dropping either would mean either splitting a hook's stages or dropping the version component, and without variation and version a mid-window flag change would go unobserved, since nothing resets the cache exceptidentify.versionForEventsprefersflagVersion, which only moves when the flag itself changes, so 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. Without this component, that transition would be suppressed.HookRunnerTestpins it down; give each hook its own instance unless you want that.EvaluationExposureDeduperlives in theintegrationspackage and is public, so it can be constructed with different parameters, opted out of withdisabled(), or replaced by a subclass. It owns its ownDEFAULT_MAX_SIZEand no longer depends onLDConfig. The exposure key it is handed stays internal, inEvaluationExposureKey.Hookalso covers plugin-provided hooks, which are returned fromPlugin.getHooks()and never pass throughHooksConfigurationBuilder.addHook.variationDetailcan be called from any thread.shouldRecordcombines the check and the mark in one atomic step so two concurrent evaluations of the same flag cannot both be told to record.LinkedHashMapand drops expired entries before falling back to dropping the oldest, in a batch, so a workload with more live keys than the cap doesn't pay for a scan on every exposure. It returns early once reclaiming expired keys brings the map back within the cap, so live keys are never discarded needlessly.flagExposureDedupeWindowMillis, butflagcarries no information inside a flag SDK, and the local vocabulary is already "evaluation" (recordEvaluationEvent,EvaluationDetail, evaluation events)....Millis(pollIntervalMillis,connectTimeoutMillis,flushIntervalMillis), while every duration on iOS'sLDConfigis aTimeIntervalin seconds.local.properties, and registers two hooks with different windows so the display shows them suppressing independently.Known limitation
The key has no notion of where a flag was read, so two evaluations from different code paths that land on the same variation collapse into one. For a hook counting exposures that is correct, since the exposure belongs to the user and the variation. For a hook building traces it means a span can be missing a flag it depended on, and because it depends on the window, the gap is timing-sensitive. A hook that needs per-operation fidelity can subclass the deduper and fold the active span id into the key, which reduces suppression to repeats within one span, where the first read has already annotated that span. Whether the SDK should offer that scope as a first-class concept is left for a follow-up.
Test plan
EvaluationExposureDeduperTest— 12 unit tests covering the disabled instance, suppression, window expiry, independent keys, reset, eviction, re-recording, keeping live keys when reclaiming expired ones suffices, key construction, and concurrent accessHookRunnerTest— 28 unit tests covering per-hook dedupers, a hook without one observing everything, independent suppression between hooks, two hooks sharing one deduper, reset, exposure key lookup and the fast path that skips it, and hooks added after initializationLDClientHooksTest— 8 instrumented tests covering the end-to-end path: deduplication being off unless a hook asks for it, suppression within a hook's window, re-reporting afteridentify, different flags tracked separately, and three hooks with different policies on one client suppressing independentlyLDClientEventTest— instrumented test asserting evaluation events are still recorded for evaluations that were deduplicated for hooks./gradlew :launchdarkly-android-client-sdk:testDebugUnitTest: 713 tests, 0 failures (StreamingDataSourceTest.startSendsRequestWithoutReasonsWhenDisabledis flaky and unrelated; it passes on re-run)./gradlew :launchdarkly-android-client-sdk:javadoc: no unresolved referencesNote
Medium Risk
Touches core evaluation and hook paths in
HookRunnerandLDClient(including pre-evaluation exposure keys and identify resets); behavior is well covered by tests but incorrect dedupe could affect observability hooks in production.Overview
Adds opt-in, per-hook deduplication so repeated flag evaluations that resolve to the same exposure can skip the hook’s full
beforeEvaluation/afterEvaluationseries within a configurable time window—without changing LaunchDarkly analytics event counts.Hooks opt in via fluent
Hook#evaluationExposureDeduper(...)(default window/cap, custom millis + max keys, customEvaluationExposureDeduper, ordisabled()).HookRunnerdecides which hooks run before the evaluation series opens, usingEvaluationExposureKey(environment, flag, variation, event version, experiment status, context).LDClient#identifyclears all hook dedupe caches, including when the context is unchanged.The example app loads mobile key and staging endpoints from
local.properties, registers two hooks with different windows, and surfaces live dedupe stats.HooksConfigurationBuildergainsaddHook.Reviewed by Cursor Bugbot for commit f46f1c9. Bugbot is set up for automated code reviews on this repo. Configure here.