Skip to content

Commit

Permalink
experiment overrides (#1195)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1204186595873227/1209266907041599/f
iOS PR: duckduckgo/iOS#3892
macOS PR: duckduckgo/macos-browser#3798
What kind of version bump will this require?: Major

**Optional**:

Tech Design URL:
https://app.asana.com/0/1204186595873227/1209266907041602/f

**Description**:
- Experiment types will be defined as FeatureFlag enum values, with
their cohort types defined as per FeatureFlagDescribing protocol.
- Add support for overrides of experiment cohorts, Cohort type can be
used to populate local overrides menu
  • Loading branch information
SabrinaTardio authored Feb 3, 2025
1 parent b874310 commit 4376de0
Show file tree
Hide file tree
Showing 15 changed files with 535 additions and 250 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -51,26 +51,26 @@ public protocol ExperimentCohortsManaging {
/// for the specified experiment. If the assigned cohort is valid (i.e., it matches
/// one of the experiment's defined cohorts), the method returns the assigned cohort.
/// Otherwise, the invalid cohort is removed, and a new cohort is assigned if
/// `allowCohortReassignment` is `true`.
/// `allowCohortAssignment` is `true`.
///
/// - Parameters:
/// - experiment: The `ExperimentSubfeature` representing the experiment and its associated cohorts.
/// - allowCohortReassignment: A Boolean value indicating whether cohort assignment is allowed
/// - allowCohortAssignment: A Boolean value indicating whether cohort assignment is allowed
/// if the user is not already assigned to a valid cohort.
///
/// - Returns: The valid `CohortID` assigned to the user for the experiment, or `nil`
/// if no valid cohort exists and `allowCohortReassignment` is `false`.
/// if no valid cohort exists and `allowCohortAssignment` is `false`.
///
/// - Behavior:
/// 1. Retrieves the currently assigned cohort for the experiment using the `subfeatureID`.
/// 2. Validates if the assigned cohort exists within the experiment's cohort list:
/// - If valid, the assigned cohort is returned.
/// - If invalid, the cohort is removed from storage.
/// 3. If cohort assignment is enabled (`allowCohortReassignment` is `true`), a new cohort
/// 3. If cohort assignment is enabled (`allowCohortAssignment` is `true`), a new cohort
/// is assigned based on the experiment's cohort weights and saved in storage.
/// - Cohort assignment is probabilistic, determined by the cohort weights.
///
func resolveCohort(for experiment: ExperimentSubfeature, allowCohortReassignment: Bool) -> CohortID?
func resolveCohort(for experiment: ExperimentSubfeature, allowCohortAssignment: Bool) -> CohortID?
}

public class ExperimentCohortsManager: ExperimentCohortsManaging {
Expand All @@ -95,14 +95,14 @@ public class ExperimentCohortsManager: ExperimentCohortsManaging {
self.fireCohortAssigned = fireCohortAssigned
}

public func resolveCohort(for experiment: ExperimentSubfeature, allowCohortReassignment: Bool) -> CohortID? {
public func resolveCohort(for experiment: ExperimentSubfeature, allowCohortAssignment: Bool) -> CohortID? {
queue.sync {
let assignedCohort = cohort(for: experiment.subfeatureID)
if experiment.cohorts.contains(where: { $0.name == assignedCohort }) {
return assignedCohort
}
removeCohort(from: experiment.subfeatureID)
return allowCohortReassignment ? assignCohort(to: experiment) : nil
return allowCohortAssignment ? assignCohort(to: experiment) : nil
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,23 @@ public protocol FeatureFlagLocalOverridesPersisting {
///
func value<Flag: FeatureFlagDescribing>(for flag: Flag) -> Bool?

/// Return value for the cohort override for the experiment flag.
///
/// If there's no override, this function should return `nil`.
///
func value<Flag: FeatureFlagDescribing>(for flag: Flag) -> CohortID?

/// Set new override for the feature flag.
///
/// Flag can be overridden to `true` or `false`. Setting `nil` clears the override.
///
func set<Flag: FeatureFlagDescribing>(_ value: Bool?, for flag: Flag)

/// Sets a new cohort override for the specified experiment feature flag.
///
/// Flag can be overridden to one of the cohort of the feature. Setting `nil` clears the override.
///
func setExperiment<Flag: FeatureFlagDescribing>(_ value: CohortID?, for flag: Flag)
}

public struct FeatureFlagLocalOverridesUserDefaultsPersistor: FeatureFlagLocalOverridesPersisting {
Expand All @@ -48,18 +60,36 @@ public struct FeatureFlagLocalOverridesUserDefaultsPersistor: FeatureFlagLocalOv
return keyValueStore.object(forKey: key) as? Bool
}

public func value<Flag: FeatureFlagDescribing>(for flag: Flag) -> CohortID? {
let key = experimentKey(for: flag)
return keyValueStore.object(forKey: key) as? CohortID
}

public func set<Flag: FeatureFlagDescribing>(_ value: Bool?, for flag: Flag) {
let key = key(for: flag)
keyValueStore.set(value, forKey: key)
}

public func setExperiment<Flag: FeatureFlagDescribing>(_ value: CohortID?, for flag: Flag) {
let key = experimentKey(for: flag)
keyValueStore.set(value, forKey: key)
}

/// This function returns the User Defaults key for a feature flag override.
///
/// It uses camel case to simplify inter-process User Defaults KVO.
///
private func key<Flag: FeatureFlagDescribing>(for flag: Flag) -> String {
return "localOverride\(flag.rawValue.capitalizedFirstLetter)"
}

/// This function returns the User Defaults key for a feature flag cohort override.
///
/// It uses camel case to simplify inter-process User Defaults KVO.
///
private func experimentKey<Flag: FeatureFlagDescribing>(for flag: Flag) -> String {
return "localOverride\(flag.rawValue.capitalizedFirstLetter)_cohort"
}
}

private extension String {
Expand All @@ -77,6 +107,13 @@ public protocol FeatureFlagLocalOverridesHandling {
/// It can be implemented by client apps to react to changes to feature flag
/// value in runtime, caused by adjusting its local override.
func flagDidChange<Flag: FeatureFlagDescribing>(_ featureFlag: Flag, isEnabled: Bool)

/// This function is called whenever the effective cohort of a feature flag changes due to a local override.
/// changes as a result of adding or removing a local override.
///
/// It can be implemented by client apps to react to changes to feature flag
/// value in runtime, caused by adjusting its local override.
func experimentFlagDidChange<Flag: FeatureFlagDescribing>(_ featureFlag: Flag, cohort: CohortID)
}

/// `FeatureFlagLocalOverridesHandling` implementation providing Combine publisher for flag changes.
Expand All @@ -87,15 +124,24 @@ public struct FeatureFlagOverridesPublishingHandler<F: FeatureFlagDescribing>: F

public let flagDidChangePublisher: AnyPublisher<(F, Bool), Never>
private let flagDidChangeSubject = PassthroughSubject<(F, Bool), Never>()
public let experimentFlagDidChangePublisher: AnyPublisher<(F, CohortID), Never>
private let experimentFlagDidChangeSubject = PassthroughSubject<(F, CohortID), Never>()

public init() {
flagDidChangePublisher = flagDidChangeSubject.eraseToAnyPublisher()
experimentFlagDidChangePublisher = experimentFlagDidChangeSubject.eraseToAnyPublisher()
}

public func flagDidChange<Flag: FeatureFlagDescribing>(_ featureFlag: Flag, isEnabled: Bool) {
guard let flag = featureFlag as? F else { return }
flagDidChangeSubject.send((flag, isEnabled))
}

public func experimentFlagDidChange<Flag: FeatureFlagDescribing>(_ featureFlag: Flag, cohort: CohortID) {
guard let flag = featureFlag as? F else { return }
experimentFlagDidChangeSubject.send((flag, cohort))
}

}

/// This protocol defines the interface for feature flag overriding mechanism.
Expand All @@ -117,12 +163,21 @@ public protocol FeatureFlagLocalOverriding: AnyObject {
/// Returns the current override for a feature flag, or `nil` if override is not set.
func override<Flag: FeatureFlagDescribing>(for featureFlag: Flag) -> Bool?

/// Returns the current cohort override for a feature flag, or `nil` if override is not set.
func experimentOverride<Flag: FeatureFlagDescribing>(for featureFlag: Flag) -> CohortID?

/// Toggles override for a feature flag.
///
/// If override is not currently present, it sets the override to the opposite of the current flag value.
///
func toggleOverride<Flag: FeatureFlagDescribing>(for featureFlag: Flag)

/// Sets the cohort override for a feature flag.
///
/// If override is not currently present, it sets the override to the opposite of the current flag value.
///
func setExperimentCohortOverride<Flag: FeatureFlagDescribing>(for featureFlag: Flag, cohort: CohortID)

/// Clears override for a feature flag.
///
/// Calls `FeatureFlagLocalOverridesHandling.flagDidChange` if the effective flag value
Expand All @@ -135,11 +190,17 @@ public protocol FeatureFlagLocalOverriding: AnyObject {
/// This function calls `clearOverride(for:)` for each flag.
///
func clearAllOverrides<Flag: FeatureFlagDescribing>(for flagType: Flag.Type)

/// Returns the effective value of a feature flag, considering the current state and overrides.
func currentValue<Flag: FeatureFlagDescribing>(for featureFlag: Flag) -> Bool?

/// Returns the effective cohort of a feature flag, considering the current state and overrides.
func currentExperimentCohort<Flag: FeatureFlagDescribing>(for featureFlag: Flag) -> (any FeatureFlagCohortDescribing)?
}

public final class FeatureFlagLocalOverrides: FeatureFlagLocalOverriding {

public let actionHandler: FeatureFlagLocalOverridesHandling
public var actionHandler: FeatureFlagLocalOverridesHandling
public weak var featureFlagger: FeatureFlagger?
private let persistor: FeatureFlagLocalOverridesPersisting

Expand Down Expand Up @@ -168,6 +229,13 @@ public final class FeatureFlagLocalOverrides: FeatureFlagLocalOverriding {
return persistor.value(for: featureFlag)
}

public func experimentOverride<Flag: FeatureFlagDescribing>(for featureFlag: Flag) -> CohortID? {
guard featureFlag.supportsLocalOverriding else {
return nil
}
return persistor.value(for: featureFlag)
}

public func toggleOverride<Flag: FeatureFlagDescribing>(for featureFlag: Flag) {
guard featureFlag.supportsLocalOverriding else {
return
Expand All @@ -178,13 +246,30 @@ public final class FeatureFlagLocalOverrides: FeatureFlagLocalOverriding {
actionHandler.flagDidChange(featureFlag, isEnabled: newValue)
}

public func clearOverride<Flag: FeatureFlagDescribing>(for featureFlag: Flag) {
guard let override = override(for: featureFlag) else {
public func setExperimentCohortOverride<Flag: FeatureFlagDescribing>(for featureFlag: Flag, cohort: CohortID) {
guard featureFlag.supportsLocalOverriding else {
return
}
persistor.set(nil, for: featureFlag)
if let defaultValue = currentValue(for: featureFlag), defaultValue != override {
actionHandler.flagDidChange(featureFlag, isEnabled: defaultValue)
let newValue = cohort
persistor.setExperiment(newValue, for: featureFlag)
actionHandler.experimentFlagDidChange(featureFlag, cohort: cohort)
}

public func clearOverride<Flag: FeatureFlagDescribing>(for featureFlag: Flag) {
// Clear the regular override
if let override = override(for: featureFlag) {
persistor.set(nil, for: featureFlag)
if let defaultValue = currentValue(for: featureFlag), defaultValue != override {
actionHandler.flagDidChange(featureFlag, isEnabled: defaultValue)
}
}

// Clear the experiment cohort override
if let experimentOverride = experimentOverride(for: featureFlag) {
persistor.setExperiment(nil, for: featureFlag)
if let defaultValue = currentExperimentCohort(for: featureFlag)?.rawValue, defaultValue != experimentOverride {
actionHandler.experimentFlagDidChange(featureFlag, cohort: defaultValue)
}
}
}

Expand All @@ -194,7 +279,12 @@ public final class FeatureFlagLocalOverrides: FeatureFlagLocalOverriding {
}
}

private func currentValue<Flag: FeatureFlagDescribing>(for featureFlag: Flag) -> Bool? {
public func currentValue<Flag: FeatureFlagDescribing>(for featureFlag: Flag) -> Bool? {
featureFlagger?.isFeatureOn(for: featureFlag, allowOverride: true)
}

public func currentExperimentCohort<Flag: FeatureFlagDescribing>(for featureFlag: Flag) -> (any FeatureFlagCohortDescribing)? {
guard let flagger = featureFlagger as? CurrentExperimentCohortProviding else { return nil }
return flagger.assignedCohort(for: featureFlag)
}
}
Loading

0 comments on commit 4376de0

Please sign in to comment.