From 21f4015a631f999347226031bc3bfce7ad9118f4 Mon Sep 17 00:00:00 2001 From: Volodymyr Nazarkevych Date: Thu, 4 Apr 2024 13:47:33 +0300 Subject: [PATCH 1/2] sticky bucket --- lib/src/Model/context.dart | 11 +++ lib/src/Model/experiment.dart | 15 ++++ lib/src/Model/experiment.g.dart | 5 ++ lib/src/Model/features.dart | 12 ++++ lib/src/Model/features.g.dart | 4 ++ .../Model/sticky_assignments_document.dart | 28 ++++++++ .../Model/sticky_assignments_document.g.dart | 23 ++++++ .../sticky_bucket_service.dart | 70 +++++++++++++++++++ lib/src/growth_book_sdk.dart | 5 ++ 9 files changed, 173 insertions(+) create mode 100644 lib/src/Model/sticky_assignments_document.dart create mode 100644 lib/src/Model/sticky_assignments_document.g.dart create mode 100644 lib/src/StickyBucketService/sticky_bucket_service.dart diff --git a/lib/src/Model/context.dart b/lib/src/Model/context.dart index 0e88a80..4310c53 100644 --- a/lib/src/Model/context.dart +++ b/lib/src/Model/context.dart @@ -1,4 +1,6 @@ import 'package:growthbook_sdk_flutter/growthbook_sdk_flutter.dart'; +import 'package:growthbook_sdk_flutter/src/Model/sticky_assignments_document.dart'; +import 'package:growthbook_sdk_flutter/src/StickyBucketService/sticky_bucket_service.dart'; /// Defines the GrowthBook context. class GBContext { @@ -10,6 +12,9 @@ class GBContext { this.enabled, this.attributes, this.forcedVariation, + this.stickyBucketAssignmentDocs, + this.stickyBucketIdentifierAttributes, + this.stickyBucketService, this.qaMode, this.trackingCallBack, this.features = const {}, @@ -37,6 +42,12 @@ class GBContext { /// Force specific experiments to always assign a specific variation (used for QA). Map? forcedVariation; + Map? stickyBucketAssignmentDocs; + + List? stickyBucketIdentifierAttributes; + + StickyBucketService? stickyBucketService; + /// If true, random assignment is disabled and only explicitly forced variations are used. bool? qaMode; diff --git a/lib/src/Model/experiment.dart b/lib/src/Model/experiment.dart index d1fe921..6f1695f 100644 --- a/lib/src/Model/experiment.dart +++ b/lib/src/Model/experiment.dart @@ -15,11 +15,15 @@ class GBExperiment { this.condition, this.parentConditions, this.hashAttribute, + this.fallbackAttribute, this.weights, this.active = true, this.coverage, this.force, this.hashVersion, + this.disableStickyBucketing, + this.bucketVersion, + this.minBucketVersion, this.ranges, this.meta, this.filters, @@ -40,6 +44,8 @@ class GBExperiment { /// All users included in the experiment will be forced into the specific variation index String? hashAttribute; + String? fallbackAttribute; + /// How to weight traffic between variations. Must add to 1. List? weights; @@ -67,6 +73,12 @@ class GBExperiment { /// The hash version to use (default to 1) int? hashVersion; + bool? disableStickyBucketing; + + int? bucketVersion; + + int? minBucketVersion; + /// Array of ranges, one per variation @Tuple2Converter() List? ranges; @@ -105,6 +117,7 @@ class GBExperimentResult { this.name, this.bucket, this.passthrough, + this.stickyBucketUsed, }); /// Whether or not the user is part of the experiment @@ -139,6 +152,8 @@ class GBExperimentResult { /// Used for holdout groups bool? passthrough; + bool? stickyBucketUsed; + factory GBExperimentResult.fromJson(Map value) => _$GBExperimentResultFromJson(value); } diff --git a/lib/src/Model/experiment.g.dart b/lib/src/Model/experiment.g.dart index 21f0060..c220906 100644 --- a/lib/src/Model/experiment.g.dart +++ b/lib/src/Model/experiment.g.dart @@ -15,11 +15,15 @@ GBExperiment _$GBExperimentFromJson(Map json) => GBExperiment( ?.map((e) => GBParentCondition.fromJson(e as Map)) .toList(), hashAttribute: json['hashAttribute'] as String?, + fallbackAttribute: json['fallbackAttribute'] as String?, weights: json['weights'] as List?, active: json['active'] as bool? ?? true, coverage: (json['coverage'] as num?)?.toDouble(), force: json['force'] as int?, hashVersion: json['hashVersion'] as int?, + disableStickyBucketing: json['disableStickyBucketing'] as bool?, + bucketVersion: json['bucketVersion'] as int?, + minBucketVersion: json['minBucketVersion'] as int?, ranges: (json['ranges'] as List?) ?.map((e) => const Tuple2Converter().fromJson(e as Map)) @@ -48,4 +52,5 @@ GBExperimentResult _$GBExperimentResultFromJson(Map json) => name: json['name'] as String?, bucket: (json['bucket'] as num?)?.toDouble(), passthrough: json['passthrough'] as bool?, + stickyBucketUsed: json['stickyBucketUsed'] as bool?, ); diff --git a/lib/src/Model/features.dart b/lib/src/Model/features.dart index f6e527f..c2a4366 100644 --- a/lib/src/Model/features.dart +++ b/lib/src/Model/features.dart @@ -37,7 +37,11 @@ class GBFeatureRule { this.weights, this.namespace, this.hashAttribute, + this.fallbackAttribute, this.hashVersion, + this.disableStickyBucketing, + this.bucketVersion, + this.minBucketVersion, this.range, this.ranges, this.meta, @@ -78,10 +82,18 @@ class GBFeatureRule { /// What user attribute should be used to assign variations (defaults to id) String? hashAttribute; + String? fallbackAttribute; + // new properties v0.4.0 /// The hash version to use (default to 1) int? hashVersion; + bool? disableStickyBucketing; + + int? bucketVersion; + + int? minBucketVersion; + /// A more precise version of coverage @Tuple2Converter() GBBucketRange? range; diff --git a/lib/src/Model/features.g.dart b/lib/src/Model/features.g.dart index 7aba41b..62b0fbb 100644 --- a/lib/src/Model/features.g.dart +++ b/lib/src/Model/features.g.dart @@ -25,7 +25,11 @@ GBFeatureRule _$GBFeatureRuleFromJson(Map json) => .toList(), namespace: json['namespace'] as List?, hashAttribute: json['hashAttribute'] as String?, + fallbackAttribute: json['fallbackAttribute'] as String?, hashVersion: json['hashVersion'] as int?, + disableStickyBucketing: json['disableStickyBucketing'] as bool?, + bucketVersion: json['bucketVersion'] as int?, + minBucketVersion: json['minBucketVersion'] as int?, range: _$JsonConverterFromJson, Tuple2>( json['range'], const Tuple2Converter().fromJson), diff --git a/lib/src/Model/sticky_assignments_document.dart b/lib/src/Model/sticky_assignments_document.dart new file mode 100644 index 0000000..42e9517 --- /dev/null +++ b/lib/src/Model/sticky_assignments_document.dart @@ -0,0 +1,28 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'sticky_assignments_document.g.dart'; + +@JsonSerializable() +class StickyAssignmentsDocument { + const StickyAssignmentsDocument({ + required this.attributeName, + required this.attributeValue, + required this.assignments, + }); + + final String attributeName; + final String attributeValue; + final StickyAssignments assignments; + + factory StickyAssignmentsDocument.fromJson(Map value) => + _$StickyAssignmentsDocumentFromJson(value); + + Map toJson() => _$StickyAssignmentsDocumentToJson(this); +} + +typedef StickyAssignments = Map; + +typedef StickyExperimentKey = String; // `${experimentId}__{version}` + +typedef StickyAttributeKey = String; // `${attributeName}||${attributeValue}` + diff --git a/lib/src/Model/sticky_assignments_document.g.dart b/lib/src/Model/sticky_assignments_document.g.dart new file mode 100644 index 0000000..e136544 --- /dev/null +++ b/lib/src/Model/sticky_assignments_document.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sticky_assignments_document.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StickyAssignmentsDocument _$StickyAssignmentsDocumentFromJson( + Map json) => + StickyAssignmentsDocument( + attributeName: json['attributeName'] as String, + attributeValue: json['attributeValue'] as String, + assignments: Map.from(json['assignments'] as Map), + ); + +Map _$StickyAssignmentsDocumentToJson( + StickyAssignmentsDocument instance) => + { + 'attributeName': instance.attributeName, + 'attributeValue': instance.attributeValue, + 'assignments': instance.assignments, + }; diff --git a/lib/src/StickyBucketService/sticky_bucket_service.dart b/lib/src/StickyBucketService/sticky_bucket_service.dart new file mode 100644 index 0000000..adac580 --- /dev/null +++ b/lib/src/StickyBucketService/sticky_bucket_service.dart @@ -0,0 +1,70 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:growthbook_sdk_flutter/src/Model/sticky_assignments_document.dart'; + +abstract class StickyBucketService { + Future getAssignments( + String attributeName, String attributeValue); + + Future saveAssignments(StickyAssignmentsDocument doc); + + Future> getAllAssignments( + Map attributes) async { + var docs = {}; + await Future.wait(attributes.entries.map((entry) async { + var doc = await getAssignments(entry.key, entry.value); + if (doc != null) { + var key = '${doc.attributeName}||${doc.attributeValue}'; + docs[key] = doc; + } + })); + return docs; + } +} + +abstract class LocalStorageCompat { + Future getItem(String key); + Future setItem(String key, String value); +} + +class LocalStorageStickyBucketService extends StickyBucketService { + String prefix; + LocalStorageCompat? localStorage; + + LocalStorageStickyBucketService( + {this.prefix = 'gbStickyBuckets__', this.localStorage}); + + @override + Future getAssignments( + String attributeName, String attributeValue) async { + final key = '$attributeName||$attributeValue'; + StickyAssignmentsDocument? doc; + try { + if (localStorage != null) { + final raw = await localStorage!.getItem(prefix + key) ?? '{}'; + final data = json.decode(raw); + if (data['attributeName'] != null && + data['attributeValue'] != null && + data['assignments'] != null) { + doc = StickyAssignmentsDocument.fromJson(data); + } + } + } catch (e) { + // Ignore localStorage errors + } + return doc; + } + + @override + Future saveAssignments(StickyAssignmentsDocument doc) async { + final key = '${doc.attributeName}||${doc.attributeValue}'; + try { + if (localStorage != null) { + await localStorage!.setItem(prefix + key, json.encode(doc.toJson())); + } + } catch (e) { + // Ignore localStorage errors + } + } +} diff --git a/lib/src/growth_book_sdk.dart b/lib/src/growth_book_sdk.dart index 35a26c8..dadd57e 100644 --- a/lib/src/growth_book_sdk.dart +++ b/lib/src/growth_book_sdk.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:growthbook_sdk_flutter/growthbook_sdk_flutter.dart'; +import 'package:growthbook_sdk_flutter/src/Model/sticky_assignments_document.dart'; import 'package:growthbook_sdk_flutter/src/Utils/crypto.dart'; typedef VoidCallback = void Function(); @@ -123,6 +124,10 @@ class GrowthBookSDK extends FeaturesFlowDelegate { ); } + Map getStickyBucketAssignmentDocs() { + return _context.stickyBucketAssignmentDocs ?? {}; + } + /// Replaces the Map of user attributes that are used to assign variations void setAttributes(Map attributes) { context.attributes = attributes; From c691a3d8ecbc9996fccd9b1d8acc4d88ff3be4dd Mon Sep 17 00:00:00 2001 From: Vladyslava Lila Date: Tue, 16 Apr 2024 18:30:31 +0300 Subject: [PATCH 2/2] add functionality to sticky bucketing --- lib/src/Cache/caching_manager.dart | 27 ++++++-- lib/src/Evaluator/feature_evaluator.dart | 51 ++++++++++++++ lib/src/Model/context.dart | 5 +- lib/src/Model/experiment.dart | 11 +++- lib/src/Model/experiment.g.dart | 2 +- lib/src/Model/features.dart | 7 ++ lib/src/Model/features.g.dart | 1 + lib/src/Model/gb_parent_condition.dart | 7 +- lib/src/Model/gb_parent_condition.g.dart | 2 +- .../sticky_bucket_service.dart | 47 +++++++------ lib/src/Utils/gb_utils.dart | 66 ++++++++++++++----- lib/src/growth_book_sdk.dart | 23 ++++++- 12 files changed, 191 insertions(+), 58 deletions(-) diff --git a/lib/src/Cache/caching_manager.dart b/lib/src/Cache/caching_manager.dart index b097106..be1dff1 100644 --- a/lib/src/Cache/caching_manager.dart +++ b/lib/src/Cache/caching_manager.dart @@ -2,7 +2,14 @@ import 'dart:developer'; import 'package:growthbook_sdk_flutter/growthbook_sdk_flutter.dart'; -class CachingManager { +abstract class CachingLayer { + + GBFeatures? getContent(String key); + void saveContent(String key, String value); +} + + +class CachingManager extends CachingLayer { // Create a static and final instance of the CachingManager class. static final CachingManager _instance = CachingManager._internal(); @@ -17,17 +24,29 @@ class CachingManager { // A map to hold the cached data. final Map _cache = {}; + getData(String fileName){ + return getContent(fileName); + } + + void putData(String fileName, dynamic content){ + saveContent(fileName, content); + } + // Method to put data into the cache. - void putData(String key, dynamic data) { + @override + void saveContent(String key, dynamic value) { // Store the data in the cache using the key. - _cache[key] = data; + _cache[key] = value; log("_cache[key] ${_cache[key]}"); final test = _cache[key]; log(test.runtimeType.toString()); } + + // Method to get data from the cache. - GBFeatures? getData(String key) { + @override + GBFeatures? getContent(String key) { // Retrieve the data from the cache using the key. return _cache[key]; } diff --git a/lib/src/Evaluator/feature_evaluator.dart b/lib/src/Evaluator/feature_evaluator.dart index 6d1f1c7..29af3be 100644 --- a/lib/src/Evaluator/feature_evaluator.dart +++ b/lib/src/Evaluator/feature_evaluator.dart @@ -5,6 +5,10 @@ import 'package:growthbook_sdk_flutter/growthbook_sdk_flutter.dart'; /// Returns Calculated Feature Result against that key class GBFeatureEvaluator { + final Map attributeOverrides; + + GBFeatureEvaluator({required this.attributeOverrides}); + static GBFeatureResult evaluateFeature(GBContext context, String featureKey) { /// If we are not able to find feature on the basis of the passed featureKey /// then we are going to return unKnownFeature. @@ -158,4 +162,51 @@ class GBFeatureEvaluator { experiment: experiment, experimentResult: experimentResult); } + + Future refreshStickyBuckets(GBContext context) async { + if (context.stickyBucketService == null) { + return; + } + + var attributes = getStickyBucketAttributes(context); + context.stickyBucketAssignmentDocs = + await context.stickyBucketService?.getAllAssignments(attributes); + } + + Map getStickyBucketAttributes(GBContext context) { + var attributes = {}; + + context.stickyBucketIdentifierAttributes = + context.stickyBucketIdentifierAttributes != null + ? deriveStickyBucketIdentifierAttributes(context) + : context.stickyBucketIdentifierAttributes; + + context.stickyBucketIdentifierAttributes?.forEach((attr) { + var hashValue = GBUtils.getHashAttribute( + context: context, attributeOverrides: attributeOverrides, attr: attr); + attributes[attr] = hashValue[1]; + }); + + return attributes; + } + + List deriveStickyBucketIdentifierAttributes( + GBContext context) { + var attributes = {}; + var features = context.features; + for (var id in features.keys) { + var feature = features[id]; + var rules = feature?.rules; + rules?.forEach((rule) { + var variations = rule.variations; + variations?.forEach((variation) { + attributes.add(rule.hashAttribute ?? "id"); + if (rule.fallbackAttribute != null) { + attributes.add(rule.fallbackAttribute!); + } + }); + }); + } + return attributes.toList(); + } } diff --git a/lib/src/Model/context.dart b/lib/src/Model/context.dart index 21220d3..1d3449a 100644 --- a/lib/src/Model/context.dart +++ b/lib/src/Model/context.dart @@ -14,10 +14,11 @@ class GBContext { this.stickyBucketAssignmentDocs, this.stickyBucketIdentifierAttributes, this.stickyBucketService, + this.remoteEval, this.qaMode, this.trackingCallBack, this.features = const {}, - this.backgroundSync, + this.backgroundSync }); /// Registered API key for GrowthBook SDK. @@ -44,6 +45,8 @@ class GBContext { StickyBucketService? stickyBucketService; + bool? remoteEval; + /// If true, random assignment is disabled and only explicitly forced variations are used. bool? qaMode; diff --git a/lib/src/Model/experiment.dart b/lib/src/Model/experiment.dart index 6f1695f..9da9c76 100644 --- a/lib/src/Model/experiment.dart +++ b/lib/src/Model/experiment.dart @@ -44,6 +44,7 @@ class GBExperiment { /// All users included in the experiment will be forced into the specific variation index String? hashAttribute; + /// When using sticky bucketing, can be used as a fallback to assign variations String? fallbackAttribute; /// How to weight traffic between variations. Must add to 1. @@ -71,12 +72,15 @@ class GBExperiment { //new properties v0.4.0 /// The hash version to use (default to 1) - int? hashVersion; + double? hashVersion; + /// If true, sticky bucketing will be disabled for this experiment. (Note: sticky bucketing is only available if a StickyBucketingService is provided in the Context) bool? disableStickyBucketing; + /// An sticky bucket version number that can be used to force a re-bucketing of users (default to `0`) int? bucketVersion; - + + /// Any users with a sticky bucket version less than this will be excluded from the experiment int? minBucketVersion; /// Array of ranges, one per variation @@ -129,6 +133,7 @@ class GBExperimentResult { /// The array value of the assigned variation dynamic value; + /// If a hash was used to assign a variation bool? hashUsed; /// The user attribute used to assign a variation @@ -137,6 +142,7 @@ class GBExperimentResult { /// The value of that attribute String? hashValue; + /// The id of the feature (if any) that the experiment came from String? featureId; //new properties v0.4.0 @@ -152,6 +158,7 @@ class GBExperimentResult { /// Used for holdout groups bool? passthrough; + /// If sticky bucketing was used to assign a variation bool? stickyBucketUsed; factory GBExperimentResult.fromJson(Map value) => diff --git a/lib/src/Model/experiment.g.dart b/lib/src/Model/experiment.g.dart index c220906..e7d603e 100644 --- a/lib/src/Model/experiment.g.dart +++ b/lib/src/Model/experiment.g.dart @@ -20,7 +20,7 @@ GBExperiment _$GBExperimentFromJson(Map json) => GBExperiment( active: json['active'] as bool? ?? true, coverage: (json['coverage'] as num?)?.toDouble(), force: json['force'] as int?, - hashVersion: json['hashVersion'] as int?, + hashVersion: (json['hashVersion'] as num?)?.toDouble(), disableStickyBucketing: json['disableStickyBucketing'] as bool?, bucketVersion: json['bucketVersion'] as int?, minBucketVersion: json['minBucketVersion'] as int?, diff --git a/lib/src/Model/features.dart b/lib/src/Model/features.dart index c2a4366..11a3df2 100644 --- a/lib/src/Model/features.dart +++ b/lib/src/Model/features.dart @@ -29,6 +29,7 @@ class GBFeature { @JsonSerializable(createToJson: false) class GBFeatureRule { GBFeatureRule({ + this.id, this.condition, this.coverage, this.force, @@ -53,6 +54,8 @@ class GBFeatureRule { this.parentConditions, }); + String? id; + /// Optional targeting condition GBCondition? condition; @@ -82,16 +85,20 @@ class GBFeatureRule { /// What user attribute should be used to assign variations (defaults to id) String? hashAttribute; + /// When using sticky bucketing, can be used as a fallback to assign variations String? fallbackAttribute; // new properties v0.4.0 /// The hash version to use (default to 1) int? hashVersion; + /// If true, sticky bucketing will be disabled for this experiment. (Note: sticky bucketing is only available if a StickyBucketingService is provided in the Context) bool? disableStickyBucketing; + /// An sticky bucket version number that can be used to force a re-bucketing of users (default to '0') int? bucketVersion; + /// Any users with a sticky bucket version less than this will be excluded from the experiment int? minBucketVersion; /// A more precise version of coverage diff --git a/lib/src/Model/features.g.dart b/lib/src/Model/features.g.dart index 62b0fbb..0b5358c 100644 --- a/lib/src/Model/features.g.dart +++ b/lib/src/Model/features.g.dart @@ -15,6 +15,7 @@ GBFeature _$GBFeatureFromJson(Map json) => GBFeature( GBFeatureRule _$GBFeatureRuleFromJson(Map json) => GBFeatureRule( + id: json['id'] as String?, condition: json['condition'], coverage: (json['coverage'] as num?)?.toDouble(), force: json['force'], diff --git a/lib/src/Model/gb_parent_condition.dart b/lib/src/Model/gb_parent_condition.dart index 2a1c56c..8ce8c4f 100644 --- a/lib/src/Model/gb_parent_condition.dart +++ b/lib/src/Model/gb_parent_condition.dart @@ -1,4 +1,3 @@ -import 'package:growthbook_sdk_flutter/growthbook_sdk_flutter.dart'; import 'package:json_annotation/json_annotation.dart'; part 'gb_parent_condition.g.dart'; @@ -6,9 +5,9 @@ part 'gb_parent_condition.g.dart'; @JsonSerializable(createToJson: false) class GBParentCondition { - String id; - GBCondition condition; - bool? gate; + final String id; + final Map condition; + final bool? gate; GBParentCondition({required this.id, required this.condition, this.gate}); diff --git a/lib/src/Model/gb_parent_condition.g.dart b/lib/src/Model/gb_parent_condition.g.dart index 3910aa0..ba433bb 100644 --- a/lib/src/Model/gb_parent_condition.g.dart +++ b/lib/src/Model/gb_parent_condition.g.dart @@ -9,6 +9,6 @@ part of 'gb_parent_condition.dart'; GBParentCondition _$GBParentConditionFromJson(Map json) => GBParentCondition( id: json['id'] as String, - condition: json['condition'] as Object, + condition: json['condition'] as Map, gate: json['gate'] as bool?, ); diff --git a/lib/src/StickyBucketService/sticky_bucket_service.dart b/lib/src/StickyBucketService/sticky_bucket_service.dart index adac580..4dbc6d7 100644 --- a/lib/src/StickyBucketService/sticky_bucket_service.dart +++ b/lib/src/StickyBucketService/sticky_bucket_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:growthbook_sdk_flutter/src/Cache/caching_manager.dart'; import 'package:growthbook_sdk_flutter/src/Model/sticky_assignments_document.dart'; abstract class StickyBucketService { @@ -10,30 +11,15 @@ abstract class StickyBucketService { Future saveAssignments(StickyAssignmentsDocument doc); Future> getAllAssignments( - Map attributes) async { - var docs = {}; - await Future.wait(attributes.entries.map((entry) async { - var doc = await getAssignments(entry.key, entry.value); - if (doc != null) { - var key = '${doc.attributeName}||${doc.attributeValue}'; - docs[key] = doc; - } - })); - return docs; - } -} - -abstract class LocalStorageCompat { - Future getItem(String key); - Future setItem(String key, String value); + Map attributes); } class LocalStorageStickyBucketService extends StickyBucketService { String prefix; - LocalStorageCompat? localStorage; + CachingManager? localStorage; LocalStorageStickyBucketService( - {this.prefix = 'gbStickyBuckets__', this.localStorage}); + {this.prefix = 'gbStickyBuckets__', this.localStorage}); @override Future getAssignments( @@ -42,12 +28,9 @@ class LocalStorageStickyBucketService extends StickyBucketService { StickyAssignmentsDocument? doc; try { if (localStorage != null) { - final raw = await localStorage!.getItem(prefix + key) ?? '{}'; - final data = json.decode(raw); - if (data['attributeName'] != null && - data['attributeValue'] != null && - data['assignments'] != null) { - doc = StickyAssignmentsDocument.fromJson(data); + final data = localStorage!.getContent('$prefix$key'); + if(data != null){ + doc = StickyAssignmentsDocument.fromJson(data); } } } catch (e) { @@ -61,10 +44,24 @@ class LocalStorageStickyBucketService extends StickyBucketService { final key = '${doc.attributeName}||${doc.attributeValue}'; try { if (localStorage != null) { - await localStorage!.setItem(prefix + key, json.encode(doc.toJson())); + localStorage!.saveContent('$prefix$key', json.encode(doc.toJson())); } } catch (e) { // Ignore localStorage errors } } + + @override + Future> getAllAssignments( + Map attributes) async { + Map docs = {}; + attributes.forEach((key, value) async { + var doc = await getAssignments(key, value); + if (doc != null) { + String docKey = '${doc.attributeName}||${doc.attributeValue}'; + docs[docKey] = doc; + } + }); + return docs; + } } diff --git a/lib/src/Utils/gb_utils.dart b/lib/src/Utils/gb_utils.dart index 30b47f2..32bad32 100644 --- a/lib/src/Utils/gb_utils.dart +++ b/lib/src/Utils/gb_utils.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:growthbook_sdk_flutter/src/Model/context.dart'; import 'package:growthbook_sdk_flutter/src/Utils/utils.dart'; /// Fowler-Noll-Vo hash - 32 bit @@ -71,28 +72,25 @@ class GBUtils { } /// Returns an array of double with numVariations items that are all equal and - /// sum to 1. For example, getEqualWeights(2) would return [0.5, 0.5]. - static List getEqualWeights(int numVariations) { - List weights = []; - - if (numVariations >= 1) { - weights = List.filled(numVariations, 1 / numVariations); - } + /// sum to 1. For example, getEqualWeights(2) would return [0.5, 0.5] - return weights; + static List getEqualWeights(int numVariations) { + if (numVariations <= 0) return []; + return List.filled(numVariations, 1.0 / numVariations); } ///This converts and experiment's coverage and variation weights into an array /// of bucket ranges. static List getBucketRanges( - int numVariations, double coverage, List weights) { + int numVariations, double coverage, List? weights) { // Clamp the value of coverage to between 0 and 1 inclusive. double targetCoverage = coverage.clamp(0, 1); // Default to equal weights if the weights don't match the number of variations. - var targetWeights = weights; - if (weights.length != numVariations) { - targetWeights = getEqualWeights(numVariations); + final equal = getEqualWeights(numVariations); + var targetWeights = weights ?? equal; + if (targetWeights.length != numVariations) { + targetWeights = equal; } // Default to equal weights if the sum is not equal 1 (or close enough when @@ -100,7 +98,7 @@ class GBUtils { final weightsSum = targetWeights.fold(0, (prev, element) => prev + element); if (weightsSum < 0.99 || weightsSum > 1.01) { - targetWeights = getEqualWeights(numVariations); + targetWeights = equal; } // Convert weights to ranges and return @@ -126,12 +124,10 @@ class GBUtils { } int chooseVariation(double n, List ranges) { - var counter = 0; - for (final range in ranges) { - if (n >= range.item1 && n < range.item2) { - return counter; + for (int index = 0; index < ranges.length; index++) { + if (inRange(n, ranges[index])) { + return index; } - counter++; } return -1; } @@ -237,4 +233,38 @@ class GBUtils { // Then, join back together into a single string return parts.join('-'); } + + static List getHashAttribute({ + required GBContext context, + String? attr, + String? fallback, + required Map attributeOverrides, + }) { + String hashAttribute = attr ?? 'id'; + String hashValue = ''; + + if (attributeOverrides.containsKey(hashAttribute) && + attributeOverrides[hashAttribute] != null) { + hashValue = attributeOverrides[hashAttribute]; + } else if (context.attributes!.containsKey(hashAttribute) && + context.attributes?[hashAttribute] != null) { + hashValue = context.attributes?[hashAttribute]; + } + + if (hashValue.isEmpty && fallback != null) { + if (attributeOverrides.containsKey(fallback) && + attributeOverrides[fallback] != null) { + hashValue = attributeOverrides[fallback]; + } else if (context.attributes!.containsKey(fallback) && + context.attributes?[fallback] != null) { + hashValue = context.attributes?[fallback]; + } + + if (hashValue.isNotEmpty) { + hashAttribute = fallback; + } + } + + return [hashAttribute, hashValue]; + } } diff --git a/lib/src/growth_book_sdk.dart b/lib/src/growth_book_sdk.dart index e6b7128..0a86dcf 100644 --- a/lib/src/growth_book_sdk.dart +++ b/lib/src/growth_book_sdk.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:growthbook_sdk_flutter/growthbook_sdk_flutter.dart'; import 'package:growthbook_sdk_flutter/src/Model/sticky_assignments_document.dart'; @@ -60,6 +61,7 @@ class GBSDKBuilderApp { refreshHandler: refreshHandler, ); await gb.refresh(); + await gb.refreshStickyBucketService(); return gb; } @@ -81,7 +83,8 @@ class GrowthBookSDK extends FeaturesFlowDelegate { }) : _context = context, _onInitializationFailure = onInitializationFailure, _refreshHandler = refreshHandler, - _baseClient = client ?? DioClient(); + _baseClient = client ?? DioClient(), + _attributeOverrides = {}; final GBContext _context; @@ -91,6 +94,8 @@ class GrowthBookSDK extends FeaturesFlowDelegate { final CacheRefreshHandler? _refreshHandler; + Map _attributeOverrides; + /// The complete data regarding features & attributes etc. GBContext get context => _context; @@ -136,7 +141,8 @@ class GrowthBookSDK extends FeaturesFlowDelegate { ); } - Map getStickyBucketAssignmentDocs() { + Map + getStickyBucketAssignmentDocs() { return _context.stickyBucketAssignmentDocs ?? {}; } @@ -145,6 +151,11 @@ class GrowthBookSDK extends FeaturesFlowDelegate { context.attributes = attributes; } + void setAttributeOverrides(dynamic overrides) { + _attributeOverrides = json.decode(overrides); + refreshStickyBucketService(); + } + void setEncryptedFeatures(String encryptedString, String encryptionKey, [CryptoProtocol? subtle]) { CryptoProtocol crypto = subtle ?? Crypto(); @@ -157,4 +168,12 @@ class GrowthBookSDK extends FeaturesFlowDelegate { _context.features = features; } } + + Future refreshStickyBucketService() async { + if (context.stickyBucketService != null) { + final featureEvaluator = + GBFeatureEvaluator(attributeOverrides: _attributeOverrides); + await featureEvaluator.refreshStickyBuckets(context); + } + } }