Skip to content
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
65827b6
feat: make LDValue hashable
abelonogov-ld Aug 13, 2026
4f95e46
feat: add configurable flag exposure deduplication
abelonogov-ld Aug 4, 2026
a3a6241
refactor: express the dedupe window as a TimeInterval
abelonogov-ld Aug 5, 2026
5ee0b80
test: cover eviction when reclaiming expired keys is enough
abelonogov-ld Aug 5, 2026
97fd5ed
refactor: name the dedupe options after evaluation exposures
abelonogov-ld Aug 5, 2026
0c926ef
refactor: drop the sort from exposure cache eviction
abelonogov-ld Aug 5, 2026
d0df923
fix: key exposure dedupe on experiment status
abelonogov-ld Aug 5, 2026
af353d2
refactor: deduplicate exposures reported to hooks, not events
abelonogov-ld Aug 5, 2026
26c35dd
feat: let each hook choose how its exposures are deduplicated
abelonogov-ld Aug 6, 2026
d63c0e5
refactor: make evaluation exposure dedupe opt-in per hook
abelonogov-ld Aug 6, 2026
c7b1727
feat: default the exposure deduper to a 10 minute window over 2000 keys
abelonogov-ld Aug 6, 2026
4676828
fix: keep exposure keys distinct across environments
abelonogov-ld Aug 7, 2026
f94ae50
refactor: identify exposures with a typed key instead of a joined string
abelonogov-ld Aug 7, 2026
a3b4a87
refactor: dedupe against a flag's last result rather than every resul…
abelonogov-ld Aug 7, 2026
9ee495f
refactor: stop exposing a cap on how many results a deduper tracks
abelonogov-ld Aug 7, 2026
6674a10
refactor: stop bounding how many flags a deduper tracks
abelonogov-ld Aug 8, 2026
2b2d23a
refactor: opt into exposure dedupe by wrapping a hook rather than dec…
abelonogov-ld Aug 10, 2026
a0c3f6b
refactor: pass the whole evaluation to the exposure key resolver
abelonogov-ld Aug 10, 2026
46b6786
fix: measure a dedupe window against a clock a time correction cannot…
abelonogov-ld Aug 10, 2026
8f6c29c
docs: say that a deduping hook belongs outermost when decorators stack
abelonogov-ld Aug 11, 2026
5e7a777
fix: resolve an evaluation's exposure key once, not per hook that asks
abelonogov-ld Aug 11, 2026
c5b2ef8
refactor: read the flag once per evaluation, for the hooks and the re…
abelonogov-ld Aug 11, 2026
0bc6bf5
perf: build an exposure key only for a hook that asks for one
abelonogov-ld Aug 11, 2026
c9e96b5
flag value
abelonogov-ld Aug 11, 2026
7cb5f30
fixes
abelonogov-ld Aug 11, 2026
af257d3
push
abelonogov-ld Aug 12, 2026
d5a583e
mobile key
abelonogov-ld Aug 12, 2026
14d3e83
id
abelonogov-ld Aug 12, 2026
ff651ff
renamed
abelonogov-ld Aug 12, 2026
1a0245c
comments
abelonogov-ld Aug 12, 2026
6b2c0fb
more
abelonogov-ld Aug 13, 2026
713ecc2
Apply suggestions from code review
abelonogov-ld Aug 13, 2026
4df357c
refactor: hash an exposure key by synthesis now that LDValue is hashable
abelonogov-ld Aug 13, 2026
8783669
docs: drop the note about not reading monotonicNow as a time of day
abelonogov-ld Aug 13, 2026
952a196
Unfair lock
abelonogov-ld Aug 14, 2026
704d715
remove
abelonogov-ld Aug 14, 2026
16119b1
Merge branch 'v11' into andrey/flag-exposure-dedupe
abelonogov-ld Aug 14, 2026
b7b1404
fix: drop the duplicate deduper file the merge left behind
abelonogov-ld Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions LaunchDarkly.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

41 changes: 27 additions & 14 deletions LaunchDarkly/LaunchDarkly/LDClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,15 @@ public class LDClient {
}

let config: LDConfig
/// Identifies this client's environment to a hook, without handing it the mobile key that identifies the
/// environment to LaunchDarkly. Hashed once here rather than per evaluation, because a deduping hook asks for it on
/// a path an application may take on every redraw of a view.
let mobileKeyHash: String
let service: DarklyServiceProvider
var hooks: [Hook]
/// The hooks registered with this client: the configuration's.
/// Constant, so that a series reading it more than once, as an evaluation series does for its before and after
/// stages, runs the same hooks in both, whichever thread the evaluation was made from.
let hooks: [Hook]
private(set) var context: LDContext

/**
Expand Down Expand Up @@ -949,35 +956,41 @@ public class LDClient {
private var initializedQueue = DispatchQueue(label: "com.launchdarkly.LDClient.initializedQueue")
private var identifyQueue = SheddingQueue()

private init(serviceFactory: ClientServiceCreating, configuration: LDConfig, startContext: LDContext?, completion: (() -> Void)? = nil) {
self.serviceFactory = serviceFactory
self.hooks = Array(configuration.hooks)
environmentReporter = self.serviceFactory.makeEnvironmentReporter(config: configuration)

// Collect plugin hooks before calling beforeIdentify, so plugin hooks participate in the init identify lifecycle.
let initSdkMetadata = SdkMetadata(name: SystemCapabilities.systemName, version: ReportingConsts.sdkVersion)
let initEnvironmentMetadata = EnvironmentMetadata(
/// The hooks the configuration registers, followed by the hooks the plugins contribute. Collected before the init
/// identify series opens, so that plugin hooks take part in it.
private static func collectHooks(configuration: LDConfig, environmentReporter: EnvironmentReporting) -> [Hook] {
var hooks = Array(configuration.hooks)
let metadata = EnvironmentMetadata(
applicationInfo: environmentReporter.applicationInfo,
sdkMetadata: initSdkMetadata,
sdkMetadata: SdkMetadata(name: SystemCapabilities.systemName, version: ReportingConsts.sdkVersion),
credential: configuration.mobileKey
)
for plugin in configuration.plugins {
do {
let pluginHooks = try plugin.getHooks(metadata: initEnvironmentMetadata)
self.hooks.append(contentsOf: pluginHooks)
hooks.append(contentsOf: try plugin.getHooks(metadata: metadata))
} catch {
os_log("Exception thrown getting hooks for plugin %@. Unable to get hooks, plugin will not be registered.", log: configuration.logger, type: .error, plugin.getMetadata().getName())
}
}
return hooks
}

private init(serviceFactory: ClientServiceCreating, configuration: LDConfig, startContext: LDContext?, completion: (() -> Void)? = nil) {
// Set before the hooks below run, so that the environment a hook is told about is this client's rather than the
// primary one's.
self.mobileKeyHash = Util.sha256base64(configuration.mobileKey)
self.serviceFactory = serviceFactory
environmentReporter = self.serviceFactory.makeEnvironmentReporter(config: configuration)
let hooks = LDClient.collectHooks(configuration: configuration, environmentReporter: environmentReporter)
self.hooks = hooks

flagCache = self.serviceFactory.makeFeatureFlagCache(mobileKey: configuration.mobileKey, maxCachedContexts: configuration.maxCachedContexts)
flagStore = self.serviceFactory.makeFlagStore()
flagChangeNotifier = self.serviceFactory.makeFlagChangeNotifier()
throttler = self.serviceFactory.makeThrottler(environmentReporter: environmentReporter)

config = configuration
let anonymousContext = LDContext()
context = startContext ?? anonymousContext
context = startContext ?? LDContext()

if config.autoEnvAttributes {
context = AutoEnvContextModifier(environmentReporter: environmentReporter, logger: config.logger).modifyContext(context)
Expand Down
24 changes: 13 additions & 11 deletions LaunchDarkly/LaunchDarkly/LDClientVariation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,27 +143,27 @@ extension LDClient {
return variationDetailInternal(flagKey, defaultValue, needsReason: true, methodName: "variationDetail")
}

private func evaluateWithHooks<D>(flagKey: LDFlagKey, defaultValue: D, methodName: String, evaluation: () -> LDEvaluationDetail<D>) -> LDEvaluationDetail<D> where D: LDValueConvertible, D: Decodable {
guard !self.hooks.isEmpty else {
private func evaluateWithHooks<D>(flagKey: LDFlagKey, defaultValue: D, methodName: String, featureFlag: FeatureFlag?, evaluation: () -> LDEvaluationDetail<D>) -> LDEvaluationDetail<D> where D: LDValueConvertible, D: Decodable {
guard !hooks.isEmpty else {
return evaluation()
}

let seriesContext = EvaluationSeriesContext(flagKey: flagKey, context: self.context, defaultValue: defaultValue.toLDValue(), methodName: methodName)
let hookData = self.execute_before_evaluation(seriesContext: seriesContext)
let seriesContext = EvaluationSeriesContext(flagKey: flagKey, context: self.context, defaultValue: defaultValue.toLDValue(), methodName: methodName, mobileKeyHash: mobileKeyHash, featureFlag: featureFlag)
let hookData = self.execute_before_evaluation(hooks: hooks, seriesContext: seriesContext)
let evaluationResult = evaluation()
_ = self.execute_after_evaluation(seriesContext: seriesContext, hookData: hookData, evaluationDetail: evaluationResult.map { value in return value.toLDValue()})
_ = self.execute_after_evaluation(hooks: hooks, seriesContext: seriesContext, hookData: hookData, evaluationDetail: evaluationResult.map { value in return value.toLDValue()})

return evaluationResult
}

private func execute_before_evaluation(seriesContext: EvaluationSeriesContext) -> [EvaluationSeriesData] {
return self.hooks.map { hook in
private func execute_before_evaluation(hooks: [Hook], seriesContext: EvaluationSeriesContext) -> [EvaluationSeriesData] {
return hooks.map { hook in
hook.beforeEvaluation(seriesContext: seriesContext, seriesData: EvaluationSeriesData())
}
}

private func execute_after_evaluation(seriesContext: EvaluationSeriesContext, hookData: [EvaluationSeriesData], evaluationDetail: LDEvaluationDetail<LDValue>) -> [EvaluationSeriesData] {
return zip(self.hooks, hookData).reversed().map { (hook, data) in
private func execute_after_evaluation(hooks: [Hook], seriesContext: EvaluationSeriesContext, hookData: [EvaluationSeriesData], evaluationDetail: LDEvaluationDetail<LDValue>) -> [EvaluationSeriesData] {
return zip(hooks, hookData).reversed().map { (hook, data) in
return hook.afterEvaluation(seriesContext: seriesContext, seriesData: data, evaluationDetail: evaluationDetail)
}
}
Expand All @@ -174,9 +174,11 @@ extension LDClient {
}

private func variationDetailInternal<T>(_ flagKey: LDFlagKey, _ defaultValue: T, needsReason: Bool, methodName: String, visited: inout Set<String>?) -> LDEvaluationDetail<T> where T: Decodable, T: LDValueConvertible {
return evaluateWithHooks(flagKey: flagKey, defaultValue: defaultValue, methodName: methodName) {
// Read once, so that the flag a hook is told the evaluation is about to return is the flag it does return: were
// the store read again below, an update landing in between would leave the two describing different results.
let featureFlag = flagStore.featureFlag(for: flagKey)
return evaluateWithHooks(flagKey: flagKey, defaultValue: defaultValue, methodName: methodName, featureFlag: featureFlag) {
var result: LDEvaluationDetail<T>
let featureFlag = flagStore.featureFlag(for: flagKey)
if let featureFlag = featureFlag {
if let prerequisites = featureFlag.prerequisites, !prerequisites.isEmpty {
// Recurse on prerequisites to emulate prereq evaluations occurring with desirable side effects
Expand Down
5 changes: 4 additions & 1 deletion LaunchDarkly/LaunchDarkly/LDCommon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,12 @@ struct DynamicKey: CodingKey {

This can be used to represent complex data in a context attribute, or to get a feature flag value that uses a
complex type or does not always use the same type.

Values are `Hashable`, so one can key a dictionary or belong to a set. Two values that are equal hash alike, which for
an object means that the order its keys were written in does not matter.
*/
public enum LDValue: Codable,
Equatable,
Hashable,
ExpressibleByNilLiteral,
ExpressibleByBooleanLiteral,
ExpressibleByIntegerLiteral,
Expand Down
100 changes: 100 additions & 0 deletions LaunchDarkly/LaunchDarkly/Models/Hooks/DedupingHook.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import Foundation

/**
Wraps a hook so that repeated evaluations resolving to the same result do not reach it again within a time window.

The wrapped hook is told about a flag when its result changes, and at most once per window while the result stays the
same. This is useful for reducing the telemetry volume produced by frequent re-evaluations, for example a flag that is
read on every redraw of a view. Deduplication is opt-in: a hook that is registered unwrapped observes every evaluation.

This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. It is
experimental.

```swift
config.hooks = [
MetricsHook(), // observes every evaluation
DedupingHook(ObservabilityHook()), // default window
DedupingHook(TelemetryHook(), window: 60),
DedupingHook(ExperimentHook(), deduper: sharedDeduper)
]
```

Two evaluations resolve to the same result when they agree on everything `EvaluationExposureKey` describes.

An evaluation the SDK has no flag data for resolves to the default value, and is the same result as another that does.
Evaluations made before the client has flags are of that kind, so the wrapped hook is told about one of them and then
told about the flag again as soon as its data arrives.

A suppressed evaluation reaches neither `beforeEvaluation` nor `afterEvaluation`, because hooks pair their stages. The
identify and track stages are always forwarded. Analytics events are unaffected: feature, debug, and summary events are
still recorded for every evaluation, so the evaluation counts LaunchDarkly reports for your flags do not change.

What the wrapped hook has been told about is cleared by `LDClient.identify(context:)`, so the first evaluation of each
flag after an identify always reaches it.

Give each hook its own instance unless you intend hooks to share a window: the first hook to be told about an evaluation
starts the window that suppresses the rest.

Wrap outermost when you stack hooks that wrap other hooks. Suppressing an evaluation means returning series data that
says so in place of what the stage was given, so a wrapper outside this one does not get back what it stored in its own
before stage. A wrapper inside this one is unaffected, since a suppressed evaluation never reaches it.
*/
public final class DedupingHook: HookDecorator {
// Namespaced because it travels in series data that the wrapped hook may also write to.
private static let suppressedKey = "com.launchdarkly.DedupingHook.suppressed"

private let deduper: EvaluationExposureDeduper

/**
- parameter delegate: The hook to wrap.
- parameter window: The dedupe window, in seconds. Defaults to `EvaluationExposureDeduper.defaultWindow`. A value of
zero or less forwards every evaluation.
*/
public convenience init(_ delegate: Hook, window: TimeInterval = EvaluationExposureDeduper.defaultWindow) {
self.init(delegate, deduper: EvaluationExposureDeduper(window: window))
}

/**
- parameter delegate: The hook to wrap.
- parameter deduper: Decides which evaluations reach the wrapped hook.
*/
public init(_ delegate: Hook, deduper: EvaluationExposureDeduper) {
self.deduper = deduper
super.init(delegate)
}

/**
Forwards the evaluation unless the wrapped hook has just been told about the same result.

The decision is made here, before the evaluation runs, so that a suppressed evaluation reaches neither stage of the
wrapped hook. An evaluation whose result the SDK did not describe, which is to say a series context built by
something other than the SDK, is always forwarded.
*/
public override func beforeEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData) -> EvaluationSeriesData {
if let key = seriesContext.evaluationExposureKey, !deduper.shouldRecord(key: key) {
// Recognized by identity below, so that stacked instances each recognize only their own suppressions.
return [DedupingHook.suppressedKey: self]
}
return super.beforeEvaluation(seriesContext: seriesContext, seriesData: seriesData)
}
Comment thread
cursor[bot] marked this conversation as resolved.

/// Forwards the result unless this instance suppressed the series in its before stage.
public override func afterEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail<LDValue>) -> EvaluationSeriesData {
if let marker = seriesData[DedupingHook.suppressedKey], marker as AnyObject === self {
return seriesData
}
return super.afterEvaluation(seriesContext: seriesContext, seriesData: seriesData, evaluationDetail: evaluationDetail)
}

/**
Forgets which results the wrapped hook has been told about, then forwards the stage.

Evaluations observed before an identify describe an earlier point in the application's lifecycle, so they are
reported again afterwards. This happens even when the context is unchanged, so that identify is a reliable way for an
application to mark a new phase of a session.
*/
public override func beforeIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData) -> IdentifySeriesData {
deduper.reset()
return super.beforeIdentify(seriesContext: seriesContext, seriesData: seriesData)
}
Comment thread
abelonogov-ld marked this conversation as resolved.
}
Loading
Loading