diff --git a/lib/src/Evaluator/feature_evaluator.dart b/lib/src/Evaluator/feature_evaluator.dart index c7c8023..1b30b70 100644 --- a/lib/src/Evaluator/feature_evaluator.dart +++ b/lib/src/Evaluator/feature_evaluator.dart @@ -18,17 +18,23 @@ class FeatureEvaluator { required this.featureKey, required this.attributeOverrides, FeatureEvalContext? evalContext, - }) : evalContext = - evalContext ?? FeatureEvalContext(evaluatedFeatures: {}); + }) : evalContext = evalContext ?? FeatureEvalContext(evaluatedFeatures: {}); /// Takes context and feature key and returns the calculated feature result against that key. GBFeatureResult evaluateFeature() { + /// This callback serves for listening for feature usage events + final onFeatureUsageCallback = context.featureUsageCallback; + // Check if the feature has been evaluated already and return early if it has if (evalContext?.evaluatedFeatures.contains(featureKey) ?? false) { - return prepareResult( + final featureResultWhenCircularDependencyDetected = prepareResult( value: null, source: GBFeatureSource.cyclicPrerequisite, ); + + onFeatureUsageCallback?.call(featureKey, featureResultWhenCircularDependencyDetected); + + return featureResultWhenCircularDependencyDetected; } evalContext?.evaluatedFeatures.add(featureKey); @@ -39,10 +45,13 @@ class FeatureEvaluator { // If the targetFeature is not found, return a result with null value and unknown feature source if (targetFeature == null) { - return prepareResult( + final emptyFeatureResult = prepareResult( value: null, source: GBFeatureSource.unknownFeature, ); + + onFeatureUsageCallback?.call(featureKey, emptyFeatureResult); + return emptyFeatureResult; } if (targetFeature.rules != null && targetFeature.rules!.isNotEmpty) { @@ -64,10 +73,14 @@ class FeatureEvaluator { // Check if the source of the parent result is cyclic prerequisite if (parentResult.source == GBFeatureSource.cyclicPrerequisite) { - return prepareResult( + final featureResultWhenCircularDependencyDetected = prepareResult( value: null, // Corresponds to .null in Swift source: GBFeatureSource.cyclicPrerequisite, ); + + onFeatureUsageCallback?.call(featureKey, featureResultWhenCircularDependencyDetected); + + return featureResultWhenCircularDependencyDetected; } // Create a map with the parent result value for evaluation @@ -84,10 +97,14 @@ class FeatureEvaluator { // Check if there is a gate in the parent condition if (parentCondition.gate != null) { log('Feature blocked by prerequisite'); - return prepareResult( + final featureResultWhenBlockedByPrerequisite = prepareResult( value: null, // Corresponds to .null in Swift source: GBFeatureSource.prerequisite, ); + + onFeatureUsageCallback?.call(featureKey, featureResultWhenBlockedByPrerequisite); + + return featureResultWhenBlockedByPrerequisite; } // Non-blocking prerequisite evaluation failed; continue to the next rule @@ -96,8 +113,7 @@ class FeatureEvaluator { } } if (rule.filters != null) { - if (GBUtils.isFilteredOut( - rule.filters!, context, attributeOverrides)) { + if (GBUtils.isFilteredOut(rule.filters!, context, attributeOverrides)) { log('Skip rule because of filters'); continue; // Skip to the next rule } @@ -119,8 +135,7 @@ class FeatureEvaluator { attributeOverrides, rule.seed ?? featureKey, rule.hashAttribute, - (context.stickyBucketService != null && - (rule.disableStickyBucketing != true)) + (context.stickyBucketService != null && (rule.disableStickyBucketing != true)) ? rule.fallbackAttribute : null, rule.range, @@ -137,10 +152,8 @@ class FeatureEvaluator { // Handle tracks if present if (rule.tracks != null) { for (var track in rule.tracks!) { - if (!ExperimentHelper.shared - .isTracked(track.experiment, track.experimentResult)) { - context.trackingCallBack!( - track.experiment, track.experimentResult); + if (!ExperimentHelper.shared.isTracked(track.experiment, track.experimentResult)) { + context.trackingCallBack!(track.experiment, track.experimentResult); } } } @@ -158,9 +171,7 @@ class FeatureEvaluator { } // Compute the hash using the Fowler-Noll-Vo algorithm (fnv32-1a) - double hashFNV = GBUtils.hash( - seed: featureKey, value: attributeValue, version: 1) ?? - 0.0; + double hashFNV = GBUtils.hash(seed: featureKey, value: attributeValue, version: 1) ?? 0.0; // If the computed hash value is greater than rule.coverage, skip the rule if (hashFNV > rule.coverage!) { @@ -168,9 +179,9 @@ class FeatureEvaluator { } } } - - return prepareResult( - value: rule.force!, source: GBFeatureSource.force); + final forcedFeatureResult = prepareResult(value: rule.force!, source: GBFeatureSource.force); + onFeatureUsageCallback?.call(featureKey, forcedFeatureResult); + return forcedFeatureResult; } else { if (rule.variations == null) { // If not, skip this rule @@ -197,27 +208,28 @@ class FeatureEvaluator { name: rule.name, phase: rule.phase, ); - GBExperimentResult result = - ExperimentEvaluator(attributeOverrides: attributeOverrides) - .evaluateExperiment(context, exp, featureId: featureKey); + GBExperimentResult result = ExperimentEvaluator(attributeOverrides: attributeOverrides) + .evaluateExperiment(context, exp, featureId: featureKey); // Check if the result is in the experiment and not a passthrough if (result.inExperiment && !(result.passthrough ?? false)) { // Return the result value and source if the result is successful - return prepareResult( + final experimentFeatureResult = prepareResult( value: result.value, source: GBFeatureSource.experiment, experiment: exp, result: result, ); + onFeatureUsageCallback?.call(featureKey, experimentFeatureResult); + return experimentFeatureResult; } } } } } - return prepareResult( - value: targetFeature.defaultValue, - source: GBFeatureSource.defaultValue); + final defaultFeatureResult = prepareResult(value: targetFeature.defaultValue, source: GBFeatureSource.defaultValue); + onFeatureUsageCallback?.call(featureKey, defaultFeatureResult); + return defaultFeatureResult; } GBFeatureResult prepareResult({ diff --git a/lib/src/Model/context.dart b/lib/src/Model/context.dart index 9b615ff..9750c64 100644 --- a/lib/src/Model/context.dart +++ b/lib/src/Model/context.dart @@ -17,6 +17,7 @@ class GBContext { this.remoteEval = false, this.qaMode = false, this.trackingCallBack, + this.featureUsageCallback, this.features = const {}, this.backgroundSync = false, }); @@ -53,6 +54,9 @@ class GBContext { /// A function that takes experiment and result as arguments. TrackingCallBack? trackingCallBack; + /// A callback that will be invoked every time a feature is viewed. Listen for feature usage events + GBFeatureUsageCallback? featureUsageCallback; + /// Keys are unique identifiers for the features and the values are Feature objects. /// Feature definitions - To be pulled from API / Cache GBFeatures features; diff --git a/lib/src/Utils/constant.dart b/lib/src/Utils/constant.dart index ff42177..054c5d1 100644 --- a/lib/src/Utils/constant.dart +++ b/lib/src/Utils/constant.dart @@ -42,6 +42,8 @@ typedef GBStickyBucketingService = LocalStorageStickyBucketService; /// A function that takes experiment and result as arguments. typedef TrackingCallBack = void Function(GBExperiment, GBExperimentResult); +typedef GBFeatureUsageCallback = void Function(String, GBFeatureResult); + /// GrowthBook Error Class to handle any error / exception scenario class GBError { diff --git a/lib/src/growth_book_sdk.dart b/lib/src/growth_book_sdk.dart index bc42966..71fc3ca 100644 --- a/lib/src/growth_book_sdk.dart +++ b/lib/src/growth_book_sdk.dart @@ -49,6 +49,7 @@ class GBSDKBuilderApp { CacheRefreshHandler? refreshHandler; StickyBucketService? stickyBucketService; + GBFeatureUsageCallback? featureUsageCallback; Future initialize() async { final gbContext = GBContext( @@ -60,6 +61,7 @@ class GBSDKBuilderApp { attributes: attributes, forcedVariation: forcedVariations, trackingCallBack: growthBookTrackingCallBack, + featureUsageCallback: featureUsageCallback, features: gbFeatures, stickyBucketService: stickyBucketService, backgroundSync: backgroundSync, @@ -86,6 +88,12 @@ class GBSDKBuilderApp { this.stickyBucketService = stickyBucketService; return this; } + + /// Setter for featureUsageCallback. A callback that will be invoked every time a feature is viewed. + GBSDKBuilderApp setFeatureUsageCallback(GBFeatureUsageCallback featureUsageCallback) { + this.featureUsageCallback = featureUsageCallback; + return this; + } } /// The main export of the libraries is a simple GrowthBook wrapper class that diff --git a/test/common_test/gb_feature_value_test.dart b/test/common_test/gb_feature_value_test.dart index ad9717b..3aa2a64 100644 --- a/test/common_test/gb_feature_value_test.dart +++ b/test/common_test/gb_feature_value_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:growthbook_sdk_flutter/growthbook_sdk_flutter.dart'; import '../Helper/gb_test_helper.dart'; +import '../mocks/network_mock.dart'; void main() { group('Feature Evaluator', () { @@ -17,8 +18,6 @@ void main() { final passedScenarios = []; for (final item in evaluateCondition) { - - final testData = GBFeaturesTest.fromMap(item[1]); final gbContext = GBContext( @@ -34,11 +33,8 @@ void main() { gbContext.features = testData.features!; } - final result = FeatureEvaluator( - attributeOverrides: {}, - context: gbContext, - featureKey: item[2]) - .evaluateFeature(); + final result = + FeatureEvaluator(attributeOverrides: {}, context: gbContext, featureKey: item[2]).evaluateFeature(); final expectedResult = GBFeatureResultTest.fromMap(item[3]); final status = @@ -48,8 +44,7 @@ void main() { result.off.toString() == expectedResult.off.toString() && result.source?.name.toString() == expectedResult.source && result.experiment?.key == expectedResult.experiment?.key && - result.experimentResult?.variationID == - expectedResult.experimentResult?.variationId) { + result.experimentResult?.variationID == expectedResult.experimentResult?.variationId) { passedScenarios.add(status); } else { failedScenarios.add(status); @@ -57,9 +52,80 @@ void main() { } index++; } - customLogger( - 'Passed Test ${passedScenarios.length} out of ${evaluateCondition.length}'); + customLogger('Passed Test ${passedScenarios.length} out of ${evaluateCondition.length}'); expect(failedScenarios.length, 0); }); + + test('Whether featureUsageCallback is called', () async { + const expectedNumberOfOnFeatureUsageCalls = 1; + int actualNumberOfOnFeatureUsageCalls = 0; + + const testApiKey = ''; + const attr = {}; + const testHostURL = 'https://example.growthbook.io/'; + + final gbBuilder = GBSDKBuilderApp( + apiKey: testApiKey, + hostURL: testHostURL, + attributes: attr, + client: const MockNetworkClient(), + growthBookTrackingCallBack: (exp, res) {}, + backgroundSync: false, + ); + + gbBuilder.setFeatureUsageCallback((_, __) { + actualNumberOfOnFeatureUsageCalls++; + }); + + final sdk = await gbBuilder.initialize(); + + for (final item in evaluateCondition) { + if (item is List) { + sdk.feature(item[2]); + break; + } + } + + expect(expectedNumberOfOnFeatureUsageCalls, actualNumberOfOnFeatureUsageCalls); + }); + + test('Whether featureUsageCallback is called on context level', () { + const expectedNumberOfOnFeatureUsageCalls = 1; + var actualNumberOfOnFeatureUsageCalls = 0; + + for (final item in evaluateCondition) { + if (item is List) { + final testData = GBFeaturesTest.fromMap(item[1]); + final attributes = Map.from(testData.attributes ?? {}); + + final gbContext = GBContext( + apiKey: '', + hostURL: '', + enabled: true, + attributes: attributes, + forcedVariation: {}, + qaMode: false, + trackingCallBack: (_, __) {}, + featureUsageCallback: (_, __) { + actualNumberOfOnFeatureUsageCalls++; + }, + encryptionKey: '', + ); + if (testData.features != null) { + gbContext.features = testData.features!; + } + + if (testData.forcedVariations != null) { + gbContext.forcedVariation = testData.forcedVariations!; + } + + final evaluator = FeatureEvaluator(context: gbContext, featureKey: item[2], attributeOverrides: attributes); + evaluator.evaluateFeature(); + + expect(expectedNumberOfOnFeatureUsageCalls, actualNumberOfOnFeatureUsageCalls); + break; + } + } + }); }); }