Skip to content

Commit

Permalink
feat: WIP implementing solution. Tests are
Browse files Browse the repository at this point in the history
still in progress too.
  • Loading branch information
mikechu-optimizely committed Oct 21, 2024
1 parent 74d98f7 commit a866266
Show file tree
Hide file tree
Showing 3 changed files with 310 additions and 245 deletions.
5 changes: 5 additions & 0 deletions OptimizelySDK.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,15 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=Interfaces/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="I" Suffix="" Style="AaBb" /&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=LocalConstants/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateStaticReadonly/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=15b5b1f1_002D457c_002D4ca6_002Db278_002D5615aedc07d3/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static readonly fields (private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="READONLY_FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=8b8504e3_002Df0be_002D4c14_002D9103_002Dc732f2bddc15/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Any" AccessRightKinds="Any" Description="Enum members"&gt;&lt;ElementKinds&gt;&lt;Kind Name="ENUM_MEMBER" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"&gt;&lt;ExtraRule Prefix="" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=a4f433b8_002Dabcd_002D4e55_002Da08f_002D82e78cef0f0c/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Any" AccessRightKinds="Any" Description="Local constants"&gt;&lt;ElementKinds&gt;&lt;Kind Name="LOCAL_CONSTANT" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=a7a3339e_002D4e89_002D4319_002D9735_002Da9dc4cb74cc7/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Any" AccessRightKinds="Any" Description="Interfaces"&gt;&lt;ElementKinds&gt;&lt;Kind Name="INTERFACE" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="I" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpKeepExistingMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpPlaceEmbeddedOnSameLineMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpUseContinuousIndentInsideBracesMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EPredefinedNamingRulesToUserRulesUpgrade/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Bucketer/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ODP_0027s/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Optly/@EntryIndexedValue">True</s:Boolean>
Expand Down
204 changes: 120 additions & 84 deletions OptimizelySDK/Bucketing/DecisionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public class DecisionService
private Bucketer Bucketer;
private IErrorHandler ErrorHandler;
private UserProfileService UserProfileService;
private ILogger Logger;
private static ILogger Logger;

/// <summary>
/// Associative array of user IDs to an associative array
Expand Down Expand Up @@ -85,9 +85,9 @@ public DecisionService(Bucketer bucketer, IErrorHandler errorHandler,
/// <summary>
/// Get a Variation of an Experiment for a user to be allocated into.
/// </summary>
/// <param name = "experiment" > The Experiment the user will be bucketed into.</param>
/// <param name = "user" > Optimizely user context.
/// <param name = "config" > Project config.</param>
/// <param name="experiment">The Experiment the user will be bucketed into.</param>
/// <param name="user">Optimizely user context.</param>
/// <param name="config">Project config.</param>
/// <returns>The Variation the user is allocated into.</returns>
public virtual Result<Variation> GetVariation(Experiment experiment,
OptimizelyUserContext user,
Expand All @@ -100,24 +100,72 @@ ProjectConfig config
/// <summary>
/// Get a Variation of an Experiment for a user to be allocated into.
/// </summary>
/// <param name = "experiment" > The Experiment the user will be bucketed into.</param>
/// <param name = "user" > optimizely user context.
/// <param name = "config" > Project Config.</param>
/// <param name = "options" >An array of decision options.</param>
/// <returns>The Variation the user is allocated into.</returns>
/// <param name="experiment">The Experiment the user will be bucketed into.</param>
/// <param name="user">Optimizely user context.</param>
/// <param name="config">Project Config.</param>
/// <param name="options">An array of decision options.</param>
/// <returns></returns>
public virtual Result<Variation> GetVariation(Experiment experiment,
OptimizelyUserContext user,
ProjectConfig config,
OptimizelyDecideOption[] options
)
{
var reasons = new DecisionReasons();
var userId = user.GetUserId();

var ignoreUps = options.Contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE);
UserProfileTracker userProfileTracker = null;

if (UserProfileService != null && !ignoreUps)
{
var userProfile = GetUserProfile(user.GetUserId(), reasons);
userProfileTracker = new UserProfileTracker(userProfile, false);
}

var response = GetVariation(experiment, user, config, options, userProfileTracker,
reasons);

if (UserProfileService != null && !ignoreUps &&
userProfileTracker?.ProfileUpdated == true)
{
SaveUserProfile(userProfileTracker.UserProfile);
}

return response;
}

/// <summary>
/// Get a Variation of an Experiment for a user to be allocated into.
/// </summary>
/// <param name="experiment">The Experiment the user will be bucketed into.</param>
/// <param name="user">Optimizely user context.</param>
/// <param name="config">Project Config.</param>
/// <param name="options">An array of decision options.</param>
/// <param name="userProfileTracker">A UserProfileTracker object.</param>
/// <param name="reasons">Set of reasons for the decision.</param>
/// <returns>The Variation the user is allocated into.</returns>
public virtual Result<Variation> GetVariation(Experiment experiment,
OptimizelyUserContext user,
ProjectConfig config,
OptimizelyDecideOption[] options,
UserProfileTracker userProfileTracker,
DecisionReasons reasons = null
)
{
if (reasons == null)
{
reasons = new DecisionReasons();
}

if (!ExperimentUtils.IsExperimentActive(experiment, Logger))
{
var message = reasons.AddInfo($"Experiment {experiment.Key} is not running.");
Logger.Log(LogLevel.INFO, message);
return Result<Variation>.NullResult(reasons);
}

var userId = user.GetUserId();

// check if a forced variation is set
var decisionVariationResult = GetForcedVariation(experiment.Key, userId, config);
reasons += decisionVariationResult.DecisionReasons;
Expand All @@ -137,76 +185,41 @@ OptimizelyDecideOption[] options
return decisionVariationResult;
}

// fetch the user profile map from the user profile service
var ignoreUPS = Array.Exists(options,
option => option == OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE);

UserProfile userProfile = null;
if (!ignoreUPS && UserProfileService != null)
if (userProfileTracker != null)
{
try
{
var userProfileMap = UserProfileService.Lookup(user.GetUserId());
if (userProfileMap != null &&
UserProfileUtil.IsValidUserProfileMap(userProfileMap))
{
userProfile = UserProfileUtil.ConvertMapToUserProfile(userProfileMap);
decisionVariationResult =
GetStoredVariation(experiment, userProfile, config);
reasons += decisionVariationResult.DecisionReasons;
if (decisionVariationResult.ResultObject != null)
{
return decisionVariationResult.SetReasons(reasons);
}
}
else if (userProfileMap == null)
{
Logger.Log(LogLevel.INFO,
reasons.AddInfo(
"We were unable to get a user profile map from the UserProfileService."));
}
else
{
Logger.Log(LogLevel.ERROR,
reasons.AddInfo("The UserProfileService returned an invalid map."));
}
}
catch (Exception exception)
decisionVariationResult =
GetStoredVariation(experiment, userProfileTracker.UserProfile, config);
reasons += decisionVariationResult.DecisionReasons;
variation = decisionVariationResult.ResultObject;
if (variation != null)
{
Logger.Log(LogLevel.ERROR, reasons.AddInfo(exception.Message));
ErrorHandler.HandleError(
new Exceptions.OptimizelyRuntimeException(exception.Message));
return decisionVariationResult;
}
}

var filteredAttributes = user.GetAttributes();
var doesUserMeetAudienceConditionsResult =
ExperimentUtils.DoesUserMeetAudienceConditions(config, experiment, user,
LOGGING_KEY_TYPE_EXPERIMENT, experiment.Key, Logger);
reasons += doesUserMeetAudienceConditionsResult.DecisionReasons;
if (doesUserMeetAudienceConditionsResult.ResultObject)
var decisionMeetAudience = ExperimentUtils.DoesUserMeetAudienceConditions(config,
experiment, user,
LOGGING_KEY_TYPE_EXPERIMENT, experiment.Key, Logger);
reasons += decisionMeetAudience.DecisionReasons;
if (decisionMeetAudience.ResultObject)
{
// Get Bucketing ID from user attributes.
var bucketingIdResult = GetBucketingId(userId, filteredAttributes);
var bucketingIdResult = GetBucketingId(userId, user.GetAttributes());
reasons += bucketingIdResult.DecisionReasons;

decisionVariationResult = Bucketer.Bucket(config, experiment,
bucketingIdResult.ResultObject, userId);
reasons += decisionVariationResult.DecisionReasons;
variation = decisionVariationResult.ResultObject;

if (decisionVariationResult.ResultObject?.Key != null)
if (variation != null)
{
if (UserProfileService != null && !ignoreUPS)
if (userProfileTracker != null)
{
var bucketerUserProfile = userProfile ??
new UserProfile(userId,
new Dictionary<string, Decision>());
SaveVariation(experiment, decisionVariationResult.ResultObject,
bucketerUserProfile);
userProfileTracker.UpdateUserProfile(experiment, variation);
}
else
{
Logger.Log(LogLevel.INFO,
Logger.Log(LogLevel.DEBUG,
"This decision will not be saved since the UserProfileService is null.");
}
}
Expand Down Expand Up @@ -720,18 +733,6 @@ public virtual Result<FeatureDecision> GetVariationForFeature(FeatureFlag featur
new OptimizelyDecideOption[] { });
}

private class UserProfileTracker
{
public UserProfile UserProfile { get; set; }
public bool ProfileUpdated { get; set; }

public UserProfileTracker(UserProfile userProfile, bool profileUpdated)
{
UserProfile = userProfile;
ProfileUpdated = profileUpdated;
}
}

void SaveUserProfile(UserProfile userProfile)
{
if (UserProfileService == null)
Expand Down Expand Up @@ -791,6 +792,40 @@ private UserProfile GetUserProfile(String userId, DecisionReasons reasons)
return userProfile;
}

public class UserProfileTracker
{
public UserProfile UserProfile { get; set; }
public bool ProfileUpdated { get; set; }

public UserProfileTracker(UserProfile userProfile, bool profileUpdated)
{
UserProfile = userProfile;
ProfileUpdated = profileUpdated;
}

public void UpdateUserProfile(Experiment experiment, Variation variation)
{
var experimentId = experiment.Id;
var variationId = variation.Id;
Decision decision;
if (UserProfile.ExperimentBucketMap.ContainsKey(experimentId))
{
decision = UserProfile.ExperimentBucketMap[experimentId];
decision.VariationId = variationId;
}
else
{
decision = new Decision(variationId);
}

UserProfile.ExperimentBucketMap[experimentId] = decision;
ProfileUpdated = true;

Logger.Log(LogLevel.INFO,
$"Updated variation \"{variationId}\" of experiment \"{experimentId}\" for user \"{UserProfile.UserId}\".");
}
}

public virtual List<Result<FeatureDecision>> GetVariationsForFeatureList(
List<FeatureFlag> featureFlags,
OptimizelyUserContext user,
Expand Down Expand Up @@ -834,22 +869,23 @@ OptimizelyDecideOption[] options
decisionResult = GetVariationForFeatureRollout(featureFlag, user, projectConfig);
reasons += decisionResult.DecisionReasons;

if (decisionResult.ResultObject != null)
if (decisionResult.ResultObject == null)
{
Logger.Log(LogLevel.INFO,
reasons.AddInfo(
$"The user \"{userId}\" is not bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
decisions.Add(Result<FeatureDecision>.NewResult(
new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_ROLLOUT),
reasons));
}
else
{
Logger.Log(LogLevel.INFO,
reasons.AddInfo(
$"The user \"{userId}\" is bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
decisions.Add(
Result<FeatureDecision>.NewResult(decisionResult.ResultObject, reasons));
continue;
}

Logger.Log(LogLevel.INFO,
reasons.AddInfo(
$"The user \"{userId}\" is not bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
decisions.Add(Result<FeatureDecision>.NewResult(
new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_ROLLOUT),
reasons));
}

if (UserProfileService != null && !ignoreUPS && userProfileTracker?.ProfileUpdated == true)
Expand Down
Loading

0 comments on commit a866266

Please sign in to comment.