Skip to content

Commit

Permalink
feature usage callback
Browse files Browse the repository at this point in the history
Add feature Usage Callback
Add tests for featureUsageCallback
  • Loading branch information
vazarkevych committed May 16, 2024
1 parent 3947e21 commit 175e466
Show file tree
Hide file tree
Showing 5 changed files with 130 additions and 38 deletions.
66 changes: 39 additions & 27 deletions lib/src/Evaluator/feature_evaluator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,23 @@ class FeatureEvaluator {
required this.featureKey,
required this.attributeOverrides,
FeatureEvalContext? evalContext,
}) : evalContext =
evalContext ?? FeatureEvalContext(evaluatedFeatures: <String>{});
}) : evalContext = evalContext ?? FeatureEvalContext(evaluatedFeatures: <String>{});

/// 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);
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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,
Expand All @@ -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);
}
}
}
Expand All @@ -158,19 +171,17 @@ 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!) {
continue ruleLoop; // Skip the current rule
}
}
}

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
Expand All @@ -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({
Expand Down
4 changes: 4 additions & 0 deletions lib/src/Model/context.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class GBContext {
this.remoteEval = false,
this.qaMode = false,
this.trackingCallBack,
this.featureUsageCallback,
this.features = const <String, GBFeature>{},
this.backgroundSync = false,
});
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions lib/src/Utils/constant.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions lib/src/growth_book_sdk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class GBSDKBuilderApp {

CacheRefreshHandler? refreshHandler;
StickyBucketService? stickyBucketService;
GBFeatureUsageCallback? featureUsageCallback;

Future<GrowthBookSDK> initialize() async {
final gbContext = GBContext(
Expand All @@ -60,6 +61,7 @@ class GBSDKBuilderApp {
attributes: attributes,
forcedVariation: forcedVariations,
trackingCallBack: growthBookTrackingCallBack,
featureUsageCallback: featureUsageCallback,
features: gbFeatures,
stickyBucketService: stickyBucketService,
backgroundSync: backgroundSync,
Expand All @@ -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
Expand Down
88 changes: 77 additions & 11 deletions test/common_test/gb_feature_value_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand All @@ -17,8 +18,6 @@ void main() {
final passedScenarios = <String>[];

for (final item in evaluateCondition) {


final testData = GBFeaturesTest.fromMap(item[1]);

final gbContext = GBContext(
Expand All @@ -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 =
Expand All @@ -48,18 +44,88 @@ 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);
failedIndex.add(index);
}
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 = '<API_KEY>';
const attr = <String, String>{};
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<dynamic>) {
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<dynamic>) {
final testData = GBFeaturesTest.fromMap(item[1]);
final attributes = Map<String, dynamic>.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;
}
}
});
});
}

0 comments on commit 175e466

Please sign in to comment.