feat: add per-hook evaluation exposure deduplication - #380
feat: add per-hook evaluation exposure deduplication#380abelonogov-ld wants to merge 30 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>
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>
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 <cursoragent@cursor.com>
…t 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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
Follows the deduper no longer taking one. Co-authored-by: Cursor <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
…figuring 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 <cursoragent@cursor.com>
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.
… 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.
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.
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 <cursoragent@cursor.com>
…sult 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 <cursoragent@cursor.com>
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 <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 3cba0c4. Configure here.

Summary
Opt-in deduplication of evaluation exposures for hooks. A hook is told about every evaluation until you wrap it, so nothing changes for existing hooks:
New public API, all in
com.launchdarkly.sdk.android.integrations:HookDecorator— an abstract hook that forwards every stage to the hook it wraps, and reports that hook's metadata. Decorators stack in either order.DedupingHook— a decorator that suppresses an evaluation whose result the wrapped hook has just been told about. Wrap with a window, or with a deduper of your own.EvaluationExposureDeduper— the policy. The window is its only setting. Subclass it to decide differently;shouldRecordandresetare the only methodsDedupingHookcalls.EvaluationExposureKey— what identifies an evaluation result: environment name, flag key, variation, flag version, experiment status, and fully qualified context key.EvaluationSeriesContext.getEvaluationExposureKey()— resolves that identity on demand, along with theEvaluationExposureKeySupplierthe SDK hands the series context to resolve it with. An evaluation costs a flag lookup only when a hook asks, and only once however many hooks ask.The policy keeps one record per flag per environment, holding the result that flag last reported. The wrapped hook hears about the flag again as soon as the result changes, and once per window while it stays the same. Tracking the last result rather than every result seen means a flag flipping back and forth cannot hide its flips, and it bounds the records to the flags the environment serves.
The decision is taken in
beforeEvaluation, before the series opens, so a suppressed evaluation reaches neither stage of the wrapped hook. Hooks pair their stages: the observability plugin starts a span in the before stage and ends it in the after one, so suppressing only the after stage would leave that span in its map to be evicted later and exported with a meaningless duration and nofeature_flagevent.LDClient.identifyclears what the wrapped hook has been told about, so the first evaluation of each flag afterwards always reaches it. Analytics events are untouched: feature, debug, and summary events are still recorded for every evaluation, so the evaluation counts LaunchDarkly reports for a flag do not change.Describe alternatives you've considered
Hookitself. It was the first shape this took. It puts a policy on the interface that every hook implementer has to think about, and it cannot be composed, so a hook that wanted deduplication plus anything else had nowhere to put the second behavior. The decorator gives both without touchingHook.afterEvaluation. This is what the browser and React Native observability plugins do, and it is much less machinery: no exposure key before the evaluation, no supplier, no suppression marker in the series data. It is wrong here, because the mobile observability hook pairs its stages, as above.SystemClock.elapsedRealtime(), which no correction reaches and which, unlikeSystem.nanoTime(), keeps counting while the device sleeps.Additional context
LDConfigis one instance shared by the clients for every environment insecondaryMobileKeys, and so is its deduper. The environment is therefore part of both the exposure key and the per-flag record; sharing a record across environments would make each look like the other having changed its result, and neither would ever be suppressed.Note
Overview
Adds opt-in per-hook evaluation exposure deduplication. Wrap a hook in
DedupingHookto suppress repeated same-result evaluations within a configurable window (default 10 minutes); unwrapped hooks still see every evaluation.New public API in
integrations:DedupingHook,EvaluationExposureDeduper,EvaluationExposureKey, and on-demandEvaluationSeriesContext.getEvaluationExposureKey(). Suppression happens inbeforeEvaluationso both hook stages are skipped together.identifyresets the cache. Analytics events are unchanged.LDClientnow reads the flag and context once before hooks run, so exposure keys describe the result that evaluation actually returns. Also addsHooksConfigurationBuilder.addHook()and updates the example app to demo dual-window dedupe pluslocal.properties-based mobile key / staging config.Reviewed by Cursor Bugbot for commit 6e29cef. Bugbot is set up for automated code reviews on this repo. Configure here.