Skip to content

Commit

Permalink
added descriptions for functions/variables, update documentation, add…
Browse files Browse the repository at this point in the history
…ed function for set sticky bucketing service
  • Loading branch information
vazarkevych committed Apr 20, 2024
1 parent 0ec3552 commit c2d0512
Show file tree
Hide file tree
Showing 10 changed files with 235 additions and 246 deletions.
6 changes: 5 additions & 1 deletion GrowthBookTests/MockNetworkClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ class MockNetworkClient: NetworkProtocol {
}

func consumePOSTRequest(url: String, params: [String : Any], successResult: @escaping (Data) -> Void, errorResult: @escaping (any Error) -> Void) {

if let successResponse = successResponse {
successResult(successResponse.data(using: .utf8) ?? Data())
} else if let error = error {
errorResult(error)
}
}
}

Expand Down
31 changes: 29 additions & 2 deletions GrowthBookTests/Source/json.json
Original file line number Diff line number Diff line change
Expand Up @@ -3045,8 +3045,35 @@
}
],
[
"Force rule, hash version 2",
{
"Force rule with range, ignores coverage",
{
"attributes": {
"id": "1"
},
"features": {
"feature": {
"defaultValue": 0,
"rules": [
{
"force": 2,
"coverage": 0.01,
"range": [0, 0.99]
}
]
}
}
},
"feature",
{
"value": 2,
"on": true,
"off": false,
"source": "force"
}
],
[
"Force rule, hash version 2",
{
"attributes": {
"id": "1"
},
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ var sdkInstance: GrowthBookSDK = GrowthBookBuilder(apiHost: <GrowthBook/API_HOST
.setForcedVariations(forcedVariations: <[String: Int]>) // Pass Forced Variations
.setLogLevel(<LoggerLevel>) // Set log level for SDK Logger, by default log level is set to `info`
.setCacheDirectory(<CacheDirectory>) // This function configures the cache directory used by the application to the designated directory type. Subsequent cache-related operations will target this directory.
.setStickyBucketService(stickyBucketService: StickyBucketService()) // This function creates a sticky bucket service.
.initializer()
```

Expand Down Expand Up @@ -365,6 +366,31 @@ var sdkInstance: GrowthBookSDK = GrowthBookBuilder(apiHost: <GrowthBook/API_KEY>
}, refreshHandler: { isRefreshed in
}, backgroundSync: true)
.initializer()



## Remote Evaluation

This mode brings the security benefits of a backend SDK to the front end by evaluating feature flags exclusively on a private server. Using Remote Evaluation ensures that any sensitive information within targeting rules or unused feature variations are never seen by the client. Note that Remote Evaluation should not be used in a backend context.

You must enable Remote Evaluation in your SDK Connection settings. Cloud customers are also required to self-host a GrowthBook Proxy Server or custom remote evaluation backend.

To use Remote Evaluation, add the `remoteEval: true` property to your SDK instance. A new evaluation API call will be made any time a user attribute or other dependency changes. You may optionally limit these API calls to specific attribute changes by setting the `cacheKeyAttributes` property (an array of attribute names that, when changed, trigger a new evaluation call).

var sdkInstance: GrowthBookSDK = GrowthBookBuilder(apiHost: <GrowthBook/API_KEY>, clientKey: <GrowthBook/ClientKey>, attributes: <[String: Any]>, trackingCallback: { experiment, experimentResult in
}, refreshHandler: { isRefreshed in
}, remoteEval: true)
.initializer()

:::note

If you would like to implement Sticky Bucketing while using Remote Evaluation, you must configure your remote evaluation backend to support Sticky Bucketing. You will not need to provide a StickyBucketService instance to the client side SDK.



## Sticky Bucketing

Sticky bucketing ensures that users see the same experiment variant, even when user session, user login status, or experiment parameters change. See the [Sticky Bucketing docs](/app/sticky-bucketing) for more information. If your organization and experiment supports sticky bucketing, you must implement an instance of the `StickyBucketService` to use Sticky Bucketing. For simple bucket persistence using the browser's LocalStorage (can be polyfilled for other environments).


## License
Expand Down
92 changes: 6 additions & 86 deletions Sources/CommonMain/Evaluators/ExperimentEvaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class ExperimentEvaluator {
var stickyBucketVersionIsBlocked = false

if context.stickyBucketService != nil, !(experiment.disableStickyBucketing ?? true) {
let (variation, versionIsBlocked) = getStickyBucketVariation(context: context,
let (variation, versionIsBlocked) = Utils.getStickyBucketVariation(context: context,
experimentKey: experiment.key,
experimentBucketVersion: experiment.bucketVersion ?? 0,
minExperimentBucketVersion: experiment.minBucketVersion ?? 0,
Expand All @@ -54,7 +54,7 @@ class ExperimentEvaluator {
// Some checks are not needed if we already have a sticky bucket
if !foundStickyBucket {
if let filters = experiment.filters {
if isFilteredOut(context: context, filters: filters) {
if Utils.isFilteredOut(filters: filters, context: context, attributeOverrides: attributeOverrides) {
print("Skip because of filters")
return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false)
}
Expand All @@ -77,14 +77,14 @@ class ExperimentEvaluator {
for parentCondition in parentConditions {

// TODO: option is to not pass attributeOverrides
var parentResult = FeatureEvaluator(context: context, featureKey: parentCondition.id, attributeOverrides: JSON(parentCondition.condition)).evaluateFeature()
let parentResult = FeatureEvaluator(context: context, featureKey: parentCondition.id, attributeOverrides: JSON(parentCondition.condition)).evaluateFeature()

if parentResult.source == FeatureSource.cyclicPrerequisite.rawValue {
return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false)
}

let evalObj = ["value": parentResult.value]
var evalCondition = ConditionEvaluator().isEvalCondition(
let evalCondition = ConditionEvaluator().isEvalCondition(
attributes: JSON(evalObj),
conditionObj: parentCondition.condition
)
Expand Down Expand Up @@ -133,10 +133,10 @@ class ExperimentEvaluator {
let result = getExperimentResult(gbContext: context, experiment: experiment, variationIndex: assigned, hashUsed: true, bucket: hash, stickyBucketUsed: foundStickyBucket)
print("ExperimentResult: \(result)")
if context.stickyBucketService != nil && !(experiment.disableStickyBucketing ?? true) {
let (key, doc, changed) = generateStickyBucketAssignmentDoc(context: context,
let (key, doc, changed) = Utils.generateStickyBucketAssignmentDoc(context: context,
attributeName: hashAttribute,
attributeValue: hashValue,
assignments: [getStickyBucketExperimentKey(experiment.key,
assignments: [Utils.getStickyBucketExperimentKey(experiment.key,
experiment.bucketVersion ?? 0): result.key])
if changed {
context.stickyBucketAssignmentDocs = context.stickyBucketAssignmentDocs ?? [:]
Expand Down Expand Up @@ -193,84 +193,4 @@ class ExperimentEvaluator {

return result
}

func getStickyBucketAssignments(context: Context) -> [String: String] {
var mergedAssignments: [String: String] = [:]

context.stickyBucketAssignmentDocs?.values.forEach({ doc in
mergedAssignments.merge(doc.assignments)
})
return mergedAssignments
}

func getStickyBucketVariation(
context: Context,
experimentKey: String,
experimentBucketVersion: Int = 0,
minExperimentBucketVersion: Int = 0,
meta: [VariationMeta] = []
) -> (variation: Int, versionIsBlocked: Bool?) {

let id = getStickyBucketExperimentKey(experimentKey, experimentBucketVersion)
let assignments = getStickyBucketAssignments(context: context)

// users with any blocked bucket version (0 to minExperimentBucketVersion) are excluded from the test
if minExperimentBucketVersion > 0 {
for version in 0...minExperimentBucketVersion {
let blockedKey = getStickyBucketExperimentKey(experimentKey, version)
if let _ = assignments[blockedKey] {
return (variation: -1, versionIsBlocked: true)
}
}
}
guard let variationKey = assignments[id] else {
return (variation: -1, versionIsBlocked: nil)
}
guard let variation = meta.firstIndex(where: { $0.key == variationKey }) else {
// invalid assignment, treat as "no assignment found"
return (variation: -1, versionIsBlocked: nil)
}

return (variation: variation, versionIsBlocked: nil)
}

func getStickyBucketExperimentKey(_ experimentKey: String, _ experimentBucketVersion: Int = 0) -> String {
return "\(experimentKey)__\(experimentBucketVersion)" //`${experimentKey}__${experimentBucketVersion}`;
}

private func isFilteredOut(context: Context, filters: [Filter]) -> Bool {
return filters.contains { filter in
let hashAttribute = Utils.getHashAttribute(context: context, attr: filter.attribute, attributeOverrides: attributeOverrides)
let hashValue = hashAttribute.hashValue

let hash = Utils.hash(seed: filter.seed, value: hashValue, version: filter.hashVersion)
guard let hashValue = hash else { return true }

return !filter.ranges.contains { range in
return Utils.inRange(n: hashValue, range: range)
}
}
}

func generateStickyBucketAssignmentDoc(context: Context,
attributeName: String,
attributeValue: String,
assignments: [String: String]) -> (key: String, doc: StickyAssignmentsDocument, changed: Bool) {
let key = "\(attributeName)||\(attributeValue)"
let existingAssignments: [String: String] = (context.stickyBucketAssignmentDocs?[key]?.assignments) ?? [:]
var newAssignments = existingAssignments
assignments.forEach { newAssignments[$0] = $1 }

let changed = NSDictionary(dictionary: existingAssignments).isEqual(to: newAssignments) == false

return (
key: key,
doc: StickyAssignmentsDocument(
attributeName: attributeName,
attributeValue: attributeValue,
assignments: newAssignments
),
changed: changed
)
}
}
Loading

0 comments on commit c2d0512

Please sign in to comment.