Skip to content

feat: add per-hook evaluation exposure deduplication - #380

Open
abelonogov-ld wants to merge 30 commits into
mainfrom
andrey/flag-exposure-dedupe
Open

feat: add per-hook evaluation exposure deduplication#380
abelonogov-ld wants to merge 30 commits into
mainfrom
andrey/flag-exposure-dedupe

Conversation

@abelonogov-ld

@abelonogov-ld abelonogov-ld commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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:

Components.hooks()
    .addHook(new MetricsHook())                                    // every evaluation
    .addHook(new DedupingHook(new ObservabilityHook()))            // 10 minute window
    .addHook(new DedupingHook(new TelemetryHook(), 60_000))
    .addHook(new DedupingHook(new ExperimentHook(), myCustomDeduper))

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; shouldRecord and reset are the only methods DedupingHook calls.
  • 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 the EvaluationExposureKeySupplier the 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 no feature_flag event.

LDClient.identify clears 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

  • A deduper configured on Hook itself. 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 touching Hook.
  • Filtering only 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.
  • Deduplicating on every distinct exposure seen, rather than one record per flag. A flag that flips between two results would report neither flip after the first, and the set of remembered exposures grows with every result a flag has ever had.
  • Wall clock time for the window. 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, which for a large correction is hours of dropped exposures. Windows are measured against SystemClock.elapsedRealtime(), which no correction reaches and which, unlike System.nanoTime(), keeps counting while the device sleeps.

Additional context

  • Additive and off by default. An unwrapped hook behaves exactly as it does today.
  • A hook set on LDConfig is one instance shared by the clients for every environment in secondaryMobileKeys, 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.
  • Experiment status is a component of the key in its own right, because the version reported on events is the flag's own version: 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.
  • Mirrored on iOS in launchdarkly/ios-client-sdk, with docs in sdk-meta and ld-docs-private.

Note

Overview
Adds opt-in per-hook evaluation exposure deduplication. Wrap a hook in DedupingHook to 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-demand EvaluationSeriesContext.getEvaluationExposureKey(). Suppression happens in beforeEvaluation so both hook stages are skipped together. identify resets the cache. Analytics events are unchanged.

LDClient now reads the flag and context once before hooks run, so exposure keys describe the result that evaluation actually returns. Also adds HooksConfigurationBuilder.addHook() and updates the example app to demo dual-window dedupe plus local.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.

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>
@abelonogov-ld
abelonogov-ld requested a review from a team as a code owner August 4, 2026 23:37
abelonogov-ld and others added 2 commits August 4, 2026 17:34
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>
@abelonogov-ld abelonogov-ld changed the title feat: add configurable flag exposure deduplication feat: add configurable evaluation exposure deduplication Aug 5, 2026
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>
@kinyoklion

Copy link
Copy Markdown
Member

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.

@abelonogov-ld

abelonogov-ld commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

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

@abelonogov-ld
abelonogov-ld marked this pull request as draft August 5, 2026 03:45
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>
@tanderson-ld

tanderson-ld commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

it should only dedup evaluation hook call

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?

abelonogov-ld and others added 4 commits August 5, 2026 22:24
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>
@abelonogov-ld abelonogov-ld changed the title feat: add configurable evaluation exposure deduplication feat: add per-hook evaluation exposure deduplication Aug 6, 2026
abelonogov-ld and others added 2 commits August 6, 2026 16:59
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>
@abelonogov-ld
abelonogov-ld marked this pull request as ready for review August 7, 2026 00:01
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>
abelonogov-ld and others added 9 commits August 7, 2026 15:40
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.
abelonogov-ld and others added 2 commits August 10, 2026 17:32
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>
abelonogov-ld and others added 2 commits August 10, 2026 20:11
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants