Skip to content

feat: add configurable evaluation exposure deduplication - #516

Merged
abelonogov-ld merged 38 commits into
v11from
andrey/flag-exposure-dedupe
Aug 14, 2026
Merged

feat: add configurable evaluation exposure deduplication#516
abelonogov-ld merged 38 commits into
v11from
andrey/flag-exposure-dedupe

Conversation

@abelonogov-ld

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

Copy link
Copy Markdown
Contributor
image

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:

config.hooks = [
    MetricsHook(),                                      // every evaluation
    DedupingHook(ObservabilityHook()),                  // 10 minute window
    DedupingHook(TelemetryHook(), window: 60),
    DedupingHook(ExperimentHook(), deduper: myCustomDeduper)
]

New public API:

  • HookDecorator — an open class conforming to 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(key:now:) and reset() are the only members DedupingHook calls.
  • 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 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 declared on the Hook protocol. 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 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 resolver, 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.
  • 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 against CLOCK_MONOTONIC_RAW, exposed as EvaluationExposureDeduper.monotonicNow(), which no correction reaches and which, unlike mach_absolute_time and everything built on it, 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 versionForEvents prefers 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 Android in launchdarkly/android-client-sdk, with docs in sdk-meta and ld-docs-private. Demonstrated in launchdarkly/hello-ios.

Note

Overview
Adds opt-in deduplication for hook evaluation callbacks: wrap a hook in DedupingHook (default 10‑minute window, or a custom EvaluationExposureDeduper) so repeated evaluations with the same EvaluationExposureKey skip both beforeEvaluation and afterEvaluation until the result changes, the window expires, or identify clears state.

Introduces HookDecorator for stacking hook behavior, EvaluationExposureKey / evaluationExposureKey on EvaluationSeriesContext, and UnfairLock for thread-safe dedupe state. LDClient now hashes the mobile key once (mobileKeyHash), collects hooks deterministically at init, and reads each flag once per variation so hooks and the returned value describe the same snapshot.

Unwrapped hooks and LaunchDarkly feature/debug/summary events still run on every evaluation; only wrapped hook stages are suppressed.

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

Comment thread LaunchDarkly/LaunchDarkly/ServiceObjects/EventReporter.swift Outdated
@abelonogov-ld abelonogov-ld changed the title feat: add configurable flag exposure deduplication feat: add configurable evaluation exposure deduplication Aug 5, 2026
@abelonogov-ld abelonogov-ld reopened this Aug 5, 2026
@abelonogov-ld
abelonogov-ld marked this pull request as draft August 5, 2026 03:46
@abelonogov-ld
abelonogov-ld marked this pull request as ready for review August 7, 2026 16:49
Comment thread LaunchDarkly/LaunchDarkly/LDClientVariation.swift Outdated
Comment thread LaunchDarkly/LaunchDarkly/LDClientVariation.swift Outdated
Comment thread LaunchDarkly/LaunchDarkly/Models/Hooks/DedupingHook.swift
Comment thread LaunchDarkly/LaunchDarkly/Models/Hooks/DedupingHook.swift
@abelonogov-ld

Copy link
Copy Markdown
Contributor Author

@launchdarkly verify changes here with Android https://github.com/launchdarkly/android-client-sdk main

@abelonogov-ld

Copy link
Copy Markdown
Contributor Author

@devin verify changes here with Android https://github.com/launchdarkly/android-client-sdk main

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Could not match GitHub user abelonogov-ld to a Devin account. Please make sure your GitHub account is linked in your Devin settings.

Comment thread LaunchDarkly/LaunchDarkly/LDClient.swift Outdated
Comment thread LaunchDarkly/LaunchDarkly/LDClient.swift Outdated
Comment thread LaunchDarkly/LaunchDarkly/ServiceObjects/EvaluationExposureDeduper.swift Outdated
Comment thread LaunchDarkly/LaunchDarkly/ServiceObjects/EvaluationExposureDeduper.swift Outdated
Comment thread LaunchDarkly/LaunchDarkly/ServiceObjects/EvaluationExposureDeduper.swift Outdated
Comment thread LaunchDarkly/LaunchDarkly/ServiceObjects/EvaluationExposureDeduper.swift Outdated
Comment thread LaunchDarkly/LaunchDarkly/ServiceObjects/EvaluationExposureDeduper.swift Outdated
Comment thread LaunchDarkly/LaunchDarkly/ServiceObjects/EvaluationExposureDeduper.swift Outdated
Comment thread LaunchDarkly/LaunchDarkly/ServiceObjects/EvaluationExposureDeduper.swift Outdated
Comment thread LaunchDarkly/LaunchDarkly/ServiceObjects/EvaluationExposureDeduper.swift Outdated
An LDValue could be compared but not hashed, so anything holding one in a
dictionary or a set had to walk the enum and hash each case by hand. The
conformance is synthesized, so it agrees with the equality that was already
synthesized alongside it, including an object hashing the same whichever order
its keys were written in.

Hashable refines Equatable, so this replaces that conformance rather than adding
to it.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abelonogov-ld abelonogov-ld mentioned this pull request Aug 13, 2026
3 tasks
abelonogov-ld and others added 11 commits August 13, 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>
abelonogov-ld and others added 15 commits August 13, 2026 16:36
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>
Co-authored-by: Todd Anderson <127344469+tanderson-ld@users.noreply.github.com>
Every component of the key is hashable on its own, so Swift can synthesize the
conformance, which also makes it agree with the synthesized equality by
construction. The hand-rolled version existed only because LDValue could not be
hashed, and it had to sort an object's keys itself; Dictionary already hashes
regardless of order.

Co-authored-by: Cursor <cursoragent@cursor.com>
The paragraph above it already says the reading counts from an arbitrary point.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abelonogov-ld
abelonogov-ld force-pushed the andrey/flag-exposure-dedupe branch from 6072a22 to 8783669 Compare August 13, 2026 23:39
@abelonogov-ld
abelonogov-ld changed the base branch from v11 to andrey/ldvalue-hashable August 13, 2026 23:39

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

Comment thread LaunchDarkly/LaunchDarkly/Models/FeatureFlag/FeatureFlag.swift Outdated
@tanderson-ld
tanderson-ld self-requested a review August 14, 2026 16:49
abelonogov-ld added a commit that referenced this pull request Aug 14, 2026
**Requirements**

- [x] I have added test coverage for new or changed functionality
- [x] I have followed the repository's [pull request submission
guidelines](../blob/v11/CONTRIBUTING.md#submitting-pull-requests)
- [x] I have validated my changes against all supported platform
versions

**Related issues**

Split out of #516, which needs to hash a flag value as part of an
evaluation exposure key. That PR is stacked on this one.

**Describe the solution you've provided**

`LDValue` was `Equatable` but not `Hashable`, so anything wanting to
hold one in a dictionary or a set had to switch over the enum and hash
each case by hand — which #516 was doing, including sorting object keys
so that a map's iteration order could not change the hash.

Every payload `LDValue` carries is already `Hashable` once `LDValue`
itself is (`[LDValue]` and `[String: LDValue]` conform when their
elements do), so the conformance is synthesized. That matters for
correctness: it agrees by construction with the `Equatable` conformance,
which was also synthesized, rather than being a second hand-written
definition of the same thing that could drift from it.

`Hashable` refines `Equatable`, so this replaces `Equatable` in the
conformance list rather than adding to it. Adding a conformance to a
public type is source compatible.

**Describe alternatives you've considered**

- Writing `hash(into:)` by hand. It's more code, and it has to be kept
in step with equality; the synthesized pair cannot disagree.
- Leaving `LDValue` alone and keeping the hand-rolled hashing in the
deduper. That leaves the same work for the next caller that wants to key
something by a flag value, and each copy has to remember details like
object key ordering.

**Additional context**

`LDValueSpec` covers equal values hashing alike, an object hashing the
same whichever order its keys were written in, values of different kinds
staying distinct (including pairs a naive payload-only hash would
collide, such as `.bool(false)` against `.null`), and a value keying a
dictionary.

Full suite passes: 597 tests.

Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <cursoragent@cursor.com>
Base automatically changed from andrey/ldvalue-hashable to v11 August 14, 2026 16:51
abelonogov-ld and others added 2 commits August 14, 2026 12:53
* v11:
  chore(v11): release 11.4.0 (#520)
  feat: make LDValue hashable (#519)
Two copies of EvaluationExposureDeduper.swift shared a basename, which SPM
rejects with "multiple producers" before it compiles anything. The Xcode
target only referenced the copy under Models/Hooks, so the break was
invisible there.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abelonogov-ld
abelonogov-ld merged commit 3528ebe into v11 Aug 14, 2026
18 checks passed
@abelonogov-ld
abelonogov-ld deleted the andrey/flag-exposure-dedupe branch August 14, 2026 22:32
abelonogov-ld pushed a commit that referenced this pull request Aug 14, 2026
🤖 I have created a release *beep* *boop*
---


##
[11.5.0](11.4.0...11.5.0)
(2026-08-14)


### Features

* add configurable evaluation exposure deduplication
([#516](#516))
([3528ebe](3528ebe))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Release **11.5.0** updates version strings from **11.4.0** in the
release manifest, **CHANGELOG**, **LaunchDarkly.podspec**, Xcode
**MARKETING_VERSION** / **DYLIB_CURRENT_VERSION**,
**ReportingConsts.sdkVersion**, and dependency examples in **README**
(SPM, CocoaPods, Carthage).
> 
> The new changelog entry for this release highlights **configurable
evaluation exposure deduplication**
([#516](https://github.com/launchdarkly/ios-client-sdk/issues/516))—the
`DedupingHook` / `EvaluationExposureDeduper` work that lets apps wrap
hooks with a time window so repeated identical evaluations are not
re-reported. That feature is not part of this diff; this PR is the
version bump and release notes only.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
7bc0008. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: LaunchDarklyReleaseBot <LaunchDarklyReleaseBot@launchdarkly.com>
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.

2 participants