feat: add configurable evaluation exposure deduplication - #516
Open
abelonogov-ld wants to merge 22 commits into
Open
feat: add configurable evaluation exposure deduplication#516abelonogov-ld wants to merge 22 commits into
abelonogov-ld wants to merge 22 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 config 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. Co-authored-by: Cursor <cursoragent@cursor.com>
flagExposureDedupeWindowMillis was an Int in milliseconds. That matches the Android SDK's convention, but not this one: every other duration on LDConfig is a TimeInterval in seconds, including connectionTimeout, eventFlushInterval, flagPollingInterval, and diagnosticRecordingInterval. Renames the option to flagExposureDedupeWindow and types it as a TimeInterval so it reads like its neighbors, and threads seconds through ExposureDeduper instead of converting units at the boundary. Sub-second windows are now expressible, which a new spec case covers. Co-authored-by: Cursor <cursoragent@cursor.com>
The guard that returns early once expired-key cleanup brings the map back within maxSize was untested. Bugbot found the Android port was missing that guard, so cover the path here to keep the two suites in parity and to catch the same regression if it is ever introduced. Uses a maxSize of 8 because the batch term is maxSize / 4, which integer division makes zero for the smaller caps the other eviction tests use. 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: recordFlagEvaluationEvents, EvaluationDetail, evaluation events. Renames the public options to evaluationExposureDedupeWindow and evaluationExposureDedupeMaxSize, ExposureDeduper to EvaluationExposureDeduper along with its file and spec, and the EventReporting hook to resetEvaluationExposureDedupeCache. Mocks regenerated with sourcery. 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>
Singling out the oldest keys meant sorting the whole cache, because Dictionary is unordered. Sorting to pick a batch is more machinery than this path deserves: it only runs when more keys are live at once than maxSize allows, which means the configured cap is already too small for the workload. Reclaim expired keys as before, and if that is not enough, start over instead of ranking what is left. Refilling takes another maxSize exposures, so the cost stays amortized, and dropped keys are suppressed again as soon as they are re-recorded. The key being recorded when the reset fires is re-inserted, since its window opened a moment ago and dropping it would report the very next evaluation of that same result again. Android needs no equivalent change: LinkedHashMap already iterates in record order, so it drops the oldest keys without sorting. 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. Co-authored-by: Cursor <cursoragent@cursor.com>
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. The deduper is now reachable from arbitrary threads, so it synchronizes itself rather than relying on the event queue. Co-authored-by: Cursor <cursoragent@cursor.com>
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 return nil 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 becomes public: implementations can be built with different parameters, opted out of with .disabled, or replaced by a subclass. Swift hooks are protocol witnesses rather than instances the SDK can configure, so the deduper is a protocol requirement defaulting to nil rather than the fluent setter the Android SDK offers. Co-authored-by: Cursor <cursoragent@cursor.com>
Match the Android SDK: remove the LDConfig window and max-size options so deduplication is no longer a client-wide default that every hook inherits. A hook observes every evaluation until it returns its own evaluationExposureDeduper; nil and .disabled mean the same thing. Fold the parallel hooks and dedupers arrays into RegisteredHook so the pair cannot drift apart, and move the cache cap onto EvaluationExposureDeduper.defaultMaxSize. Co-authored-by: Cursor <cursoragent@cursor.com>
This was referenced Aug 6, 2026
Building a deduper required picking both a window and a cap, with no guidance on what a reasonable window is. Both parameters now default, so a hook that just wants the SDK's policy can write EvaluationExposureDeduper(). 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>
abelonogov-ld
marked this pull request as ready for review
August 7, 2026 16:49
Building the exposure key by joining its components meant every evaluation allocated a string proportional to the flag key, context key and environment name, and forced nil variations and versions into sentinel empty strings. EvaluationExposureKey holds the components instead, and Swift synthesizes its hashing from them. 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>
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 reclaim and start-over pass is gone. Co-authored-by: Cursor <cursoragent@cursor.com>
…laring it A hook declared a deduper as a protocol property that the client read once at initialization, which left the wiring invisible at the call site and put a requirement on every Hook that most conformances did not want. Deduplication is now a decorator: DedupingHook wraps the hook it dedupes for, and HookDecorator is the base any decorator subclasses, 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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ 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 9ee867b. Configure here.
Keeps the resolver in step with Android, where the supplier is public and so its shape is worth settling before release. The key is now also built from the context the evaluation was for rather than the client's current one, which is where it came from anyway.
… move Windows were measured against Date(). 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. CLOCK_MONOTONIC_RAW counts from an arbitrary point, so no correction reaches it, and unlike mach_absolute_time and everything built on it it advances while the device sleeps, so a window is an interval of real time rather than of awake time.
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 dictionary on the suppression path, which is the path the feature exists to keep cheap. Co-authored-by: Cursor <cursoragent@cursor.com>
The key was resolved on every read, so two deduping hooks in one evaluation could be told about different results if the flag store changed between them, and neither had to match what the evaluation returned. Android already resolves once and hands every hook the same key; this matches it. Co-authored-by: Cursor <cursoragent@cursor.com>
…sult The exposure key resolver 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 variation path now reads the flag once and hands it to the hooks and to the evaluation, which also retires the resolver protocol, the weak client reference, and the memoization they needed. Co-authored-by: Cursor <cursoragent@cursor.com>
Reading the flag once meant handing the series context a key built for every evaluation, which an application whose hooks are all undeduped never reads. The context now holds that one read of the flag and builds the key on the ask. Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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:
HookDecorator— an open class conforming toHookthat 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(key:now:)andreset()are the only membersDedupingHookcalls.EvaluationExposureKey— what identifies an evaluation result: environment name, flag key, variation, flag version, experiment status, and fully qualified context key.EvaluationSeriesContext.evaluationExposureKey— resolves that identity on demand, so an evaluation costs a flag lookup only when a hook asks for one.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
Hookprotocol. It was the first shape this took. It puts a policy on the protocol 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 resolver, no suppression marker in the series data. It is wrong here, because the mobile observability hook pairs its stages, as above.Date()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. Windows are measured againstCLOCK_MONOTONIC_RAW, exposed asEvaluationExposureDeduper.monotonicNow(), which no correction reaches and which, unlikemach_absolute_timeand everything built on it, 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.versionForEventsprefers 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.Note
Overview
Adds opt-in deduplication for hook evaluation callbacks so telemetry hooks can skip repeated reads of the same flag result within a configurable window, without changing LaunchDarkly analytics event counts.
New public pieces:
HookDecorator(stackable hook wrapper),DedupingHook(suppresses whole before/after evaluation series when the exposure matches the last one reported),EvaluationExposureDeduper(default 10‑minute window, monotonic clock, one record per flag per environment),EvaluationExposureKey, andEvaluationSeriesContext.evaluationExposureKey.FeatureFlag.isInExperimentis included in exposure identity.LDClientnow tracksenvironmentNameper multi-environment instance so shared config hooks dedupe per environment.LDClientVariationreads the flag once before running hooks and passes that snapshot into the series context so dedupe decisions match the result the evaluation actually returns. Dedupe state resets onidentify; unwrapped hooks behave as before.Reviewed by Cursor Bugbot for commit 31e0591. Bugbot is set up for automated code reviews on this repo. Configure here.