From b315d5d0365f45026565ca581de1da8708cfcdb1 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Fri, 18 Oct 2024 14:58:51 -0400 Subject: [PATCH 01/28] ci: fail if triggered from Draft PR --- .github/workflows/csharp.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 4303e0e2..8a6f6426 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -7,9 +7,20 @@ on: branches: [ master ] jobs: - lintCodebase: + failOnDraftPullRequest: + name: Fail If Draft Pull Request + if: github.event.pull_request.draft == true runs-on: ubuntu-latest + steps: + - name: Fails Draft pull request. + run: | + echo "Draft pull requests should not trigger CI. Please mark the pull request as Ready for review to continue." + exit 1 + + lintCodebase: name: Lint Codebase + needs: [ failOnDraftPullRequest ] + runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 From b5658b1d7dd6d61942ceb7571f3a9f510c0f2a69 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Fri, 18 Oct 2024 14:59:10 -0400 Subject: [PATCH 02/28] test: cover new cases --- .../OptimizelyUserContextTest.cs | 163 +++++++++++++++++- 1 file changed, 158 insertions(+), 5 deletions(-) diff --git a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs index 49fd91a4..bd41baab 100644 --- a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs +++ b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs @@ -1,5 +1,5 @@ /* - * Copyright 2020-2021, 2022-2023 Optimizely and contributors + * Copyright 2020-2021, 2022-2024 Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,7 @@ using System; using System.Collections.Generic; -using System.Threading; +using System.Linq; using Castle.Core.Internal; using Moq; using NUnit.Framework; @@ -61,6 +61,22 @@ public void SetUp() Optimizely = new Optimizely(TestData.Datafile, EventDispatcherMock.Object, LoggerMock.Object, ErrorHandlerMock.Object); } + + private Mock MakeUserProfileServiceMock() + { + var projectConfig = DatafileProjectConfig.Create(TestData.Datafile, LoggerMock.Object, + ErrorHandlerMock.Object); + var experiment = projectConfig.Experiments[8]; + var variation = experiment.Variations[0]; + var decision = new Decision(variation.Id); + var userProfile = new UserProfile(UserID, new Dictionary + { + { experiment.Id, decision }, + }); + var userProfileServiceMock = new Mock(); + userProfileServiceMock.Setup(up => up.Lookup(UserID)).Returns(userProfile.ToMap()); + return userProfileServiceMock; + } [Test] public void OptimizelyUserContextWithAttributes() @@ -193,7 +209,7 @@ public void SetAttributeToOverrideAttribute() Assert.AreEqual(user.GetAttributes()["k1"], true); } - #region decide + #region Decide [Test] public void TestDecide() @@ -409,10 +425,111 @@ public void DecideWhenConfigIsNull() Assert.IsTrue(TestData.CompareObjects(decision, decisionExpected)); } - #endregion decide + [Test] + public void SeparateDecideShouldHaveSameNumberOfUpsSaveOnlyOneLookup() + { + var experimentFlagKey = "double_single_variable_feature"; + var rolloutFlagKey = "boolean_single_variable_feature"; + var userProfileServiceMock = MakeUserProfileServiceMock(); + var saveArgsCollector = new List>(); + userProfileServiceMock.Setup(up => up.Save(Capture.In(saveArgsCollector))); + var optimizely = new Optimizely(TestData.Datafile, EventDispatcherMock.Object, + LoggerMock.Object, ErrorHandlerMock.Object, userProfileServiceMock.Object); + var user = optimizely.CreateUserContext(UserID); + var expectedUserProfileExperiment = new UserProfile(UserID, new Dictionary + { + { "224", new Decision("280") }, + { "122238", new Decision("122240") }, + }); + var expectedUserProfileRollout = new UserProfile(UserID, new Dictionary + { + { "999", new Decision("99") }, + { "99999", new Decision("999") }, + }); + + user.Decide(experimentFlagKey); + user.Decide(rolloutFlagKey); + + LoggerMock.Verify( + l => l.Log(LogLevel.INFO, + "We were unable to get a user profile map from the UserProfileService."), + Times.Never); + LoggerMock.Verify( + l => l.Log(LogLevel.ERROR, "The UserProfileService returned an invalid map."), + Times.Never); + userProfileServiceMock.Verify(l => l.Lookup(UserID), Times.Once); + userProfileServiceMock.Verify(l => l.Save(It.IsAny>()), + Times.Exactly(2)); + Assert.AreEqual(saveArgsCollector[0], expectedUserProfileExperiment.ToMap()); + Assert.AreEqual(saveArgsCollector[1], expectedUserProfileRollout.ToMap()); + } + + [Test] + public void DecideWithUpsShouldOnlyLookupSaveOnce() + { + var flagKeyFromTestDataJson = "double_single_variable_feature"; + var userProfileServiceMock = MakeUserProfileServiceMock(); + var saveArgsCollector = new List>(); + userProfileServiceMock.Setup(up => up.Save(Capture.In(saveArgsCollector))); + var optimizely = new Optimizely(TestData.Datafile, EventDispatcherMock.Object, + LoggerMock.Object, ErrorHandlerMock.Object, userProfileServiceMock.Object); + var user = optimizely.CreateUserContext(UserID); + var expectedUserProfile = new UserProfile(UserID, new Dictionary + { + { "224", new Decision("280") }, + { "122238", new Decision("122240") }, + }); + + user.Decide(flagKeyFromTestDataJson); + + LoggerMock.Verify( + l => l.Log(LogLevel.INFO, + "We were unable to get a user profile map from the UserProfileService."), + Times.Never); + LoggerMock.Verify( + l => l.Log(LogLevel.ERROR, "The UserProfileService returned an invalid map."), + Times.Never); + userProfileServiceMock.Verify(l => l.Lookup(UserID), Times.Once); + userProfileServiceMock.Verify(l => l.Save(It.IsAny>()), + Times.Once); + Assert.AreEqual(saveArgsCollector.First(), expectedUserProfile.ToMap()); + } + + #endregion Decide + + #region DecideForKeys - #region decideAll + [Test] + public void DecideForKeysWithUpsShouldOnlyLookupSaveOnceWithMultipleFlags() + { + var flagKeys = new[] { "double_single_variable_feature", "boolean_feature" }; + var userProfileServiceMock = MakeUserProfileServiceMock(); + var saveArgsCollector = new List>(); + userProfileServiceMock.Setup(up => up.Save(Capture.In(saveArgsCollector))); + var optimizely = new Optimizely(TestData.Datafile, EventDispatcherMock.Object, + LoggerMock.Object, ErrorHandlerMock.Object, userProfileServiceMock.Object); + var userContext = optimizely.CreateUserContext(UserID); + var expectedUserProfile = new UserProfile(UserID, new Dictionary + { + { "224", new Decision("280") }, + { "122238", new Decision("122240") }, + }); + userContext.DecideForKeys(flagKeys); + + LoggerMock.Verify( + l => l.Log(LogLevel.INFO, + "We were unable to get a user profile map from the UserProfileService."), + Times.Never); + LoggerMock.Verify( + l => l.Log(LogLevel.ERROR, "The UserProfileService returned an invalid map."), + Times.Never); + userProfileServiceMock.Verify(l => l.Lookup(UserID), Times.Once); + userProfileServiceMock.Verify(l => l.Save(It.IsAny>()), + Times.Once); + Assert.AreEqual(saveArgsCollector.First(), expectedUserProfile.ToMap()); + } + [Test] public void DecideForKeysWithOneFlag() { @@ -442,6 +559,42 @@ public void DecideForKeysWithOneFlag() new string[0]); Assert.IsTrue(TestData.CompareObjects(decision, expDecision)); } + + #endregion DecideForKeys + + #region DecideAll + + [Test] + public void DecideAllWithUpsShouldOnlyLookupSaveOnce() + { + var userProfileServiceMock = MakeUserProfileServiceMock(); + var saveArgsCollector = new List>(); + userProfileServiceMock.Setup(up => up.Save(Capture.In(saveArgsCollector))); + var optimizely = new Optimizely(TestData.Datafile, EventDispatcherMock.Object, + LoggerMock.Object, ErrorHandlerMock.Object, userProfileServiceMock.Object); + var user = optimizely.CreateUserContext(UserID); + var expectedUserProfile = new UserProfile(UserID, new Dictionary + { + { "224", new Decision("280") }, + { "122238", new Decision("122240") }, + { "122241", new Decision("122242") }, + { "122235", new Decision("122236") }, + { "188880", new Decision("188881") }, + }); + + user.DecideAll(); + + LoggerMock.Verify( + l => l.Log(LogLevel.INFO, + "We were unable to get a user profile map from the UserProfileService."), + Times.Never); + LoggerMock.Verify( + l => l.Log(LogLevel.ERROR, "The UserProfileService returned an invalid map."), + Times.Never); + userProfileServiceMock.Verify(l => l.Lookup(UserID), Times.Once); + userProfileServiceMock.Verify(l => l.Save(It.IsAny>()), Times.Once); + Assert.AreEqual(saveArgsCollector.First(), expectedUserProfile.ToMap()); + } [Test] public void DecideAllTwoFlag() From 74d98f705fef3c282d7782ba3b92eddea7a624b3 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Fri, 18 Oct 2024 17:51:53 -0400 Subject: [PATCH 03/28] feat: WIP --- OptimizelySDK/Bucketing/DecisionService.cs | 188 +++++++++++++++++---- OptimizelySDK/Optimizely.cs | 58 ++++++- 2 files changed, 202 insertions(+), 44 deletions(-) diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs index e6088d7e..c820fefc 100644 --- a/OptimizelySDK/Bucketing/DecisionService.cs +++ b/OptimizelySDK/Bucketing/DecisionService.cs @@ -1,5 +1,5 @@ /* -* Copyright 2017-2022, Optimizely +* Copyright 2017-2022, 2024 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; +using System.Linq; using OptimizelySDK.Entity; using OptimizelySDK.ErrorHandler; using OptimizelySDK.Logger; @@ -706,9 +707,9 @@ OptimizelyDecideOption[] options /// /// Get the variation the user is bucketed into for the FeatureFlag /// - /// The feature flag the user wants to access. - /// User Identifier - /// The user's attributes. This should be filtered to just attributes in the Datafile. + /// The feature flag the user wants to access. + /// The user context. + /// The project config. /// null if the user is not bucketed into any variation or the FeatureDecision entity if the user is /// successfully bucketed. public virtual Result GetVariationForFeature(FeatureFlag featureFlag, @@ -719,53 +720,168 @@ public virtual Result GetVariationForFeature(FeatureFlag featur new OptimizelyDecideOption[] { }); } - /// - /// Get the variation the user is bucketed into for the FeatureFlag - /// - /// The feature flag the user wants to access. - /// User Identifier - /// The user's attributes. This should be filtered to just attributes in the Datafile. - /// The user's attributes. This should be filtered to just attributes in the Datafile. - /// An array of decision options. - /// null if the user is not bucketed into any variation or the FeatureDecision entity if the user is - /// successfully bucketed. - public virtual Result GetVariationForFeature(FeatureFlag featureFlag, + 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) + { + return; + } + + try + { + UserProfileService.Save(userProfile.ToMap()); + Logger.Log(LogLevel.INFO, + $"Saved user profile of user \"{userProfile.UserId}\"."); + } + catch (Exception exception) + { + Logger.Log(LogLevel.WARN, + $"Failed to save user profile of user \"{userProfile.UserId}\"."); + ErrorHandler.HandleError(new Exceptions.OptimizelyRuntimeException(exception.Message)); + } + } + + private UserProfile GetUserProfile(String userId, DecisionReasons reasons) + { + UserProfile userProfile = null; + + try + { + var userProfileMap = UserProfileService.Lookup(userId); + if (userProfileMap == null) + { + Logger.Log(LogLevel.INFO, + reasons.AddInfo( + "We were unable to get a user profile map from the UserProfileService.")); + } + else if (UserProfileUtil.IsValidUserProfileMap(userProfileMap)) + { + userProfile = UserProfileUtil.ConvertMapToUserProfile(userProfileMap); + } + else + { + Logger.Log(LogLevel.WARN, + reasons.AddInfo("The UserProfileService returned an invalid map.")); + } + } + catch (Exception exception) + { + Logger.Log(LogLevel.ERROR, reasons.AddInfo(exception.Message)); + ErrorHandler.HandleError( + new Exceptions.OptimizelyRuntimeException(exception.Message)); + } + + if (userProfile == null) + { + userProfile = new UserProfile(userId, new Dictionary()); + } + + return userProfile; + } + + public virtual List> GetVariationsForFeatureList( + List featureFlags, OptimizelyUserContext user, - ProjectConfig config, + ProjectConfig projectConfig, UserAttributes filteredAttributes, OptimizelyDecideOption[] options ) { - var reasons = new DecisionReasons(); - var userId = user.GetUserId(); - // Check if the feature flag has an experiment and the user is bucketed into that experiment. - var decisionResult = GetVariationForFeatureExperiment(featureFlag, user, - filteredAttributes, config, options); - reasons += decisionResult.DecisionReasons; + var upsReasons = new DecisionReasons(); - if (decisionResult.ResultObject != null) + var ignoreUPS = options.Contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); + UserProfileTracker userProfileTracker = null; + + if (UserProfileService != null && !ignoreUPS) { - return Result.NewResult(decisionResult.ResultObject, reasons); + var userProfile = GetUserProfile(user.GetUserId(), upsReasons); + userProfileTracker = new UserProfileTracker(userProfile, false); } - // Check if the feature flag has rollout and the the user is bucketed into one of its rules. - decisionResult = GetVariationForFeatureRollout(featureFlag, user, config); - reasons += decisionResult.DecisionReasons; + var userId = user.GetUserId(); + var decisions = new List>(); - if (decisionResult.ResultObject != null) + foreach (var featureFlag in featureFlags) { + var reasons = new DecisionReasons(); + reasons += upsReasons; + + // Check if the feature flag has an experiment and the user is bucketed into that experiment. + var decisionResult = GetVariationForFeatureExperiment(featureFlag, user, + filteredAttributes, projectConfig, options); + reasons += decisionResult.DecisionReasons; + + if (decisionResult.ResultObject != null) + { + decisions.Add( + Result.NewResult(decisionResult.ResultObject, reasons)); + continue; + } + + // Check if the feature flag has rollout and the the user is bucketed into one of its rules. + decisionResult = GetVariationForFeatureRollout(featureFlag, user, projectConfig); + reasons += decisionResult.DecisionReasons; + + if (decisionResult.ResultObject != null) + { + Logger.Log(LogLevel.INFO, + reasons.AddInfo( + $"The user \"{userId}\" is bucketed into a rollout for feature flag \"{featureFlag.Key}\".")); + decisions.Add( + Result.NewResult(decisionResult.ResultObject, reasons)); + continue; + } + Logger.Log(LogLevel.INFO, reasons.AddInfo( - $"The user \"{userId}\" is bucketed into a rollout for feature flag \"{featureFlag.Key}\".")); - return Result.NewResult(decisionResult.ResultObject, reasons); + $"The user \"{userId}\" is not bucketed into a rollout for feature flag \"{featureFlag.Key}\".")); + decisions.Add(Result.NewResult( + new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_ROLLOUT), + reasons)); } - Logger.Log(LogLevel.INFO, - reasons.AddInfo( - $"The user \"{userId}\" is not bucketed into a rollout for feature flag \"{featureFlag.Key}\".")); - return Result.NewResult( - new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_ROLLOUT), reasons); - ; + if (UserProfileService != null && !ignoreUPS && userProfileTracker?.ProfileUpdated == true) + { + SaveUserProfile(userProfileTracker.UserProfile); + } + + return decisions; + } + + /// + /// Get the variation the user is bucketed into for the FeatureFlag + /// + /// The feature flag the user wants to access. + /// The user context. + /// The project config. + /// The user's attributes. This should be filtered to just attributes in the Datafile. + /// An array of decision options. + /// null if the user is not bucketed into any variation or the FeatureDecision entity if the user is + /// successfully bucketed. + public virtual Result GetVariationForFeature(FeatureFlag featureFlag, + OptimizelyUserContext user, + ProjectConfig config, + UserAttributes filteredAttributes, + OptimizelyDecideOption[] options + ) + { + return GetVariationsForFeatureList(new List { featureFlag }, + user, + config, + filteredAttributes, + options).First(); } /// diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index 9da300f8..4bc3b568 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -869,6 +869,8 @@ internal OptimizelyDecision Decide(OptimizelyUserContext user, OptimizelyDecideOption[] options ) { + return DecideForKeys(user, new[] { key }, options)[key]; + var config = ProjectConfigManager?.GetConfig(); if (config == null) @@ -1024,7 +1026,7 @@ OptimizelyDecideOption[] options if (projectConfig == null) { Logger.Log(LogLevel.ERROR, - "Optimizely instance is not valid, failing isFeatureEnabled call."); + "Optimizely instance is not valid, failing DecideAll call."); return decisionMap; } @@ -1041,23 +1043,61 @@ OptimizelyDecideOption[] options { var decisionDictionary = new Dictionary(); - var projectConfig = ProjectConfigManager?.GetConfig(); - if (projectConfig == null) + if (keys.Length == 0) { - Logger.Log(LogLevel.ERROR, - "Optimizely instance is not valid, failing isFeatureEnabled call."); return decisionDictionary; } - if (keys.Length == 0) + var projectConfig = ProjectConfigManager?.GetConfig(); + if (projectConfig == null) { + Logger.Log(LogLevel.ERROR, + "Optimizely instance is not valid, failing DecideForKeys call."); return decisionDictionary; } var allOptions = GetAllOptions(options); + var flagDecisions = new Dictionary(); + var decisionReasons = new Dictionary(); + + var flagsWithoutForcedDecisions = new List(); + foreach (var key in keys) { + var flag = projectConfig.GetFeatureFlagFromKey(key); + if (flag.Key == null) + { + decisionDictionary.Add(key, + OptimizelyDecision.NewErrorDecision(key, user, + DecisionMessage.Reason(DecisionMessage.FLAG_KEY_INVALID, key), + ErrorHandler, Logger)); + continue; + } + + var decisionContext = new OptimizelyDecisionContext(flag.Key); + var forcedDecisionVariation = + DecisionService.ValidatedForcedDecision(decisionContext, projectConfig, user); + decisionReasons.Add(key, forcedDecisionVariation.DecisionReasons); + + if (forcedDecisionVariation.ResultObject != null) + { + var experiment = projectConfig.GetExperimentFromKey(flag.Key); + var featureDecision = Result.NewResult( + new FeatureDecision(experiment, forcedDecisionVariation.ResultObject, + FeatureDecision.DECISION_SOURCE_FEATURE_TEST), + forcedDecisionVariation.DecisionReasons); + flagDecisions.Add(key, featureDecision.ResultObject); + } + else + { + flagsWithoutForcedDecisions.Add(flag); + } + + var decisionsList = DecisionService.GetVariationsForFeatureList( + flagsWithoutForcedDecisions, user, projectConfig, user.GetAttributes(), + options); + var decision = Decide(user, key, options); if (!allOptions.Contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || decision.Enabled) @@ -1347,7 +1387,8 @@ List segmentOptions if (config == null) { - Logger.Log(LogLevel.ERROR, "Datafile has invalid format. Failing 'FetchQualifiedSegments'."); + Logger.Log(LogLevel.ERROR, + "Datafile has invalid format. Failing 'FetchQualifiedSegments'."); return null; } @@ -1378,7 +1419,8 @@ internal void IdentifyUser(string userId) /// Dictionary for identifiers. The caller must provide at least one key-value pair. /// Type of event (defaults to `fullstack`) /// Optional event data in a key-value pair format - public void SendOdpEvent(string action, Dictionary identifiers, string type = Constants.ODP_EVENT_TYPE, + public void SendOdpEvent(string action, Dictionary identifiers, + string type = Constants.ODP_EVENT_TYPE, Dictionary data = null ) { From a8662667d5ab1e69ec25c34994d0d4976a4da5a5 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Mon, 21 Oct 2024 16:34:07 -0400 Subject: [PATCH 04/28] feat: WIP implementing solution. Tests are still in progress too. --- OptimizelySDK.sln.DotSettings | 5 + OptimizelySDK/Bucketing/DecisionService.cs | 204 +++++++----- OptimizelySDK/Optimizely.cs | 346 +++++++++++---------- 3 files changed, 310 insertions(+), 245 deletions(-) diff --git a/OptimizelySDK.sln.DotSettings b/OptimizelySDK.sln.DotSettings index 3ccf7ffc..8ee6e5a4 100644 --- a/OptimizelySDK.sln.DotSettings +++ b/OptimizelySDK.sln.DotSettings @@ -43,10 +43,15 @@ <Policy Inspect="True" Prefix="I" Suffix="" Style="AaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /> <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static readonly fields (private)"><ElementKinds><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Enum members"><ElementKinds><Kind Name="ENUM_MEMBER" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Local constants"><ElementKinds><Kind Name="LOCAL_CONSTANT" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Interfaces"><ElementKinds><Kind Name="INTERFACE" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="I" Suffix="" Style="AaBb" /></Policy> True True True True + True True True True diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs index c820fefc..4dba482c 100644 --- a/OptimizelySDK/Bucketing/DecisionService.cs +++ b/OptimizelySDK/Bucketing/DecisionService.cs @@ -43,7 +43,7 @@ public class DecisionService private Bucketer Bucketer; private IErrorHandler ErrorHandler; private UserProfileService UserProfileService; - private ILogger Logger; + private static ILogger Logger; /// /// Associative array of user IDs to an associative array @@ -85,9 +85,9 @@ public DecisionService(Bucketer bucketer, IErrorHandler errorHandler, /// /// Get a Variation of an Experiment for a user to be allocated into. /// - /// The Experiment the user will be bucketed into. - /// Optimizely user context. - /// Project config. + /// The Experiment the user will be bucketed into. + /// Optimizely user context. + /// Project config. /// The Variation the user is allocated into. public virtual Result GetVariation(Experiment experiment, OptimizelyUserContext user, @@ -100,11 +100,11 @@ ProjectConfig config /// /// Get a Variation of an Experiment for a user to be allocated into. /// - /// The Experiment the user will be bucketed into. - /// optimizely user context. - /// Project Config. - /// An array of decision options. - /// The Variation the user is allocated into. + /// The Experiment the user will be bucketed into. + /// Optimizely user context. + /// Project Config. + /// An array of decision options. + /// public virtual Result GetVariation(Experiment experiment, OptimizelyUserContext user, ProjectConfig config, @@ -112,12 +112,60 @@ 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; + } + + /// + /// Get a Variation of an Experiment for a user to be allocated into. + /// + /// The Experiment the user will be bucketed into. + /// Optimizely user context. + /// Project Config. + /// An array of decision options. + /// A UserProfileTracker object. + /// Set of reasons for the decision. + /// The Variation the user is allocated into. + public virtual Result 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.NullResult(reasons); } + var userId = user.GetUserId(); + // check if a forced variation is set var decisionVariationResult = GetForcedVariation(experiment.Key, userId, config); reasons += decisionVariationResult.DecisionReasons; @@ -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()); - 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."); } } @@ -720,18 +733,6 @@ public virtual Result 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) @@ -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> GetVariationsForFeatureList( List featureFlags, OptimizelyUserContext user, @@ -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.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.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.NewResult( - new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_ROLLOUT), - reasons)); } if (UserProfileService != null && !ignoreUPS && userProfileTracker?.ProfileUpdated == true) diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index 4bc3b568..a84466e4 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -855,12 +855,30 @@ private OptimizelyUserContext CreateUserContextCopy(string userId, ); } + public FeatureDecision GetForcedDecision(string flagKey, DecisionReasons decisionReasons, + ProjectConfig projectConfig, OptimizelyUserContext user + ) + { + var context = new OptimizelyDecisionContext(flagKey); + var forcedDecisionVariation = + DecisionService.ValidatedForcedDecision(context, projectConfig, user); + decisionReasons += forcedDecisionVariation.DecisionReasons; + if (forcedDecisionVariation.ResultObject != null) + { + return new FeatureDecision(null, forcedDecisionVariation.ResultObject, + FeatureDecision.DECISION_SOURCE_FEATURE_TEST); + } + + return null; + } + /// /// Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, which contains all data required to deliver the flag. ///
    ///
  • If the SDK finds an error, it’ll return a decision with null for variationKey. The decision will include an error message in reasons. ///
///
+ /// User context to be used to make decision. /// A flag key for which a decision will be made. /// A list of options for decision-making. /// A decision result. @@ -869,8 +887,6 @@ internal OptimizelyDecision Decide(OptimizelyUserContext user, OptimizelyDecideOption[] options ) { - return DecideForKeys(user, new[] { key }, options)[key]; - var config = ProjectConfigManager?.GetConfig(); if (config == null) @@ -879,141 +895,11 @@ OptimizelyDecideOption[] options ErrorHandler, Logger); } - if (key == null) - { - return OptimizelyDecision.NewErrorDecision(key, - user, - DecisionMessage.Reason(DecisionMessage.FLAG_KEY_INVALID, key), - ErrorHandler, Logger); - } - - var flag = config.GetFeatureFlagFromKey(key); - if (flag.Key == null) - { - return OptimizelyDecision.NewErrorDecision(key, - user, - DecisionMessage.Reason(DecisionMessage.FLAG_KEY_INVALID, key), - ErrorHandler, Logger); - } - - var userId = user?.GetUserId(); - var userAttributes = user?.GetAttributes(); - var decisionEventDispatched = false; - var allOptions = GetAllOptions(options); - var decisionReasons = new DecisionReasons(); - FeatureDecision decision = null; - - var decisionContext = new OptimizelyDecisionContext(flag.Key); - var forcedDecisionVariation = - DecisionService.ValidatedForcedDecision(decisionContext, config, user); - decisionReasons += forcedDecisionVariation.DecisionReasons; - - if (forcedDecisionVariation.ResultObject != null) - { - decision = new FeatureDecision(null, forcedDecisionVariation.ResultObject, - FeatureDecision.DECISION_SOURCE_FEATURE_TEST); - } - else - { - var flagDecisionResult = DecisionService.GetVariationForFeature( - flag, - user, - config, - userAttributes, - allOptions - ); - decisionReasons += flagDecisionResult.DecisionReasons; - decision = flagDecisionResult.ResultObject; - } - - var featureEnabled = false; - - if (decision?.Variation != null) - { - featureEnabled = decision.Variation.FeatureEnabled.GetValueOrDefault(); - } + var filteredOptions = GetAllOptions(options). + Where(opt => opt != OptimizelyDecideOption.ENABLED_FLAGS_ONLY). + ToArray(); - if (featureEnabled) - { - Logger.Log(LogLevel.INFO, - "Feature \"" + key + "\" is enabled for user \"" + userId + "\""); - } - else - { - Logger.Log(LogLevel.INFO, - "Feature \"" + key + "\" is not enabled for user \"" + userId + "\""); - } - - var variableMap = new Dictionary(); - if (flag?.Variables != null && - !allOptions.Contains(OptimizelyDecideOption.EXCLUDE_VARIABLES)) - { - foreach (var featureVariable in flag?.Variables) - { - var variableValue = featureVariable.DefaultValue; - if (featureEnabled) - { - var featureVariableUsageInstance = - decision?.Variation.GetFeatureVariableUsageFromId(featureVariable.Id); - if (featureVariableUsageInstance != null) - { - variableValue = featureVariableUsageInstance.Value; - } - } - - var typeCastedValue = - GetTypeCastedVariableValue(variableValue, featureVariable.Type); - - if (typeCastedValue is OptimizelyJSON) - { - typeCastedValue = ((OptimizelyJSON)typeCastedValue).ToDictionary(); - } - - variableMap.Add(featureVariable.Key, typeCastedValue); - } - } - - var optimizelyJSON = new OptimizelyJSON(variableMap, ErrorHandler, Logger); - - var decisionSource = decision?.Source ?? FeatureDecision.DECISION_SOURCE_ROLLOUT; - if (!allOptions.Contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) - { - decisionEventDispatched = SendImpressionEvent(decision?.Experiment, - decision?.Variation, userId, userAttributes, config, key, decisionSource, - featureEnabled); - } - - var reasonsToReport = decisionReasons - .ToReport(allOptions.Contains(OptimizelyDecideOption.INCLUDE_REASONS)) - .ToArray(); - var variationKey = decision?.Variation?.Key; - - // TODO: add ruleKey values when available later. use a copy of experimentKey until then. - var ruleKey = decision?.Experiment?.Key; - - var decisionInfo = new Dictionary - { - { "flagKey", key }, - { "enabled", featureEnabled }, - { "variables", variableMap }, - { "variationKey", variationKey }, - { "ruleKey", ruleKey }, - { "reasons", reasonsToReport }, - { "decisionEventDispatched", decisionEventDispatched }, - }; - - NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Decision, - DecisionNotificationTypes.FLAG, userId, - userAttributes ?? new UserAttributes(), decisionInfo); - - return new OptimizelyDecision( - variationKey, - featureEnabled, - optimizelyJSON, - ruleKey, - key, - user, - reasonsToReport); + return DecideForKeys(user, new[] { key }, filteredOptions, true)[key]; } internal Dictionary DecideAll(OptimizelyUserContext user, @@ -1038,16 +924,12 @@ OptimizelyDecideOption[] options internal Dictionary DecideForKeys(OptimizelyUserContext user, string[] keys, - OptimizelyDecideOption[] options + OptimizelyDecideOption[] options, + bool ignoreDefaultOptions = false ) { var decisionDictionary = new Dictionary(); - if (keys.Length == 0) - { - return decisionDictionary; - } - var projectConfig = ProjectConfigManager?.GetConfig(); if (projectConfig == null) { @@ -1056,13 +938,20 @@ OptimizelyDecideOption[] options return decisionDictionary; } - var allOptions = GetAllOptions(options); + if (keys.Length == 0) + { + return decisionDictionary; + } + + var allOptions = ignoreDefaultOptions ? options : GetAllOptions(options); var flagDecisions = new Dictionary(); - var decisionReasons = new Dictionary(); + var decisionReasonsMap = new Dictionary(); var flagsWithoutForcedDecisions = new List(); + var validKeys = new List(); + foreach (var key in keys) { var flag = projectConfig.GetFeatureFlagFromKey(key); @@ -1075,40 +964,175 @@ OptimizelyDecideOption[] options continue; } - var decisionContext = new OptimizelyDecisionContext(flag.Key); - var forcedDecisionVariation = - DecisionService.ValidatedForcedDecision(decisionContext, projectConfig, user); - decisionReasons.Add(key, forcedDecisionVariation.DecisionReasons); + validKeys.Add(key); + + var decisionReasons = new DecisionReasons(); + var forcedDecision = GetForcedDecision(key, decisionReasons, projectConfig, user); + decisionReasonsMap.Add(key, decisionReasons); - if (forcedDecisionVariation.ResultObject != null) + if (forcedDecision != null) { - var experiment = projectConfig.GetExperimentFromKey(flag.Key); - var featureDecision = Result.NewResult( - new FeatureDecision(experiment, forcedDecisionVariation.ResultObject, - FeatureDecision.DECISION_SOURCE_FEATURE_TEST), - forcedDecisionVariation.DecisionReasons); - flagDecisions.Add(key, featureDecision.ResultObject); + flagDecisions.Add(key, forcedDecision); } else { flagsWithoutForcedDecisions.Add(flag); } + } - var decisionsList = DecisionService.GetVariationsForFeatureList( - flagsWithoutForcedDecisions, user, projectConfig, user.GetAttributes(), - options); + var decisionsList = DecisionService.GetVariationsForFeatureList( + flagsWithoutForcedDecisions, user, projectConfig, user.GetAttributes(), + allOptions); - var decision = Decide(user, key, options); + for (var i = 0; i < decisionsList.Count; i += 1) + { + var decision = decisionsList[i]; + var flagKey = flagsWithoutForcedDecisions[i].Key; + flagDecisions.Add(flagKey, decision.ResultObject); + decisionReasonsMap[flagKey] += decision.DecisionReasons; + } + + foreach (var key in validKeys) + { + var flagDecision = flagDecisions[key]; + var decisionReasons = decisionReasonsMap[key]; + + var optimizelyDecision = CreateOptimizelyDecision(user, key, flagDecision, + decisionReasons, allOptions.ToList(), projectConfig); if (!allOptions.Contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || - decision.Enabled) + optimizelyDecision.Enabled) { - decisionDictionary.Add(key, decision); + decisionDictionary.Add(key, optimizelyDecision); } } return decisionDictionary; } + private OptimizelyDecision CreateOptimizelyDecision( + OptimizelyUserContext user, + string flagKey, + FeatureDecision flagDecision, + DecisionReasons decisionReasons, + List allOptions, + ProjectConfig projectConfig + ) + { + var userId = user.GetUserId(); + + var flagEnabled = false; + if (flagDecision.Variation != null) + { + if (flagDecision.Variation.IsFeatureEnabled) + { + flagEnabled = true; + } + } + + Logger.Log(LogLevel.INFO, + $"Feature \"{flagKey}\" is enabled for user \"{userId}\"? {flagEnabled}"); + + var variableMap = new Dictionary(); + if (!allOptions.Contains(OptimizelyDecideOption.EXCLUDE_VARIABLES)) + { + var decisionVariables = GetDecisionVariableMap( + projectConfig.GetFeatureFlagFromKey(flagKey), + flagDecision.Variation, + flagEnabled); + variableMap = decisionVariables.ResultObject; + decisionReasons += decisionVariables.DecisionReasons; + } + + var optimizelyJson = new OptimizelyJSON(variableMap, ErrorHandler, Logger); + + var decisionSource = FeatureDecision.DECISION_SOURCE_ROLLOUT; + if (flagDecision.Source != null) + { + decisionSource = flagDecision.Source; + } + + var reasonsToReport = decisionReasons.ToReport().ToArray(); + var variationKey = flagDecision.Variation?.Key; + // TODO: add ruleKey values when available later. use a copy of experimentKey until then. + // add to event metadata as well (currently set to experimentKey) + var ruleKey = flagDecision.Experiment?.Key; + + var decisionEventDispatched = false; + if (!allOptions.Contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) + { + decisionEventDispatched = SendImpressionEvent( + flagDecision.Experiment, + flagDecision.Variation, + userId, + user.GetAttributes(), + projectConfig, + flagKey, + decisionSource, + flagEnabled); + } + + var decisionInfo = new Dictionary + { + { "featureKey", flagKey }, + { "featureEnabled", flagEnabled }, + { "variableValues", variableMap }, + { "variationKey", variationKey }, + { "ruleKey", ruleKey }, + { "reasons", reasonsToReport }, + { "decisionEventDispatched", decisionEventDispatched }, + }; + + // var decisionNotificationType = + // projectConfig.IsFeatureExperiment(flagDecision.Experiment?.Id) ? + // DecisionNotificationTypes.FEATURE_TEST : + // DecisionNotificationTypes.AB_TEST; + NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Decision, + userId, + user.GetAttributes(), decisionInfo); + + return new OptimizelyDecision( + variationKey, + flagEnabled, + optimizelyJson, + ruleKey, + flagKey, + user, + reasonsToReport); + } + + private Result> GetDecisionVariableMap(FeatureFlag flag, Variation variation, bool featureEnabled) + { + var reasons = new DecisionReasons(); + var valuesMap = new Dictionary(); + + foreach (var variable in flag.Variables) + { + var value = variable.DefaultValue; + if (featureEnabled) + { + var instance = variation.GetFeatureVariableUsageFromId(variable.Id); + if (instance != null) + { + value = instance.Value; + } + } + + var convertedValue = GetTypeCastedVariableValue(value, variable.Type); + if (convertedValue == null) + { + reasons.AddError(DecisionMessage.Reason(DecisionMessage.VARIABLE_VALUE_INVALID, variable.Key)); + } + else if (convertedValue is OptimizelyJSON optimizelyJson) + { + convertedValue = optimizelyJson.ToDictionary(); + } + + valuesMap[variable.Key] = convertedValue; + } + + return Result>.NewResult(valuesMap, reasons); + } + private OptimizelyDecideOption[] GetAllOptions(OptimizelyDecideOption[] options) { var copiedOptions = DefaultDecideOptions; From 80bed0ae7fe07ab2c781f6d96b460fcdfdba3efb Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Mon, 21 Oct 2024 16:39:30 -0400 Subject: [PATCH 05/28] doc: add comment block --- OptimizelySDK/Optimizely.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index a84466e4..41ad272a 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -855,6 +855,14 @@ private OptimizelyUserContext CreateUserContextCopy(string userId, ); } + /// + /// Get forced decision for the given flag key. + /// + /// Flag key + /// A collection of decision reasons + /// The project config + /// The user context + /// Feature decision public FeatureDecision GetForcedDecision(string flagKey, DecisionReasons decisionReasons, ProjectConfig projectConfig, OptimizelyUserContext user ) @@ -1443,8 +1451,7 @@ internal void IdentifyUser(string userId) /// Dictionary for identifiers. The caller must provide at least one key-value pair. /// Type of event (defaults to `fullstack`) /// Optional event data in a key-value pair format - public void SendOdpEvent(string action, Dictionary identifiers, - string type = Constants.ODP_EVENT_TYPE, + public void SendOdpEvent(string action, Dictionary identifiers, string type = Constants.ODP_EVENT_TYPE, Dictionary data = null ) { From 075e51154f29fa897a942e18e91457f3cff2a55f Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Mon, 21 Oct 2024 16:41:21 -0400 Subject: [PATCH 06/28] chore: remove commented code --- OptimizelySDK/Optimizely.cs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index 41ad272a..b76a2a97 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -1089,14 +1089,9 @@ ProjectConfig projectConfig { "reasons", reasonsToReport }, { "decisionEventDispatched", decisionEventDispatched }, }; - - // var decisionNotificationType = - // projectConfig.IsFeatureExperiment(flagDecision.Experiment?.Id) ? - // DecisionNotificationTypes.FEATURE_TEST : - // DecisionNotificationTypes.AB_TEST; - NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Decision, - userId, - user.GetAttributes(), decisionInfo); + + NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Decision, + userId, user.GetAttributes(), decisionInfo); return new OptimizelyDecision( variationKey, @@ -1419,8 +1414,7 @@ List segmentOptions if (config == null) { - Logger.Log(LogLevel.ERROR, - "Datafile has invalid format. Failing 'FetchQualifiedSegments'."); + Logger.Log(LogLevel.ERROR,"Datafile has invalid format. Failing 'FetchQualifiedSegments'."); return null; } From 344f451cda5eb8d1290f4bbc5965d5945be9ebd6 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Mon, 21 Oct 2024 16:52:00 -0400 Subject: [PATCH 07/28] test: fix via code under test --- OptimizelySDK/Optimizely.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index b76a2a97..7e32766c 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -1081,9 +1081,9 @@ ProjectConfig projectConfig var decisionInfo = new Dictionary { - { "featureKey", flagKey }, - { "featureEnabled", flagEnabled }, - { "variableValues", variableMap }, + { "flagKey", flagKey }, + { "enabled", flagEnabled }, + { "variables", variableMap }, { "variationKey", variationKey }, { "ruleKey", ruleKey }, { "reasons", reasonsToReport }, @@ -1091,7 +1091,7 @@ ProjectConfig projectConfig }; NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Decision, - userId, user.GetAttributes(), decisionInfo); + DecisionNotificationTypes.FLAG, userId, user.GetAttributes(), decisionInfo); return new OptimizelyDecision( variationKey, @@ -1414,7 +1414,7 @@ List segmentOptions if (config == null) { - Logger.Log(LogLevel.ERROR,"Datafile has invalid format. Failing 'FetchQualifiedSegments'."); + Logger.Log(LogLevel.ERROR, "Datafile has invalid format. Failing 'FetchQualifiedSegments'."); return null; } From c0ce71fd4af768d61c30003d2d3210cce24f7cab Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Tue, 22 Oct 2024 09:47:14 -0400 Subject: [PATCH 08/28] fix: match updates to java PR --- OptimizelySDK/Bucketing/DecisionService.cs | 135 ++++++++++----------- OptimizelySDK/Optimizely.cs | 39 ++---- 2 files changed, 76 insertions(+), 98 deletions(-) diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs index 4dba482c..c1c0b2ef 100644 --- a/OptimizelySDK/Bucketing/DecisionService.cs +++ b/OptimizelySDK/Bucketing/DecisionService.cs @@ -41,8 +41,8 @@ public class DecisionService public const string LOGGING_KEY_TYPE_RULE = "rule"; private Bucketer Bucketer; - private IErrorHandler ErrorHandler; - private UserProfileService UserProfileService; + private static IErrorHandler ErrorHandler; + private static UserProfileService UserProfileService; private static ILogger Logger; /// @@ -118,8 +118,8 @@ OptimizelyDecideOption[] options if (UserProfileService != null && !ignoreUps) { - var userProfile = GetUserProfile(user.GetUserId(), reasons); - userProfileTracker = new UserProfileTracker(userProfile, false); + userProfileTracker = new UserProfileTracker(user.GetUserId()); + userProfileTracker.LoadUserProfile(reasons); } var response = GetVariation(experiment, user, config, options, userProfileTracker, @@ -128,7 +128,7 @@ OptimizelyDecideOption[] options if (UserProfileService != null && !ignoreUps && userProfileTracker?.ProfileUpdated == true) { - SaveUserProfile(userProfileTracker.UserProfile); + userProfileTracker.SaveUserProfile(); } return response; @@ -166,7 +166,6 @@ public virtual Result GetVariation(Experiment experiment, var userId = user.GetUserId(); - // check if a forced variation is set var decisionVariationResult = GetForcedVariation(experiment.Key, userId, config); reasons += decisionVariationResult.DecisionReasons; var variation = decisionVariationResult.ResultObject; @@ -733,75 +732,52 @@ public virtual Result GetVariationForFeature(FeatureFlag featur new OptimizelyDecideOption[] { }); } - void SaveUserProfile(UserProfile userProfile) + public class UserProfileTracker { - if (UserProfileService == null) - { - return; - } + public UserProfile UserProfile { get; private set; } + public bool ProfileUpdated { get; private set; } + private string UserId { get; set; } - try + public UserProfileTracker(string userId) { - UserProfileService.Save(userProfile.ToMap()); - Logger.Log(LogLevel.INFO, - $"Saved user profile of user \"{userProfile.UserId}\"."); + UserId = userId; + ProfileUpdated = false; + UserProfile = null; } - catch (Exception exception) + + public void LoadUserProfile(DecisionReasons reasons) { - Logger.Log(LogLevel.WARN, - $"Failed to save user profile of user \"{userProfile.UserId}\"."); - ErrorHandler.HandleError(new Exceptions.OptimizelyRuntimeException(exception.Message)); - } - } - - private UserProfile GetUserProfile(String userId, DecisionReasons reasons) - { - UserProfile userProfile = null; - - try - { - var userProfileMap = UserProfileService.Lookup(userId); - if (userProfileMap == null) + try { - Logger.Log(LogLevel.INFO, - reasons.AddInfo( - "We were unable to get a user profile map from the UserProfileService.")); + var userProfileMap = UserProfileService.Lookup(UserId); + if (userProfileMap == null) + { + Logger.Log(LogLevel.INFO, + reasons.AddInfo( + "We were unable to get a user profile map from the UserProfileService.")); + } + else if (UserProfileUtil.IsValidUserProfileMap(userProfileMap)) + { + UserProfile = UserProfileUtil.ConvertMapToUserProfile(userProfileMap); + } + else + { + Logger.Log(LogLevel.WARN, + reasons.AddInfo("The UserProfileService returned an invalid map.")); + } } - else if (UserProfileUtil.IsValidUserProfileMap(userProfileMap)) + catch (Exception exception) { - userProfile = UserProfileUtil.ConvertMapToUserProfile(userProfileMap); + Logger.Log(LogLevel.ERROR, reasons.AddInfo(exception.Message)); + ErrorHandler.HandleError( + new Exceptions.OptimizelyRuntimeException(exception.Message)); } - else + + if (UserProfile == null) { - Logger.Log(LogLevel.WARN, - reasons.AddInfo("The UserProfileService returned an invalid map.")); + UserProfile = new UserProfile(UserId, new Dictionary()); } } - catch (Exception exception) - { - Logger.Log(LogLevel.ERROR, reasons.AddInfo(exception.Message)); - ErrorHandler.HandleError( - new Exceptions.OptimizelyRuntimeException(exception.Message)); - } - - if (userProfile == null) - { - userProfile = new UserProfile(userId, new Dictionary()); - } - - 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) { @@ -824,6 +800,27 @@ public void UpdateUserProfile(Experiment experiment, Variation variation) Logger.Log(LogLevel.INFO, $"Updated variation \"{variationId}\" of experiment \"{experimentId}\" for user \"{UserProfile.UserId}\"."); } + + public void SaveUserProfile() + { + if (!ProfileUpdated) + { + return; + } + + try + { + UserProfileService.Save(UserProfile.ToMap()); + Logger.Log(LogLevel.INFO, + $"Saved user profile of user \"{UserProfile.UserId}\"."); + } + catch (Exception exception) + { + Logger.Log(LogLevel.WARN, + $"Failed to save user profile of user \"{UserProfile.UserId}\"."); + ErrorHandler.HandleError(new Exceptions.OptimizelyRuntimeException(exception.Message)); + } + } } public virtual List> GetVariationsForFeatureList( @@ -836,13 +833,13 @@ OptimizelyDecideOption[] options { var upsReasons = new DecisionReasons(); - var ignoreUPS = options.Contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); + var ignoreUps = options.Contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); UserProfileTracker userProfileTracker = null; - if (UserProfileService != null && !ignoreUPS) + if (UserProfileService != null && !ignoreUps) { - var userProfile = GetUserProfile(user.GetUserId(), upsReasons); - userProfileTracker = new UserProfileTracker(userProfile, false); + userProfileTracker = new UserProfileTracker(user.GetUserId()); + userProfileTracker.LoadUserProfile(upsReasons); } var userId = user.GetUserId(); @@ -888,9 +885,9 @@ OptimizelyDecideOption[] options } } - if (UserProfileService != null && !ignoreUPS && userProfileTracker?.ProfileUpdated == true) + if (UserProfileService != null && !ignoreUps && userProfileTracker?.ProfileUpdated == true) { - SaveUserProfile(userProfileTracker.UserProfile); + userProfileTracker.SaveUserProfile(); } return decisions; diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index 7e32766c..b9631333 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -855,31 +855,6 @@ private OptimizelyUserContext CreateUserContextCopy(string userId, ); } - /// - /// Get forced decision for the given flag key. - /// - /// Flag key - /// A collection of decision reasons - /// The project config - /// The user context - /// Feature decision - public FeatureDecision GetForcedDecision(string flagKey, DecisionReasons decisionReasons, - ProjectConfig projectConfig, OptimizelyUserContext user - ) - { - var context = new OptimizelyDecisionContext(flagKey); - var forcedDecisionVariation = - DecisionService.ValidatedForcedDecision(context, projectConfig, user); - decisionReasons += forcedDecisionVariation.DecisionReasons; - if (forcedDecisionVariation.ResultObject != null) - { - return new FeatureDecision(null, forcedDecisionVariation.ResultObject, - FeatureDecision.DECISION_SOURCE_FEATURE_TEST); - } - - return null; - } - /// /// Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, which contains all data required to deliver the flag. ///
    @@ -975,12 +950,18 @@ internal Dictionary DecideForKeys(OptimizelyUserCont validKeys.Add(key); var decisionReasons = new DecisionReasons(); - var forcedDecision = GetForcedDecision(key, decisionReasons, projectConfig, user); decisionReasonsMap.Add(key, decisionReasons); - - if (forcedDecision != null) + + var optimizelyDecisionContext = new OptimizelyDecisionContext(key); + var forcedDecisionVariation = + DecisionService.ValidatedForcedDecision(optimizelyDecisionContext, projectConfig, user); + decisionReasons += forcedDecisionVariation.DecisionReasons; + + if (forcedDecisionVariation.ResultObject != null) { - flagDecisions.Add(key, forcedDecision); + flagDecisions.Add(key, new FeatureDecision(null, + forcedDecisionVariation.ResultObject, + FeatureDecision.DECISION_SOURCE_FEATURE_TEST)); } else { From 3fab122f25eeeb68408aab6d4f3dbf5f9a0c2a5b Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Tue, 22 Oct 2024 11:08:32 -0400 Subject: [PATCH 09/28] fix: code from test results --- OptimizelySDK.Tests/DecisionServiceTest.cs | 8 ++-- OptimizelySDK/Bucketing/DecisionService.cs | 50 +++++++++++----------- OptimizelySDK/Optimizely.cs | 2 +- 3 files changed, 29 insertions(+), 31 deletions(-) diff --git a/OptimizelySDK.Tests/DecisionServiceTest.cs b/OptimizelySDK.Tests/DecisionServiceTest.cs index 633847ae..82096156 100644 --- a/OptimizelySDK.Tests/DecisionServiceTest.cs +++ b/OptimizelySDK.Tests/DecisionServiceTest.cs @@ -856,7 +856,7 @@ public void TestGetVariationForFeatureRolloutWhenRolloutIsNotInDataFile() ds.GetVariationForFeatureExperiment(It.IsAny(), It.IsAny(), It.IsAny(), ProjectConfig, - new OptimizelyDecideOption[] { })). + new OptimizelyDecideOption[] { }, null)). Returns(null); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -1201,7 +1201,7 @@ public void TestGetVariationForFeatureWhenTheUserIsBucketedIntoFeatureExperiment DecisionServiceMock.Setup(ds => ds.GetVariationForFeatureExperiment( It.IsAny(), It.IsAny(), It.IsAny(), ProjectConfig, - It.IsAny())). + It.IsAny(), null)). Returns(expectedDecision); OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns("user1"); @@ -1228,7 +1228,7 @@ public void DecisionServiceMock.Setup(ds => ds.GetVariationForFeatureExperiment( It.IsAny(), It.IsAny(), It.IsAny(), ProjectConfig, - It.IsAny())). + It.IsAny(), null)). Returns(Result.NullResult(null)); DecisionServiceMock.Setup(ds => ds.GetVariationForFeatureRollout( It.IsAny(), It.IsAny(), @@ -1262,7 +1262,7 @@ public void ds.GetVariationForFeatureExperiment(It.IsAny(), It.IsAny(), It.IsAny(), ProjectConfig, - new OptimizelyDecideOption[] { })). + new OptimizelyDecideOption[] { }, null)). Returns(Result.NullResult(null)); DecisionServiceMock. Setup(ds => ds.GetVariationForFeatureRollout(It.IsAny(), diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs index c1c0b2ef..aac103d4 100644 --- a/OptimizelySDK/Bucketing/DecisionService.cs +++ b/OptimizelySDK/Bucketing/DecisionService.cs @@ -166,33 +166,32 @@ public virtual Result GetVariation(Experiment experiment, var userId = user.GetUserId(); - var decisionVariationResult = GetForcedVariation(experiment.Key, userId, config); - reasons += decisionVariationResult.DecisionReasons; - var variation = decisionVariationResult.ResultObject; + var decisionVariation = GetForcedVariation(experiment.Key, userId, config); + reasons += decisionVariation.DecisionReasons; + var variation = decisionVariation.ResultObject; if (variation == null) { - decisionVariationResult = GetWhitelistedVariation(experiment, user.GetUserId()); - reasons += decisionVariationResult.DecisionReasons; - - variation = decisionVariationResult.ResultObject; + decisionVariation = GetWhitelistedVariation(experiment, user.GetUserId()); + reasons += decisionVariation.DecisionReasons; + variation = decisionVariation.ResultObject; } if (variation != null) { - decisionVariationResult.SetReasons(reasons); - return decisionVariationResult; + decisionVariation.SetReasons(reasons); + return decisionVariation; } if (userProfileTracker != null) { - decisionVariationResult = + decisionVariation = GetStoredVariation(experiment, userProfileTracker.UserProfile, config); - reasons += decisionVariationResult.DecisionReasons; - variation = decisionVariationResult.ResultObject; + reasons += decisionVariation.DecisionReasons; + variation = decisionVariation.ResultObject; if (variation != null) { - return decisionVariationResult; + return decisionVariation; } } @@ -202,13 +201,11 @@ public virtual Result GetVariation(Experiment experiment, reasons += decisionMeetAudience.DecisionReasons; if (decisionMeetAudience.ResultObject) { - var bucketingIdResult = GetBucketingId(userId, user.GetAttributes()); - reasons += bucketingIdResult.DecisionReasons; + var bucketingId = GetBucketingId(userId, user.GetAttributes()).ResultObject; - decisionVariationResult = Bucketer.Bucket(config, experiment, - bucketingIdResult.ResultObject, userId); - reasons += decisionVariationResult.DecisionReasons; - variation = decisionVariationResult.ResultObject; + decisionVariation = Bucketer.Bucket(config, experiment, bucketingId, userId); + reasons += decisionVariation.DecisionReasons; + variation = decisionVariation.ResultObject; if (variation != null) { @@ -218,12 +215,12 @@ public virtual Result GetVariation(Experiment experiment, } else { - Logger.Log(LogLevel.DEBUG, + Logger.Log(LogLevel.INFO, "This decision will not be saved since the UserProfileService is null."); } } - return decisionVariationResult.SetReasons(reasons); + return decisionVariation.SetReasons(reasons); } Logger.Log(LogLevel.INFO, @@ -651,7 +648,8 @@ public virtual Result GetVariationForFeatureExperiment( OptimizelyUserContext user, UserAttributes filteredAttributes, ProjectConfig config, - OptimizelyDecideOption[] options + OptimizelyDecideOption[] options, + UserProfileTracker userProfileTracker = null ) { var reasons = new DecisionReasons(); @@ -692,7 +690,7 @@ OptimizelyDecideOption[] options } else { - var decisionResponse = GetVariation(experiment, user, config, options); + var decisionResponse = GetVariation(experiment, user, config, options, userProfileTracker); reasons += decisionResponse?.DecisionReasons; decisionVariation = decisionResponse.ResultObject; @@ -736,7 +734,7 @@ public class UserProfileTracker { public UserProfile UserProfile { get; private set; } public bool ProfileUpdated { get; private set; } - private string UserId { get; set; } + private string UserId { get; } public UserProfileTracker(string userId) { @@ -798,7 +796,7 @@ public void UpdateUserProfile(Experiment experiment, Variation variation) ProfileUpdated = true; Logger.Log(LogLevel.INFO, - $"Updated variation \"{variationId}\" of experiment \"{experimentId}\" for user \"{UserProfile.UserId}\"."); + $"Saved variation \"{variationId}\" of experiment \"{experimentId}\" for user \"{UserProfile.UserId}\"."); } public void SaveUserProfile() @@ -852,7 +850,7 @@ OptimizelyDecideOption[] options // Check if the feature flag has an experiment and the user is bucketed into that experiment. var decisionResult = GetVariationForFeatureExperiment(featureFlag, user, - filteredAttributes, projectConfig, options); + filteredAttributes, projectConfig, options, userProfileTracker); reasons += decisionResult.DecisionReasons; if (decisionResult.ResultObject != null) diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index b9631333..9f0b4a6e 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -900,7 +900,7 @@ OptimizelyDecideOption[] options } var allFlags = projectConfig.FeatureFlags; - var allFlagKeys = allFlags.Select(v => v.Key).ToArray(); + var allFlagKeys = allFlags.Select(v => v.Key).ToArray(); return DecideForKeys(user, allFlagKeys, options); } From bfdc2e09fefb5a3a683733d62be69d003ed9fb7e Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Tue, 22 Oct 2024 11:22:31 -0400 Subject: [PATCH 10/28] test: correct test --- OptimizelySDK.Tests/OptimizelyUserContextTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs index bd41baab..cb0ee9d2 100644 --- a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs +++ b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs @@ -426,7 +426,7 @@ public void DecideWhenConfigIsNull() } [Test] - public void SeparateDecideShouldHaveSameNumberOfUpsSaveOnlyOneLookup() + public void SeparateDecideShouldHaveSameNumberOfUpsSaveAndLookup() { var experimentFlagKey = "double_single_variable_feature"; var rolloutFlagKey = "boolean_single_variable_feature"; @@ -457,7 +457,7 @@ public void SeparateDecideShouldHaveSameNumberOfUpsSaveOnlyOneLookup() LoggerMock.Verify( l => l.Log(LogLevel.ERROR, "The UserProfileService returned an invalid map."), Times.Never); - userProfileServiceMock.Verify(l => l.Lookup(UserID), Times.Once); + userProfileServiceMock.Verify(l => l.Lookup(UserID), Times.Exactly(2)); userProfileServiceMock.Verify(l => l.Save(It.IsAny>()), Times.Exactly(2)); Assert.AreEqual(saveArgsCollector[0], expectedUserProfileExperiment.ToMap()); From a36280a58467621d01f38a06979aaaa48f7184fd Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Tue, 22 Oct 2024 12:31:20 -0400 Subject: [PATCH 11/28] tests: updated --- OptimizelySDK.Tests/DecisionServiceTest.cs | 9 ++++--- .../OptimizelyUserContextTest.cs | 27 ++++++++++--------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/OptimizelySDK.Tests/DecisionServiceTest.cs b/OptimizelySDK.Tests/DecisionServiceTest.cs index 82096156..862d12f5 100644 --- a/OptimizelySDK.Tests/DecisionServiceTest.cs +++ b/OptimizelySDK.Tests/DecisionServiceTest.cs @@ -150,15 +150,18 @@ public void TestGetVariationLogsErrorWhenUserProfileMapItsNull() var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, UserProfileServiceMock.Object, LoggerMock.Object); var options = new OptimizelyDecideOption[] { OptimizelyDecideOption.INCLUDE_REASONS }; + var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), + LoggerMock.Object); + OptimizelyUserContextMock = new Mock(optlyObject, + WhitelistedUserId, new UserAttributes(), ErrorHandlerMock.Object, + LoggerMock.Object); OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns(GenericUserId); var variationResult = decisionService.GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig, options); Assert.AreEqual(variationResult.DecisionReasons.ToReport(true)[0], - "We were unable to get a user profile map from the UserProfileService."); - Assert.AreEqual(variationResult.DecisionReasons.ToReport(true)[1], "Audiences for experiment \"etag3\" collectively evaluated to FALSE"); - Assert.AreEqual(variationResult.DecisionReasons.ToReport(true)[2], + Assert.AreEqual(variationResult.DecisionReasons.ToReport(true)[1], "User \"genericUserId\" does not meet conditions to be in experiment \"etag3\"."); } diff --git a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs index cb0ee9d2..66e974c9 100644 --- a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs +++ b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs @@ -428,27 +428,27 @@ public void DecideWhenConfigIsNull() [Test] public void SeparateDecideShouldHaveSameNumberOfUpsSaveAndLookup() { - var experimentFlagKey = "double_single_variable_feature"; - var rolloutFlagKey = "boolean_single_variable_feature"; + var flag1 = "double_single_variable_feature"; + var flag2 = "integer_single_variable_feature"; var userProfileServiceMock = MakeUserProfileServiceMock(); var saveArgsCollector = new List>(); userProfileServiceMock.Setup(up => up.Save(Capture.In(saveArgsCollector))); var optimizely = new Optimizely(TestData.Datafile, EventDispatcherMock.Object, LoggerMock.Object, ErrorHandlerMock.Object, userProfileServiceMock.Object); var user = optimizely.CreateUserContext(UserID); - var expectedUserProfileExperiment = new UserProfile(UserID, new Dictionary + var flag1UserProfile = new UserProfile(UserID, new Dictionary { { "224", new Decision("280") }, { "122238", new Decision("122240") }, - }); - var expectedUserProfileRollout = new UserProfile(UserID, new Dictionary + }); + var flag2UserProfile = new UserProfile(UserID, new Dictionary { - { "999", new Decision("99") }, - { "99999", new Decision("999") }, + { "224", new Decision("280") }, + { "122241", new Decision("122242") }, }); - user.Decide(experimentFlagKey); - user.Decide(rolloutFlagKey); + user.Decide(flag1); + user.Decide(flag2); LoggerMock.Verify( l => l.Log(LogLevel.INFO, @@ -460,8 +460,8 @@ public void SeparateDecideShouldHaveSameNumberOfUpsSaveAndLookup() userProfileServiceMock.Verify(l => l.Lookup(UserID), Times.Exactly(2)); userProfileServiceMock.Verify(l => l.Save(It.IsAny>()), Times.Exactly(2)); - Assert.AreEqual(saveArgsCollector[0], expectedUserProfileExperiment.ToMap()); - Assert.AreEqual(saveArgsCollector[1], expectedUserProfileRollout.ToMap()); + Assert.AreEqual(saveArgsCollector[0], flag1UserProfile.ToMap()); + Assert.AreEqual(saveArgsCollector[1], flag2UserProfile.ToMap()); } [Test] @@ -513,6 +513,8 @@ public void DecideForKeysWithUpsShouldOnlyLookupSaveOnceWithMultipleFlags() { { "224", new Decision("280") }, { "122238", new Decision("122240") }, + { "7723330021", new Decision(null) }, + { "7718750065", new Decision(null) }, }); userContext.DecideForKeys(flagKeys); @@ -579,7 +581,8 @@ public void DecideAllWithUpsShouldOnlyLookupSaveOnce() { "122238", new Decision("122240") }, { "122241", new Decision("122242") }, { "122235", new Decision("122236") }, - { "188880", new Decision("188881") }, + { "7723330021", new Decision(null) }, + { "7718750065", new Decision(null) }, }); user.DecideAll(); From c23e898223924bf7d5edfeb5e10de68b7969b265 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Tue, 22 Oct 2024 12:31:43 -0400 Subject: [PATCH 12/28] refactor: rename to match Java ref imp --- OptimizelySDK/Optimizely.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index 9f0b4a6e..5c678f99 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -878,11 +878,11 @@ OptimizelyDecideOption[] options ErrorHandler, Logger); } - var filteredOptions = GetAllOptions(options). + var allOptions = GetAllOptions(options). Where(opt => opt != OptimizelyDecideOption.ENABLED_FLAGS_ONLY). ToArray(); - return DecideForKeys(user, new[] { key }, filteredOptions, true)[key]; + return DecideForKeys(user, new[] { key }, allOptions, true)[key]; } internal Dictionary DecideAll(OptimizelyUserContext user, From 03037e2f2059c1e1535acb9fee9a434e75b59b86 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Tue, 22 Oct 2024 12:42:54 -0400 Subject: [PATCH 13/28] refactor: extract UserProfileTracker --- OptimizelySDK/Bucketing/DecisionService.cs | 116 +++--------------- OptimizelySDK/Bucketing/UserProfileTracker.cs | 108 ++++++++++++++++ OptimizelySDK/OptimizelySDK.csproj | 1 + 3 files changed, 124 insertions(+), 101 deletions(-) create mode 100644 OptimizelySDK/Bucketing/UserProfileTracker.cs diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs index aac103d4..7167779e 100644 --- a/OptimizelySDK/Bucketing/DecisionService.cs +++ b/OptimizelySDK/Bucketing/DecisionService.cs @@ -41,9 +41,9 @@ public class DecisionService public const string LOGGING_KEY_TYPE_RULE = "rule"; private Bucketer Bucketer; - private static IErrorHandler ErrorHandler; + private readonly IErrorHandler ErrorHandler; private static UserProfileService UserProfileService; - private static ILogger Logger; + private readonly ILogger Logger; /// /// Associative array of user IDs to an associative array @@ -118,7 +118,8 @@ OptimizelyDecideOption[] options if (UserProfileService != null && !ignoreUps) { - userProfileTracker = new UserProfileTracker(user.GetUserId()); + userProfileTracker = new UserProfileTracker(UserProfileService, user.GetUserId(), + Logger, ErrorHandler); userProfileTracker.LoadUserProfile(reasons); } @@ -690,7 +691,8 @@ public virtual Result GetVariationForFeatureExperiment( } else { - var decisionResponse = GetVariation(experiment, user, config, options, userProfileTracker); + var decisionResponse = GetVariation(experiment, user, config, options, + userProfileTracker); reasons += decisionResponse?.DecisionReasons; decisionVariation = decisionResponse.ResultObject; @@ -730,97 +732,6 @@ public virtual Result GetVariationForFeature(FeatureFlag featur new OptimizelyDecideOption[] { }); } - public class UserProfileTracker - { - public UserProfile UserProfile { get; private set; } - public bool ProfileUpdated { get; private set; } - private string UserId { get; } - - public UserProfileTracker(string userId) - { - UserId = userId; - ProfileUpdated = false; - UserProfile = null; - } - - public void LoadUserProfile(DecisionReasons reasons) - { - try - { - var userProfileMap = UserProfileService.Lookup(UserId); - if (userProfileMap == null) - { - Logger.Log(LogLevel.INFO, - reasons.AddInfo( - "We were unable to get a user profile map from the UserProfileService.")); - } - else if (UserProfileUtil.IsValidUserProfileMap(userProfileMap)) - { - UserProfile = UserProfileUtil.ConvertMapToUserProfile(userProfileMap); - } - else - { - Logger.Log(LogLevel.WARN, - reasons.AddInfo("The UserProfileService returned an invalid map.")); - } - } - catch (Exception exception) - { - Logger.Log(LogLevel.ERROR, reasons.AddInfo(exception.Message)); - ErrorHandler.HandleError( - new Exceptions.OptimizelyRuntimeException(exception.Message)); - } - - if (UserProfile == null) - { - UserProfile = new UserProfile(UserId, new Dictionary()); - } - } - - 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, - $"Saved variation \"{variationId}\" of experiment \"{experimentId}\" for user \"{UserProfile.UserId}\"."); - } - - public void SaveUserProfile() - { - if (!ProfileUpdated) - { - return; - } - - try - { - UserProfileService.Save(UserProfile.ToMap()); - Logger.Log(LogLevel.INFO, - $"Saved user profile of user \"{UserProfile.UserId}\"."); - } - catch (Exception exception) - { - Logger.Log(LogLevel.WARN, - $"Failed to save user profile of user \"{UserProfile.UserId}\"."); - ErrorHandler.HandleError(new Exceptions.OptimizelyRuntimeException(exception.Message)); - } - } - } - public virtual List> GetVariationsForFeatureList( List featureFlags, OptimizelyUserContext user, @@ -836,7 +747,8 @@ OptimizelyDecideOption[] options if (UserProfileService != null && !ignoreUps) { - userProfileTracker = new UserProfileTracker(user.GetUserId()); + userProfileTracker = new UserProfileTracker(UserProfileService, user.GetUserId(), + Logger, ErrorHandler); userProfileTracker.LoadUserProfile(upsReasons); } @@ -883,7 +795,8 @@ OptimizelyDecideOption[] options } } - if (UserProfileService != null && !ignoreUps && userProfileTracker?.ProfileUpdated == true) + if (UserProfileService != null && !ignoreUps && + userProfileTracker?.ProfileUpdated == true) { userProfileTracker.SaveUserProfile(); } @@ -909,10 +822,11 @@ OptimizelyDecideOption[] options ) { return GetVariationsForFeatureList(new List { featureFlag }, - user, - config, - filteredAttributes, - options).First(); + user, + config, + filteredAttributes, + options). + First(); } /// diff --git a/OptimizelySDK/Bucketing/UserProfileTracker.cs b/OptimizelySDK/Bucketing/UserProfileTracker.cs new file mode 100644 index 00000000..226cca48 --- /dev/null +++ b/OptimizelySDK/Bucketing/UserProfileTracker.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using OptimizelySDK.Entity; +using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Logger; +using OptimizelySDK.OptimizelyDecisions; + +namespace OptimizelySDK.Bucketing +{ + public class UserProfileTracker + { + public UserProfile UserProfile { get; private set; } + public bool ProfileUpdated { get; private set; } + + private readonly UserProfileService _userProfileService; + private readonly string _userId; + private readonly ILogger _logger; + private readonly IErrorHandler _errorHandler; + + public UserProfileTracker(UserProfileService userProfileService, string userId, ILogger logger, IErrorHandler errorHandler) + { + _userProfileService = userProfileService; + _userId = userId; + _logger = logger; + _errorHandler = errorHandler; + ProfileUpdated = false; + UserProfile = null; + } + + public void LoadUserProfile(DecisionReasons reasons) + { + try + { + var userProfileMap = _userProfileService.Lookup(_userId); + if (userProfileMap == null) + { + _logger.Log(LogLevel.INFO, + reasons.AddInfo( + "We were unable to get a user profile map from the UserProfileService.")); + } + else if (UserProfileUtil.IsValidUserProfileMap(userProfileMap)) + { + UserProfile = UserProfileUtil.ConvertMapToUserProfile(userProfileMap); + } + else + { + _logger.Log(LogLevel.WARN, + reasons.AddInfo("The UserProfileService returned an invalid map.")); + } + } + catch (Exception exception) + { + _logger.Log(LogLevel.ERROR, reasons.AddInfo(exception.Message)); + _errorHandler.HandleError( + new Exceptions.OptimizelyRuntimeException(exception.Message)); + } + + if (UserProfile == null) + { + UserProfile = new UserProfile(_userId, new Dictionary()); + } + } + + 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, + $"Saved variation \"{variationId}\" of experiment \"{experimentId}\" for user \"{UserProfile.UserId}\"."); + } + + public void SaveUserProfile() + { + if (!ProfileUpdated) + { + return; + } + + try + { + _userProfileService.Save(UserProfile.ToMap()); + _logger.Log(LogLevel.INFO, + $"Saved user profile of user \"{UserProfile.UserId}\"."); + } + catch (Exception exception) + { + _logger.Log(LogLevel.WARN, + $"Failed to save user profile of user \"{UserProfile.UserId}\"."); + _errorHandler.HandleError( + new Exceptions.OptimizelyRuntimeException(exception.Message)); + } + } + } +} diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index 1812a2ad..5f041ac1 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -75,6 +75,7 @@ + From 2ea917eba74c23779f4f1ed852890bd20d341157 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Tue, 22 Oct 2024 16:22:12 -0400 Subject: [PATCH 14/28] test: fixes --- OptimizelySDK.Tests/DecisionServiceTest.cs | 34 +++++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/OptimizelySDK.Tests/DecisionServiceTest.cs b/OptimizelySDK.Tests/DecisionServiceTest.cs index 862d12f5..02e2138d 100644 --- a/OptimizelySDK.Tests/DecisionServiceTest.cs +++ b/OptimizelySDK.Tests/DecisionServiceTest.cs @@ -1,6 +1,6 @@ /** * - * Copyright 2017-2021, Optimizely and contributors + * Copyright 2017-2021, 2024 Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -65,8 +65,8 @@ public void SetUp() DecisionService = new DecisionService(new Bucketer(LoggerMock.Object), ErrorHandlerMock.Object, null, LoggerMock.Object); DecisionServiceMock = new Mock(BucketerMock.Object, - ErrorHandlerMock.Object, null, LoggerMock.Object) - { CallBase = true }; + ErrorHandlerMock.Object, null, LoggerMock.Object) + { CallBase = true }; DecisionReasons = new DecisionReasons(); VariationWithKeyControl = @@ -294,6 +294,9 @@ public void TestBucketReturnsVariationStoredInUserProfile() { var experiment = ProjectConfig.Experiments[6]; var variation = experiment.Variations[0]; + var variationResult = Result.NewResult( + experiment.Variations[0], + DecisionReasons); var decision = new Decision(variation.Id); var userProfile = new UserProfile(UserProfileId, new Dictionary @@ -303,8 +306,10 @@ public void TestBucketReturnsVariationStoredInUserProfile() UserProfileServiceMock.Setup(_ => _.Lookup(UserProfileId)).Returns(userProfile.ToMap()); - var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + BucketerMock. + Setup(bm => bm.Bucket(ProjectConfig, experiment, It.IsAny(), + It.IsAny())). + Returns(variationResult); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -313,6 +318,8 @@ public void TestBucketReturnsVariationStoredInUserProfile() LoggerMock.Object); OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns(UserProfileId); + var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, + UserProfileServiceMock.Object, LoggerMock.Object); var actualVariation = decisionService.GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig); @@ -739,7 +746,8 @@ public void TestGetVariationForFeatureExperimentGivenNonMutexGroupAndUserIsBucke DecisionServiceMock.Setup(ds => ds.GetVariation( ProjectConfig.GetExperimentFromKey("test_experiment_multivariate"), OptimizelyUserContextMock.Object, ProjectConfig, - It.IsAny())). + It.IsAny(), It.IsAny(), + It.IsAny())). Returns(variation); var featureFlag = ProjectConfig.GetFeatureFlagFromKey("multi_variate_feature"); @@ -792,13 +800,18 @@ public void TestGetVariationForFeatureExperimentGivenMutexGroupAndUserIsBucketed [Test] public void TestGetVariationForFeatureExperimentGivenMutexGroupAndUserNotBucketed() { - var mutexExperiment = ProjectConfig.GetExperimentFromKey("group_experiment_1"); + var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), + LoggerMock.Object); + OptimizelyUserContextMock = new Mock(optlyObject, + WhitelistedUserId, new UserAttributes(), ErrorHandlerMock.Object, + LoggerMock.Object); OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns("user1"); DecisionServiceMock. Setup(ds => ds.GetVariation(It.IsAny(), It.IsAny(), ProjectConfig, - It.IsAny())). + It.IsAny(), It.IsAny(), + It.IsAny())). Returns(Result.NullResult(null)); var featureFlag = ProjectConfig.GetFeatureFlagFromKey("boolean_feature"); @@ -1312,6 +1325,11 @@ public void TestGetVariationForFeatureWhenTheUserIsBuckedtedInBothExperimentAndR WhitelistedUserId, userAttributes, ErrorHandlerMock.Object, LoggerMock.Object); OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns(UserProfileId); + BucketerMock. + Setup(bm => bm.Bucket(ProjectConfig, experiment, It.IsAny(), + It.IsAny())). + Returns(variation); + DecisionServiceMock.Setup(ds => ds.GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig, It.IsAny())). From ecda765713d2f298c526a10ad794c46a61f61836 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Tue, 22 Oct 2024 17:31:09 -0400 Subject: [PATCH 15/28] fix: tests & code under tests --- .../OptimizelyUserContextTest.cs | 19 ++++++++++--------- OptimizelySDK/Optimizely.cs | 3 ++- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs index 66e974c9..76d0d8b8 100644 --- a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs +++ b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs @@ -61,7 +61,7 @@ public void SetUp() Optimizely = new Optimizely(TestData.Datafile, EventDispatcherMock.Object, LoggerMock.Object, ErrorHandlerMock.Object); } - + private Mock MakeUserProfileServiceMock() { var projectConfig = DatafileProjectConfig.Create(TestData.Datafile, LoggerMock.Object, @@ -440,7 +440,7 @@ public void SeparateDecideShouldHaveSameNumberOfUpsSaveAndLookup() { { "224", new Decision("280") }, { "122238", new Decision("122240") }, - }); + }); var flag2UserProfile = new UserProfile(UserID, new Dictionary { { "224", new Decision("280") }, @@ -531,7 +531,7 @@ public void DecideForKeysWithUpsShouldOnlyLookupSaveOnceWithMultipleFlags() Times.Once); Assert.AreEqual(saveArgsCollector.First(), expectedUserProfile.ToMap()); } - + [Test] public void DecideForKeysWithOneFlag() { @@ -561,11 +561,11 @@ public void DecideForKeysWithOneFlag() new string[0]); Assert.IsTrue(TestData.CompareObjects(decision, expDecision)); } - + #endregion DecideForKeys - + #region DecideAll - + [Test] public void DecideAllWithUpsShouldOnlyLookupSaveOnce() { @@ -595,8 +595,9 @@ public void DecideAllWithUpsShouldOnlyLookupSaveOnce() l => l.Log(LogLevel.ERROR, "The UserProfileService returned an invalid map."), Times.Never); userProfileServiceMock.Verify(l => l.Lookup(UserID), Times.Once); - userProfileServiceMock.Verify(l => l.Save(It.IsAny>()), Times.Once); - Assert.AreEqual(saveArgsCollector.First(), expectedUserProfile.ToMap()); + userProfileServiceMock.Verify(l => l.Save(It.IsAny>()), + Times.Once); + Assert.AreEqual(saveArgsCollector.First(), expectedUserProfile.ToMap()); } [Test] @@ -806,7 +807,7 @@ public void DecideAllAllFlags() null, flagKey10, user, - new string[0]); + new[] { "Variable value for key \"any_key\" is invalid or wrong type." }); Assert.IsTrue(TestData.CompareObjects(decisions[flagKey10], expDecision10)); } diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index 5c678f99..775b9a2d 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -1040,7 +1040,8 @@ ProjectConfig projectConfig decisionSource = flagDecision.Source; } - var reasonsToReport = decisionReasons.ToReport().ToArray(); + var includeReasons= allOptions.Contains(OptimizelyDecideOption.INCLUDE_REASONS); + var reasonsToReport = decisionReasons.ToReport(includeReasons).ToArray(); var variationKey = flagDecision.Variation?.Key; // TODO: add ruleKey values when available later. use a copy of experimentKey until then. // add to event metadata as well (currently set to experimentKey) From e0124c24f43d27a2480c5ad3f06e4b7fe0efea74 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Tue, 22 Oct 2024 17:36:41 -0400 Subject: [PATCH 16/28] ci: also kick off CI if a PR turns to ready for review --- .github/workflows/csharp.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 8a6f6426..5fe0dc38 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -5,6 +5,8 @@ on: branches: [ master ] pull_request: branches: [ master ] + pull_request_target: + types: [ready_for_review] jobs: failOnDraftPullRequest: From 097e90c005cfe160dcb133d565d6ff5f1059bdf0 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Tue, 22 Oct 2024 17:43:06 -0400 Subject: [PATCH 17/28] ci: try to ready for review trigger --- .github/workflows/csharp.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 5fe0dc38..2f13b031 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -5,8 +5,7 @@ on: branches: [ master ] pull_request: branches: [ master ] - pull_request_target: - types: [ready_for_review] + types: [ opened, synchronize, reopened, ready_for_review ] jobs: failOnDraftPullRequest: From dbf7eb17c1a36c72ff56cc706cdb82114ac12a9a Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Tue, 22 Oct 2024 17:44:54 -0400 Subject: [PATCH 18/28] ci: for now just manually kick it off --- .github/workflows/csharp.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 2f13b031..8a6f6426 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -5,7 +5,6 @@ on: branches: [ master ] pull_request: branches: [ master ] - types: [ opened, synchronize, reopened, ready_for_review ] jobs: failOnDraftPullRequest: From 5d0584fbe9283c4ebfa11bd957412e34445d1c2a Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Tue, 22 Oct 2024 17:48:35 -0400 Subject: [PATCH 19/28] ci: remove Fail If Draft Pull Request --- .github/workflows/csharp.yml | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 8a6f6426..7228976e 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -5,21 +5,12 @@ on: branches: [ master ] pull_request: branches: [ master ] + types: [ opened, synchronize, reopened, ready_for_review ] -jobs: - failOnDraftPullRequest: - name: Fail If Draft Pull Request - if: github.event.pull_request.draft == true - runs-on: ubuntu-latest - steps: - - name: Fails Draft pull request. - run: | - echo "Draft pull requests should not trigger CI. Please mark the pull request as Ready for review to continue." - exit 1 - +jobs: lintCodebase: - name: Lint Codebase - needs: [ failOnDraftPullRequest ] + name: Lint Codebase if Not Draft + if: github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - name: Checkout code From 99d59c1cf9b2f3675bdaa1b8b79a9fb7a19ae080 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Wed, 23 Oct 2024 09:55:02 -0400 Subject: [PATCH 20/28] fix: lints & copyright dates --- OptimizelySDK.Tests/DecisionServiceTest.cs | 2 +- OptimizelySDK/Bucketing/DecisionService.cs | 15 +++------ OptimizelySDK/Optimizely.cs | 38 +++++++++------------- 3 files changed, 21 insertions(+), 34 deletions(-) diff --git a/OptimizelySDK.Tests/DecisionServiceTest.cs b/OptimizelySDK.Tests/DecisionServiceTest.cs index 02e2138d..d1bf15af 100644 --- a/OptimizelySDK.Tests/DecisionServiceTest.cs +++ b/OptimizelySDK.Tests/DecisionServiceTest.cs @@ -66,7 +66,7 @@ public void SetUp() ErrorHandlerMock.Object, null, LoggerMock.Object); DecisionServiceMock = new Mock(BucketerMock.Object, ErrorHandlerMock.Object, null, LoggerMock.Object) - { CallBase = true }; + { CallBase = true }; DecisionReasons = new DecisionReasons(); VariationWithKeyControl = diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs index 7167779e..f001da69 100644 --- a/OptimizelySDK/Bucketing/DecisionService.cs +++ b/OptimizelySDK/Bucketing/DecisionService.cs @@ -264,8 +264,7 @@ ProjectConfig config if (experimentToVariationMap.ContainsKey(experimentId) == false) { Logger.Log(LogLevel.DEBUG, - $@"No experiment ""{experimentKey}"" mapped to user ""{userId - }"" in the forced variation map."); + $@"No experiment ""{experimentKey}"" mapped to user ""{userId}"" in the forced variation map."); return Result.NullResult(reasons); } @@ -274,8 +273,7 @@ ProjectConfig config if (string.IsNullOrEmpty(variationId)) { Logger.Log(LogLevel.DEBUG, - $@"No variation mapped to experiment ""{experimentKey - }"" in the forced variation map."); + $@"No variation mapped to experiment ""{experimentKey}"" in the forced variation map."); return Result.NullResult(reasons); } @@ -288,8 +286,7 @@ ProjectConfig config } Logger.Log(LogLevel.DEBUG, - reasons.AddInfo($@"Variation ""{variationKey}"" is mapped to experiment ""{ - experimentKey}"" and user ""{userId}"" in the forced variation map")); + reasons.AddInfo($@"Variation ""{variationKey}"" is mapped to experiment ""{experimentKey}"" and user ""{userId}"" in the forced variation map")); var variation = config.GetVariationFromKey(experimentKey, variationKey); @@ -333,8 +330,7 @@ ProjectConfig config } Logger.Log(LogLevel.DEBUG, - $@"Variation mapped to experiment ""{experimentKey - }"" has been removed for user ""{userId}""."); + $@"Variation mapped to experiment ""{experimentKey}"" has been removed for user ""{userId}""."); return true; } @@ -356,8 +352,7 @@ ProjectConfig config ForcedVariationMap[userId][experimentId] = variationId; Logger.Log(LogLevel.DEBUG, - $@"Set variation ""{variationId}"" for experiment ""{experimentId}"" and user ""{ - userId}"" in the forced variation map."); + $@"Set variation ""{variationId}"" for experiment ""{experimentId}"" and user ""{userId}"" in the forced variation map."); return true; } diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index 775b9a2d..b1766985 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -1,5 +1,5 @@ /* - * Copyright 2017-2023, Optimizely + * Copyright 2017-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use file except in compliance with the License. @@ -573,8 +573,7 @@ public virtual bool IsFeatureEnabled(string featureKey, string userId, else { Logger.Log(LogLevel.INFO, - $@"The user ""{userId}"" is not being experimented on feature ""{featureKey - }""."); + $@"The user ""{userId}"" is not being experimented on feature ""{featureKey}""."); } } @@ -624,8 +623,7 @@ public virtual T GetFeatureVariableValueForType(string featureKey, string var if (config == null) { Logger.Log(LogLevel.ERROR, - $@"Datafile has invalid format. Failing '{ - FeatureVariable.GetFeatureVariableTypeName(variableType)}'."); + $@"Datafile has invalid format. Failing '{FeatureVariable.GetFeatureVariableTypeName(variableType)}'."); return default; } @@ -649,15 +647,13 @@ public virtual T GetFeatureVariableValueForType(string featureKey, string var if (featureVariable == null) { Logger.Log(LogLevel.ERROR, - $@"No feature variable was found for key ""{variableKey}"" in feature flag ""{ - featureKey}""."); + $@"No feature variable was found for key ""{variableKey}"" in feature flag ""{featureKey}""."); return default; } else if (featureVariable.Type != variableType) { Logger.Log(LogLevel.ERROR, - $@"Variable is of type ""{featureVariable.Type - }"", but you requested it as type ""{variableType}""."); + $@"Variable is of type ""{featureVariable.Type}"", but you requested it as type ""{variableType}""."); return default; } @@ -681,28 +677,24 @@ public virtual T GetFeatureVariableValueForType(string featureKey, string var { variableValue = featureVariableUsageInstance.Value; Logger.Log(LogLevel.INFO, - $@"Got variable value ""{variableValue}"" for variable ""{variableKey - }"" of feature flag ""{featureKey}""."); + $@"Got variable value ""{variableValue}"" for variable ""{variableKey}"" of feature flag ""{featureKey}""."); } else { Logger.Log(LogLevel.INFO, - $@"Feature ""{featureKey}"" is not enabled for user {userId - }. Returning the default variable value ""{variableValue}""."); + $@"Feature ""{featureKey}"" is not enabled for user {userId}. Returning the default variable value ""{variableValue}""."); } } else { Logger.Log(LogLevel.INFO, - $@"Variable ""{variableKey}"" is not used in variation ""{variation.Key - }"", returning default value ""{variableValue}""."); + $@"Variable ""{variableKey}"" is not used in variation ""{variation.Key}"", returning default value ""{variableValue}""."); } } else { Logger.Log(LogLevel.INFO, - $@"User ""{userId}"" is not in any variation for feature flag ""{featureKey - }"", returning default value ""{variableValue}""."); + $@"User ""{userId}"" is not in any variation for feature flag ""{featureKey}"", returning default value ""{variableValue}""."); } var sourceInfo = new Dictionary(); @@ -951,12 +943,12 @@ internal Dictionary DecideForKeys(OptimizelyUserCont var decisionReasons = new DecisionReasons(); decisionReasonsMap.Add(key, decisionReasons); - + var optimizelyDecisionContext = new OptimizelyDecisionContext(key); var forcedDecisionVariation = DecisionService.ValidatedForcedDecision(optimizelyDecisionContext, projectConfig, user); decisionReasons += forcedDecisionVariation.DecisionReasons; - + if (forcedDecisionVariation.ResultObject != null) { flagDecisions.Add(key, new FeatureDecision(null, @@ -1040,7 +1032,7 @@ ProjectConfig projectConfig decisionSource = flagDecision.Source; } - var includeReasons= allOptions.Contains(OptimizelyDecideOption.INCLUDE_REASONS); + var includeReasons = allOptions.Contains(OptimizelyDecideOption.INCLUDE_REASONS); var reasonsToReport = decisionReasons.ToReport(includeReasons).ToArray(); var variationKey = flagDecision.Variation?.Key; // TODO: add ruleKey values when available later. use a copy of experimentKey until then. @@ -1071,8 +1063,8 @@ ProjectConfig projectConfig { "reasons", reasonsToReport }, { "decisionEventDispatched", decisionEventDispatched }, }; - - NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Decision, + + NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Decision, DecisionNotificationTypes.FLAG, userId, user.GetAttributes(), decisionInfo); return new OptimizelyDecision( @@ -1114,7 +1106,7 @@ private Result> GetDecisionVariableMap(FeatureFlag fl valuesMap[variable.Key] = convertedValue; } - + return Result>.NewResult(valuesMap, reasons); } From b439dcbf34e208643b4b21f15c18f833ada050ad Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Wed, 23 Oct 2024 09:55:32 -0400 Subject: [PATCH 21/28] ci: bump action versions --- .github/workflows/csharp.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 7228976e..6c746648 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -7,19 +7,19 @@ on: branches: [ master ] types: [ opened, synchronize, reopened, ready_for_review ] -jobs: +jobs: lintCodebase: name: Lint Codebase if Not Draft if: github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: # Full git history is needed to get a proper list of changed files fetch-depth: 0 - name: Run Super-Linter - uses: github/super-linter@v4 + uses: github/super-linter@v7.1.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VALIDATE_ALL_CODEBASE: false @@ -39,7 +39,7 @@ jobs: CURRENT_BRANCH: ${{ github.head_ref || github.ref_name }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Add msbuild to PATH uses: microsoft/setup-msbuild@v1 - name: Setup NuGet @@ -67,7 +67,7 @@ jobs: CURRENT_BRANCH: ${{ github.head_ref || github.ref_name }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v2 with: @@ -90,7 +90,7 @@ jobs: CURRENT_BRANCH: ${{ github.head_ref || github.ref_name }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v2 with: From 5225f7eb5e373f40b104ec4f078b6d9b6a497faf Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Wed, 23 Oct 2024 09:55:49 -0400 Subject: [PATCH 22/28] chore: csproj auto-edit --- OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj index 329b8b72..9eb81c62 100644 --- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj +++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj @@ -160,4 +160,7 @@ + + + From 62022c3dd2851675cdcc728702dd5f0f2503342a Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Wed, 23 Oct 2024 09:58:15 -0400 Subject: [PATCH 23/28] fix: super-linter version --- .github/workflows/csharp.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 6c746648..b55407af 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -19,7 +19,7 @@ jobs: # Full git history is needed to get a proper list of changed files fetch-depth: 0 - name: Run Super-Linter - uses: github/super-linter@v7.1.0 + uses: github/super-linter@v7 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VALIDATE_ALL_CODEBASE: false From 81aae4ddd4b686eb9823345c8451f3f561d11b72 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Wed, 23 Oct 2024 10:44:28 -0400 Subject: [PATCH 24/28] fix: add missing refs to UserProfileTracker.cs --- OptimizelySDK.Net35/OptimizelySDK.Net35.csproj | 5 ++++- OptimizelySDK.Net40/OptimizelySDK.Net40.csproj | 5 ++++- .../OptimizelySDK.NetStandard16.csproj | 1 + .../OptimizelySDK.NetStandard20.csproj | 3 +++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj index 8daf74ce..a4495471 100644 --- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj +++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj @@ -209,6 +209,9 @@ Bucketing\UserProfile.cs + + Bucketing\UserProfileTracker.cs + Bucketing\ExperimentUtils @@ -356,4 +359,4 @@ --> - \ No newline at end of file + diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj index a0f98b77..05785575 100644 --- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj +++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj @@ -208,6 +208,9 @@ Bucketing\UserProfile.cs + + Bucketing\UserProfileTracker.cs + Bucketing\ExperimentUtils @@ -366,4 +369,4 @@ - \ No newline at end of file + diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj index 9eb81c62..0c327dc4 100644 --- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj +++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj @@ -72,6 +72,7 @@ + diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj index 6d7ea638..b7114653 100644 --- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj +++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj @@ -133,6 +133,9 @@ Bucketing\UserProfile.cs + + Bucketing\UserProfileTracker.cs + Bucketing\UserProfileService.cs From eced9ed0b20d23e63596224ac21230aafe39df72 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Wed, 23 Oct 2024 12:25:18 -0400 Subject: [PATCH 25/28] revert: accidental csproj references --- OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj | 3 --- 1 file changed, 3 deletions(-) diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj index 0c327dc4..b17f79e7 100644 --- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj +++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj @@ -161,7 +161,4 @@ - - - From 60bcddf0e79c443245ddac73b0165ec0486da552 Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Thu, 24 Oct 2024 14:14:44 -0400 Subject: [PATCH 26/28] ci: lots of updates to how we're releasing --- .github/workflows/csharp_release.yml | 145 +++++++++++++++------------ 1 file changed, 81 insertions(+), 64 deletions(-) diff --git a/.github/workflows/csharp_release.yml b/.github/workflows/csharp_release.yml index 8f989fc6..369b1915 100644 --- a/.github/workflows/csharp_release.yml +++ b/.github/workflows/csharp_release.yml @@ -9,26 +9,23 @@ jobs: name: Set Variables runs-on: ubuntu-latest env: - # ⚠️ IMPORTANT: tag should always start with integer & will be used verbatim to string end TAG: ${{ github.event.release.tag_name }} steps: - - name: Set semantic version variable + - name: Extract semantic version from tag id: set_version run: | - SEMANTIC_VERSION=$(echo "$TAG" | grep -Po "(?<=^|[^0-9])([0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?(-[a-zA-Z]+[0-9]*)?)") + # Remove the "v" prefix if it exists and extract the semantic version number + SEMANTIC_VERSION=$(echo "${TAG}" | grep -Po "(?<=^|[^0-9])([0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?(-[a-zA-Z]+[0-9]*)?)") + SEMANTIC_VERSION=${SEMANTIC_VERSION#"v"} if [ -z "${SEMANTIC_VERSION}" ]; then - echo "Tag did not start with a semantic version number (e.g., #.#.#; #.#.#.#; #.#.#.#-beta)" + echo "Error: Tag '${TAG}' does not start with a valid semantic version number (e.g., #.#.#; #.#.#.#; #.#.#.#-beta)" exit 1 fi + echo "Extracted semantic version: ${SEMANTIC_VERSION}" echo "semantic_version=${SEMANTIC_VERSION}" >> $GITHUB_OUTPUT - - name: Output tag & semantic version - id: outputs - run: | - echo "$TAG" - echo ${{ steps.set_version.outputs.semantic_version }} outputs: tag: $TAG - semanticVersion: ${{ steps.set_version.outputs.semantic_version }} + semanticVersion: ${{ steps.set_version.outputs.semantic_version }} buildFrameworkVersions: name: Build Framework versions @@ -48,9 +45,9 @@ jobs: - name: Build and strongly name assemblies run: msbuild /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk /p:Configuration=Release ./OptimizelySDK.NETFramework.sln - name: Upload Framework artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: nuget-files + name: unsigned-dlls if-no-files-found: error path: ./**/bin/Release/**/Optimizely*.dll @@ -70,9 +67,9 @@ jobs: - name: Build and strongly name assemblies run: dotnet build OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk -c Release - name: Upload Standard 1.6 artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: nuget-files + name: unsigned-dlls if-no-files-found: error path: ./**/bin/Release/**/Optimizely*.dll @@ -92,21 +89,69 @@ jobs: - name: Build and strongly name Standard 2.0 project run: dotnet build OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk -c Release - name: Build and strongly name assemblies - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: nuget-files + name: unsigned-dlls if-no-files-found: error path: ./**/bin/Release/**/Optimizely*.dll - pack: - name: Sign & pack NuGet package + sign: + name: Send DLLs for signing needs: [ variables, buildFrameworkVersions, buildStandard16, buildStandard20 ] runs-on: ubuntu-latest + env: + # TODO: Replace actual values + SIGNING_SERVER_PRIVATE_KEY: ${{ secrets.SIGNING_SERVER_PRIVATE_KEY }} + SIGNING_SERVER_HOST: ${{ secrets.SIGNING_SERVER_HOST }} + SIGNING_SERVER_UPLOAD_PATH: /path/to/UPLOAD/directory + SIGNING_SERVER_DOWNLOAD_PATH: /path/to/DOWNLOAD/directory + steps: + # TODO: Remove this when we're ready to automate + - name: Temporarily halt progress + run: exit 1 + - name: Download the unsigned files + uses: actions/download-artifact@v4 + with: + name: unsigned-dlls + path: ./unsigned-dlls + - name: Setup SSH + uses: shimataro/ssh-key-action@v2 + with: + key: $SIGNING_SERVER_PRIVATE_KEY + - name: Send files to signing server + run: scp -r ./unsigned-dlls $SIGNING_SERVER_HOST:$SIGNING_SERVER_UPLOAD_PATH + - name: Wait for artifact to be published + run: | + for i in {1..60}; do + # Replace with actual path + if ssh $SIGNING_SERVER_HOST "ls $SIGNING_SERVER_DOWNLOAD_PATH"; then + exit 0 + fi + sleep 10 + done + exit 1 + - name: Download signed files + run: | + mkdir ./signed-dlls + scp -r $SIGNING_SERVER_HOST:$SIGNING_SERVER_DOWNLOAD_PATH ./signed-dlls + - name: Delete signed files from server + run: ssh $SIGNING_SERVER_HOST "rm -rf $SIGNING_SERVER_DOWNLOAD_PATH/*" + - name: Upload signed files + uses: actions/upload-artifact@v4 + with: + name: signed-dlls + if-no-files-found: error + path: ./signed-dlls + + pack: + name: Pack NuGet package + needs: [ variables, sign ] + runs-on: ubuntu-latest env: VERSION: ${{ needs.variables.outputs.semanticVersion }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ needs.variables.outputs.tag }} - name: Install mono @@ -114,55 +159,25 @@ jobs: sudo apt update sudo apt install -y mono-devel - name: Download NuGet files - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v4 with: - name: nuget-files - path: ./nuget-files + name: signed-dlls + path: ./signed-dlls - name: Organize files run: | - pushd ./nuget-files + pushd ./signed-files # Move all dlls to the root directory - find . -type f -name "*.dll" -exec mv {} . \; + find . -type f -name "*.dll" -exec mv {} . popd # Create directories mkdir -p nuget/lib/net35/ nuget/lib/net40/ nuget/lib/net45/ nuget/lib/netstandard1.6/ nuget/lib/netstandard2.0/ pushd ./nuget # Move files to directories - mv ../nuget-files/OptimizelySDK.Net35.dll lib/net35/ - mv ../nuget-files/OptimizelySDK.Net40.dll lib/net40/ - mv ../nuget-files/OptimizelySDK.dll lib/net45/ - mv ../nuget-files/OptimizelySDK.NetStandard16.dll lib/netstandard1.6/ - mv ../nuget-files/OptimizelySDK.NetStandard20.dll lib/netstandard2.0/ - popd - - name: Setup signing prerequisites - env: - CERTIFICATE_P12: ${{ secrets.CERTIFICATE_P12 }} - CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} - run: | - pushd ./nuget - echo $CERTIFICATE_P12 | base64 --decode > authenticode.pfx - openssl pkcs12 -in authenticode.pfx -nocerts -nodes -legacy -out key.pem -password env:CERTIFICATE_PASSWORD - openssl rsa -in key.pem -outform PVK -pvk-none -out authenticode.pvk - openssl pkcs12 -in authenticode.pfx -nokeys -nodes -legacy -out cert.pem -password env:CERTIFICATE_PASSWORD - openssl crl2pkcs7 -nocrl -certfile cert.pem -outform DER -out authenticode.spc - popd - - name: Sign the DLLs - run: | - pushd ./nuget - find . -type f -name "*.dll" -print0 | while IFS= read -r -d '' file; do - echo "Signing ${file}" - signcode \ - -spc ./authenticode.spc \ - -v ./authenticode.pvk \ - -a sha1 -$ commercial \ - -n "Optimizely, Inc" \ - -i "https://www.optimizely.com/" \ - -t "http://timestamp.digicert.com" \ - -tr 10 \ - ${file} - rm ${file}.bak - done - rm *.spc *.pem *.pvk *.pfx + mv ../signed-dlls/OptimizelySDK.Net35.dll lib/net35/ + mv ../signed-dlls/OptimizelySDK.Net40.dll lib/net40/ + mv ../signed-dlls/OptimizelySDK.dll lib/net45/ + mv ../signed-dlls/OptimizelySDK.NetStandard16.dll lib/netstandard1.6/ + mv ../signed-dlls/OptimizelySDK.NetStandard20.dll lib/netstandard2.0/ popd - name: Create nuspec # Uses env.VERSION in OptimizelySDK.nuspec.template @@ -175,27 +190,29 @@ jobs: nuget pack OptimizelySDK.nuspec popd - name: Upload nupkg artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: nuget-package if-no-files-found: error path: ./nuget/Optimizely.SDK.${{ env.VERSION }}.nupkg publish: - name: Publish package to NuGet + name: Publish package to NuGet after reviewing the artifact needs: [ variables, pack ] runs-on: ubuntu-latest + # Review the `nuget-package` artifact ensuring the dlls are + # organized and signed before approving. + environment: 'i-reviewed-nuget-package-artifact' env: VERSION: ${{ needs.variables.outputs.semanticVersion }} steps: - name: Download NuGet files - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v4 with: name: nuget-package path: ./nuget - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 - name: Publish NuGet package - # Unset secrets.NUGET_API_KEY to simulate dry run run: | dotnet nuget push ./nuget/Optimizely.SDK.${{ env.VERSION }}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} From 3dc6f0a7480c1d21b4056da47e6b066801250e8e Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Thu, 24 Oct 2024 14:46:21 -0400 Subject: [PATCH 27/28] fix: lint & dir name --- .github/workflows/csharp_release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/csharp_release.yml b/.github/workflows/csharp_release.yml index 369b1915..cd80b0b2 100644 --- a/.github/workflows/csharp_release.yml +++ b/.github/workflows/csharp_release.yml @@ -25,7 +25,7 @@ jobs: echo "semantic_version=${SEMANTIC_VERSION}" >> $GITHUB_OUTPUT outputs: tag: $TAG - semanticVersion: ${{ steps.set_version.outputs.semantic_version }} + semanticVersion: ${{ steps.set_version.outputs.semantic_version }} buildFrameworkVersions: name: Build Framework versions @@ -99,7 +99,7 @@ jobs: name: Send DLLs for signing needs: [ variables, buildFrameworkVersions, buildStandard16, buildStandard20 ] runs-on: ubuntu-latest - env: + env: # TODO: Replace actual values SIGNING_SERVER_PRIVATE_KEY: ${{ secrets.SIGNING_SERVER_PRIVATE_KEY }} SIGNING_SERVER_HOST: ${{ secrets.SIGNING_SERVER_HOST }} @@ -165,7 +165,7 @@ jobs: path: ./signed-dlls - name: Organize files run: | - pushd ./signed-files + pushd ./signed-dlls # Move all dlls to the root directory find . -type f -name "*.dll" -exec mv {} . popd @@ -202,7 +202,7 @@ jobs: runs-on: ubuntu-latest # Review the `nuget-package` artifact ensuring the dlls are # organized and signed before approving. - environment: 'i-reviewed-nuget-package-artifact' + environment: 'i-reviewed-nuget-package-artifact' env: VERSION: ${{ needs.variables.outputs.semanticVersion }} steps: From a6abb608d78a963062a9d31a272271af2d7e814a Mon Sep 17 00:00:00 2001 From: Mike Chu Date: Tue, 5 Nov 2024 17:32:48 -0500 Subject: [PATCH 28/28] revert: modifiers on DecisionService & test changes --- OptimizelySDK.Tests/DecisionServiceTest.cs | 6 +++++- OptimizelySDK/Bucketing/DecisionService.cs | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/OptimizelySDK.Tests/DecisionServiceTest.cs b/OptimizelySDK.Tests/DecisionServiceTest.cs index d1bf15af..8fbedf23 100644 --- a/OptimizelySDK.Tests/DecisionServiceTest.cs +++ b/OptimizelySDK.Tests/DecisionServiceTest.cs @@ -160,8 +160,12 @@ public void TestGetVariationLogsErrorWhenUserProfileMapItsNull() var variationResult = decisionService.GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig, options); Assert.AreEqual(variationResult.DecisionReasons.ToReport(true)[0], - "Audiences for experiment \"etag3\" collectively evaluated to FALSE"); + "We were unable to get a user profile map from the UserProfileService."); Assert.AreEqual(variationResult.DecisionReasons.ToReport(true)[1], + "No previously activated variation of experiment \"etag3\" for user \"genericUserId\" found in user profile."); + Assert.AreEqual(variationResult.DecisionReasons.ToReport(true)[2], + "Audiences for experiment \"etag3\" collectively evaluated to FALSE"); + Assert.AreEqual(variationResult.DecisionReasons.ToReport(true)[3], "User \"genericUserId\" does not meet conditions to be in experiment \"etag3\"."); } diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs index f001da69..1e364b29 100644 --- a/OptimizelySDK/Bucketing/DecisionService.cs +++ b/OptimizelySDK/Bucketing/DecisionService.cs @@ -41,9 +41,9 @@ public class DecisionService public const string LOGGING_KEY_TYPE_RULE = "rule"; private Bucketer Bucketer; - private readonly IErrorHandler ErrorHandler; - private static UserProfileService UserProfileService; - private readonly ILogger Logger; + private IErrorHandler ErrorHandler; + private UserProfileService UserProfileService; + private ILogger Logger; /// /// Associative array of user IDs to an associative array pFad - Phonifier reborn

    Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

    Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


    Alternative Proxies:

    Alternative Proxy

    pFad Proxy

    pFad v3 Proxy

    pFad v4 Proxy