-
Notifications
You must be signed in to change notification settings - Fork 93
feat: add configurable evaluation exposure deduplication #516
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 4f95e46
feat: add configurable flag exposure deduplication
abelonogov-ld a3a6241
refactor: express the dedupe window as a TimeInterval
abelonogov-ld 5ee0b80
test: cover eviction when reclaiming expired keys is enough
abelonogov-ld 97fd5ed
refactor: name the dedupe options after evaluation exposures
abelonogov-ld 0c926ef
refactor: drop the sort from exposure cache eviction
abelonogov-ld d0df923
fix: key exposure dedupe on experiment status
abelonogov-ld af353d2
refactor: deduplicate exposures reported to hooks, not events
abelonogov-ld 26c35dd
feat: let each hook choose how its exposures are deduplicated
abelonogov-ld d63c0e5
refactor: make evaluation exposure dedupe opt-in per hook
abelonogov-ld c7b1727
feat: default the exposure deduper to a 10 minute window over 2000 keys
abelonogov-ld 4676828
fix: keep exposure keys distinct across environments
abelonogov-ld f94ae50
refactor: identify exposures with a typed key instead of a joined string
abelonogov-ld a3b4a87
refactor: dedupe against a flag's last result rather than every resul…
abelonogov-ld 9ee495f
refactor: stop exposing a cap on how many results a deduper tracks
abelonogov-ld 6674a10
refactor: stop bounding how many flags a deduper tracks
abelonogov-ld 2b2d23a
refactor: opt into exposure dedupe by wrapping a hook rather than dec…
abelonogov-ld a0c3f6b
refactor: pass the whole evaluation to the exposure key resolver
abelonogov-ld 46b6786
fix: measure a dedupe window against a clock a time correction cannot…
abelonogov-ld 8f6c29c
docs: say that a deduping hook belongs outermost when decorators stack
abelonogov-ld 5e7a777
fix: resolve an evaluation's exposure key once, not per hook that asks
abelonogov-ld c5b2ef8
refactor: read the flag once per evaluation, for the hooks and the re…
abelonogov-ld 0bc6bf5
perf: build an exposure key only for a hook that asks for one
abelonogov-ld c9e96b5
flag value
abelonogov-ld 7cb5f30
fixes
abelonogov-ld af257d3
push
abelonogov-ld d5a583e
mobile key
abelonogov-ld 14d3e83
id
abelonogov-ld ff651ff
renamed
abelonogov-ld 1a0245c
comments
abelonogov-ld 6b2c0fb
more
abelonogov-ld 713ecc2
Apply suggestions from code review
abelonogov-ld 4df357c
refactor: hash an exposure key by synthesis now that LDValue is hashable
abelonogov-ld 8783669
docs: drop the note about not reading monotonicNow as a time of day
abelonogov-ld 952a196
Unfair lock
abelonogov-ld 704d715
remove
abelonogov-ld 16119b1
Merge branch 'v11' into andrey/flag-exposure-dedupe
abelonogov-ld b7b1404
fix: drop the duplicate deduper file the merge left behind
abelonogov-ld File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
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
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
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
100 changes: 100 additions & 0 deletions
100
LaunchDarkly/LaunchDarkly/Models/Hooks/DedupingHook.swift
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
| 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) | ||
| } | ||
|
|
||
| /// 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) | ||
| } | ||
|
abelonogov-ld marked this conversation as resolved.
|
||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.