Skip to content

feat: exposure dedupe call site - #517

Open
abelonogov-ld wants to merge 23 commits into
v11from
andrey/exposure-dedupe-call-site
Open

feat: exposure dedupe call site#517
abelonogov-ld wants to merge 23 commits into
v11from
andrey/exposure-dedupe-call-site

Conversation

@abelonogov-ld

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

Copy link
Copy Markdown
Contributor

Requirements

Related issues

Stacked on #REPLACE_WITH_DEDUPE_PR, the evaluation exposure deduplication PR. Review that one first; this changes what "the same exposure" means.

Describe the solution you've provided

A deduping hook was told about a flag once per window however many places in the application read it, so whichever place read it first stood for all of them: a flag read on a view redraw and again in a tap handler produced one report, and nothing distinguished which read it came from.

The variation methods now capture where they were called from, and that becomes part of the exposure:

public func boolVariation(forKey flagKey: LDFlagKey, defaultValue: Bool, file: String? = #fileID, line: UInt = #line) -> Bool
public struct EvaluationCallSite: Hashable {
    public let fileID: String
    public let line: UInt
}

The deduper holds a record per call site rather than per flag, so each place a flag is read from gets its own window and its own memory of the result it last reported. Repeats from one place are suppressed exactly as before; two places reading the same flag to the same result now report both.

The call site had to go in the record's identity rather than in the result compared against it. Were it part of the result, reading a flag alternately from two places would look like the result changing every time and nothing would ever be suppressed.

Prerequisite evaluations inherit the call site of the evaluation that caused them, since that is where the application read from.

Objective-C

ObjcLDClient passes file: nil, so evaluations made through it carry no call site. Left to the default, the captured location would be the wrapper's own line rather than anywhere in the caller's application, and every read made through that interface would look like the same handful of places. Objective-C has no way to supply its own, so those evaluations deduplicate per flag as they did before.

Compatibility

The new parameters have defaults, so existing Swift and Objective-C call sites compile and behave as they did, except for now being deduplicated per place rather than per flag. The one source break is taking an unapplied reference to a variation method, as in let evaluate = client.boolVariation, whose type gains the two parameters.

Describe alternatives you've considered

  • A single callSite: EvaluationCallSite? = .here() parameter rather than file: and line:. Nicer to read, but #fileID and #line expand at the call site of the function whose default they are, and nesting them one level deeper inside here() does not carry that through to the application.
  • Capturing #function as well. The file and line already identify a place uniquely, and the function name would only make the key larger.
  • Making this opt-in, on the deduper or the hook. Every caller of a deduping hook wants to know where a flag is read from, and an option would have meant carrying both policies.

Additional context

Test coverage: two places reading one flag to one result reach the hook twice while repeats from each are suppressed; the key carries the file and line of the application's read rather than anywhere inside the SDK; and the deduper tracks a flag separately per call site, with an unattributed read forming its own record. The existing hook specs now evaluate through a helper, so their evaluations share one call site: written inline they sit on separate lines, and would no longer suppress one another.

The documentation snippets in sdk-meta and the prose in ld-docs-private need a follow-up saying that deduplication is per place rather than per flag; happy to open those once the shape here is agreed.


Note

Overview
Evaluation exposure deduplication is now per call site, so a flag read from a view and from a tap handler can both reach a wrapped hook while repeats from the same line stay folded within the window.

Swift variation and *VariationDetail methods gain optional file (#fileID) and line defaults. Those feed EvaluationCallSite and EvaluationSeriesContext.evaluationExposureKey, and EvaluationExposureDeduper keys state by environment, flag, and call site (not just flag). Prerequisite evaluations reuse the parent read’s call site. LDClient also records environmentName, fixes the hook list after plugin registration, and reads each flag once per evaluation so hooks see a consistent result.

ObjcLDClient passes file: nil, so Objective-C reads still dedupe as one bucket per flag with no caller location.

Existing call sites keep compiling via defaults; unapplied method references (e.g. client.boolVariation) pick up the new parameter types. LaunchDarkly analytics events are unchanged—dedupe still applies only to hook stages.

Reviewed by Cursor Bugbot for commit 5c569a7. Bugbot is set up for automated code reviews on this repo. Configure here.

abelonogov-ld and others added 23 commits August 4, 2026 16:36
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>
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>
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>
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>
A deduping hook was told about a flag once per window however many places in
an application read it, so the first place read stood for all of them and a
view redraw could hide a tap handler. The variation methods now capture the
file and line of the call, which becomes part of the exposure key and gives
each place its own record and window. Evaluations made through the
Objective-C interface carry no call site, as its wrapper's own location
describes the SDK rather than anywhere a flag is read from.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abelonogov-ld
abelonogov-ld requested a review from a team as a code owner August 11, 2026 02:09
@abelonogov-ld abelonogov-ld changed the title Andrey/exposure dedupe call site feat: exposure dedupe call site Aug 11, 2026

@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 5c569a7. Configure here.

override public func beforeIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData) -> IdentifySeriesData {
deduper.reset()
return super.beforeIdentify(seriesContext: seriesContext, seriesData: seriesData)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Identify skips secondary plugin dedupers

Medium Severity

DedupingHook clears its dedupe state in beforeIdentify, but identify runs those hooks only on the client that received the call while updating every environment’s context. Config-level hooks are shared so they reset once; plugin getHooks builds a fresh DedupingHook per environment, so secondary instances never reset. After identify with the same context key, those hooks can keep suppressing exposures that should be reported again.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5c569a7. 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.

1 participant