diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 4303e0e2..b55407af 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -5,19 +5,21 @@ on: branches: [ master ] pull_request: branches: [ master ] + types: [ opened, synchronize, reopened, ready_for_review ] jobs: lintCodebase: + name: Lint Codebase if Not Draft + if: github.event.pull_request.draft == false runs-on: ubuntu-latest - name: Lint Codebase 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 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VALIDATE_ALL_CODEBASE: false @@ -37,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 @@ -65,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: @@ -88,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: diff --git a/.github/workflows/csharp_release.yml b/.github/workflows/csharp_release.yml index 8f989fc6..cd80b0b2 100644 --- a/.github/workflows/csharp_release.yml +++ b/.github/workflows/csharp_release.yml @@ -9,23 +9,20 @@ 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 }} @@ -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-dlls # 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 }} 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 329b8b72..b17f79e7 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 diff --git a/OptimizelySDK.Tests/DecisionServiceTest.cs b/OptimizelySDK.Tests/DecisionServiceTest.cs index 633847ae..8fbedf23 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,7 +65,7 @@ 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) + ErrorHandlerMock.Object, null, LoggerMock.Object) { CallBase = true }; DecisionReasons = new DecisionReasons(); @@ -150,6 +150,11 @@ 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, @@ -157,8 +162,10 @@ public void TestGetVariationLogsErrorWhenUserProfileMapItsNull() 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"); + "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\"."); } @@ -291,6 +298,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 @@ -300,8 +310,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); @@ -310,6 +322,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); @@ -736,7 +750,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"); @@ -789,13 +804,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"); @@ -856,7 +876,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 +1221,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 +1248,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 +1282,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(), @@ -1309,6 +1329,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())). diff --git a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs index 49fd91a4..76d0d8b8 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; @@ -62,6 +62,22 @@ public void SetUp() 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,9 +425,112 @@ public void DecideWhenConfigIsNull() Assert.IsTrue(TestData.CompareObjects(decision, decisionExpected)); } - #endregion decide + [Test] + public void SeparateDecideShouldHaveSameNumberOfUpsSaveAndLookup() + { + 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 flag1UserProfile = new UserProfile(UserID, new Dictionary + { + { "224", new Decision("280") }, + { "122238", new Decision("122240") }, + }); + var flag2UserProfile = new UserProfile(UserID, new Dictionary + { + { "224", new Decision("280") }, + { "122241", new Decision("122242") }, + }); + + user.Decide(flag1); + user.Decide(flag2); + + 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.Exactly(2)); + userProfileServiceMock.Verify(l => l.Save(It.IsAny>()), + Times.Exactly(2)); + Assert.AreEqual(saveArgsCollector[0], flag1UserProfile.ToMap()); + Assert.AreEqual(saveArgsCollector[1], flag2UserProfile.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 + + [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") }, + { "7723330021", new Decision(null) }, + { "7718750065", new Decision(null) }, + }); + + userContext.DecideForKeys(flagKeys); - #region 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 DecideForKeysWithOneFlag() @@ -443,6 +562,44 @@ public void DecideForKeysWithOneFlag() 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") }, + { "7723330021", new Decision(null) }, + { "7718750065", new Decision(null) }, + }); + + 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() { @@ -650,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.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 e6088d7e..1e364b29 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; @@ -84,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, @@ -99,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, @@ -111,97 +112,107 @@ 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) + { + userProfileTracker = new UserProfileTracker(UserProfileService, user.GetUserId(), + Logger, ErrorHandler); + userProfileTracker.LoadUserProfile(reasons); + } + + var response = GetVariation(experiment, user, config, options, userProfileTracker, + reasons); + + if (UserProfileService != null && !ignoreUps && + userProfileTracker?.ProfileUpdated == true) + { + userProfileTracker.SaveUserProfile(); + } + + 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); } - // check if a forced variation is set - var decisionVariationResult = GetForcedVariation(experiment.Key, userId, config); - reasons += decisionVariationResult.DecisionReasons; - var variation = decisionVariationResult.ResultObject; + var userId = user.GetUserId(); + + 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; } - // 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) + decisionVariation = + GetStoredVariation(experiment, userProfileTracker.UserProfile, config); + reasons += decisionVariation.DecisionReasons; + variation = decisionVariation.ResultObject; + if (variation != null) { - Logger.Log(LogLevel.ERROR, reasons.AddInfo(exception.Message)); - ErrorHandler.HandleError( - new Exceptions.OptimizelyRuntimeException(exception.Message)); + return decisionVariation; } } - 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); - reasons += bucketingIdResult.DecisionReasons; + var bucketingId = GetBucketingId(userId, user.GetAttributes()).ResultObject; - decisionVariationResult = Bucketer.Bucket(config, experiment, - bucketingIdResult.ResultObject, userId); - reasons += decisionVariationResult.DecisionReasons; + decisionVariation = Bucketer.Bucket(config, experiment, bucketingId, userId); + reasons += decisionVariation.DecisionReasons; + variation = decisionVariation.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 { @@ -210,7 +221,7 @@ OptimizelyDecideOption[] options } } - return decisionVariationResult.SetReasons(reasons); + return decisionVariation.SetReasons(reasons); } Logger.Log(LogLevel.INFO, @@ -253,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); } @@ -263,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); } @@ -277,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); @@ -322,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; } @@ -345,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; } @@ -638,7 +644,8 @@ public virtual Result GetVariationForFeatureExperiment( OptimizelyUserContext user, UserAttributes filteredAttributes, ProjectConfig config, - OptimizelyDecideOption[] options + OptimizelyDecideOption[] options, + UserProfileTracker userProfileTracker = null ) { var reasons = new DecisionReasons(); @@ -679,7 +686,8 @@ OptimizelyDecideOption[] options } else { - var decisionResponse = GetVariation(experiment, user, config, options); + var decisionResponse = GetVariation(experiment, user, config, options, + userProfileTracker); reasons += decisionResponse?.DecisionReasons; decisionVariation = decisionResponse.ResultObject; @@ -706,9 +714,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 +727,101 @@ 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, + 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(); + + var ignoreUps = options.Contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); + UserProfileTracker userProfileTracker = null; - if (decisionResult.ResultObject != null) + if (UserProfileService != null && !ignoreUps) { - return Result.NewResult(decisionResult.ResultObject, reasons); + userProfileTracker = new UserProfileTracker(UserProfileService, user.GetUserId(), + Logger, ErrorHandler); + userProfileTracker.LoadUserProfile(upsReasons); } - // 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) { - 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); + 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, userProfileTracker); + 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 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)); + } } - 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) + { + userProfileTracker.SaveUserProfile(); + } + + 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/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/Optimizely.cs b/OptimizelySDK/Optimizely.cs index 9da300f8..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(); @@ -861,6 +853,7 @@ private OptimizelyUserContext CreateUserContextCopy(string userId, ///
  • 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. @@ -877,122 +870,193 @@ OptimizelyDecideOption[] options ErrorHandler, Logger); } - if (key == null) - { - return OptimizelyDecision.NewErrorDecision(key, - user, - DecisionMessage.Reason(DecisionMessage.FLAG_KEY_INVALID, key), - ErrorHandler, Logger); - } + var allOptions = GetAllOptions(options). + Where(opt => opt != OptimizelyDecideOption.ENABLED_FLAGS_ONLY). + ToArray(); + + return DecideForKeys(user, new[] { key }, allOptions, true)[key]; + } - var flag = config.GetFeatureFlagFromKey(key); - if (flag.Key == null) + internal Dictionary DecideAll(OptimizelyUserContext user, + OptimizelyDecideOption[] options + ) + { + var decisionMap = new Dictionary(); + + var projectConfig = ProjectConfigManager?.GetConfig(); + if (projectConfig == null) { - return OptimizelyDecision.NewErrorDecision(key, - user, - DecisionMessage.Reason(DecisionMessage.FLAG_KEY_INVALID, key), - ErrorHandler, Logger); + Logger.Log(LogLevel.ERROR, + "Optimizely instance is not valid, failing DecideAll call."); + return decisionMap; } - var userId = user?.GetUserId(); - var userAttributes = user?.GetAttributes(); - var decisionEventDispatched = false; - var allOptions = GetAllOptions(options); - var decisionReasons = new DecisionReasons(); - FeatureDecision decision = null; + var allFlags = projectConfig.FeatureFlags; + var allFlagKeys = allFlags.Select(v => v.Key).ToArray(); - var decisionContext = new OptimizelyDecisionContext(flag.Key); - var forcedDecisionVariation = - DecisionService.ValidatedForcedDecision(decisionContext, config, user); - decisionReasons += forcedDecisionVariation.DecisionReasons; + return DecideForKeys(user, allFlagKeys, options); + } + + internal Dictionary DecideForKeys(OptimizelyUserContext user, + string[] keys, + OptimizelyDecideOption[] options, + bool ignoreDefaultOptions = false + ) + { + var decisionDictionary = new Dictionary(); - if (forcedDecisionVariation.ResultObject != null) + var projectConfig = ProjectConfigManager?.GetConfig(); + if (projectConfig == null) { - decision = new FeatureDecision(null, forcedDecisionVariation.ResultObject, - FeatureDecision.DECISION_SOURCE_FEATURE_TEST); + Logger.Log(LogLevel.ERROR, + "Optimizely instance is not valid, failing DecideForKeys call."); + return decisionDictionary; } - else + + if (keys.Length == 0) { - var flagDecisionResult = DecisionService.GetVariationForFeature( - flag, - user, - config, - userAttributes, - allOptions - ); - decisionReasons += flagDecisionResult.DecisionReasons; - decision = flagDecisionResult.ResultObject; + return decisionDictionary; } - var featureEnabled = false; + var allOptions = ignoreDefaultOptions ? options : GetAllOptions(options); - if (decision?.Variation != null) - { - featureEnabled = decision.Variation.FeatureEnabled.GetValueOrDefault(); - } + var flagDecisions = new Dictionary(); + var decisionReasonsMap = new Dictionary(); - if (featureEnabled) + var flagsWithoutForcedDecisions = new List(); + + var validKeys = new List(); + + foreach (var key in keys) { - Logger.Log(LogLevel.INFO, - "Feature \"" + key + "\" is enabled for user \"" + userId + "\""); + 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; + } + + validKeys.Add(key); + + 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, + forcedDecisionVariation.ResultObject, + FeatureDecision.DECISION_SOURCE_FEATURE_TEST)); + } + else + { + flagsWithoutForcedDecisions.Add(flag); + } } - else + + var decisionsList = DecisionService.GetVariationsForFeatureList( + flagsWithoutForcedDecisions, user, projectConfig, user.GetAttributes(), + allOptions); + + for (var i = 0; i < decisionsList.Count; i += 1) { - Logger.Log(LogLevel.INFO, - "Feature \"" + key + "\" is not enabled for user \"" + userId + "\""); + var decision = decisionsList[i]; + var flagKey = flagsWithoutForcedDecisions[i].Key; + flagDecisions.Add(flagKey, decision.ResultObject); + decisionReasonsMap[flagKey] += decision.DecisionReasons; } - var variableMap = new Dictionary(); - if (flag?.Variables != null && - !allOptions.Contains(OptimizelyDecideOption.EXCLUDE_VARIABLES)) + foreach (var key in validKeys) { - foreach (var featureVariable in flag?.Variables) + 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) || + optimizelyDecision.Enabled) { - var variableValue = featureVariable.DefaultValue; - if (featureEnabled) - { - var featureVariableUsageInstance = - decision?.Variation.GetFeatureVariableUsageFromId(featureVariable.Id); - if (featureVariableUsageInstance != null) - { - variableValue = featureVariableUsageInstance.Value; - } - } + decisionDictionary.Add(key, optimizelyDecision); + } + } - var typeCastedValue = - GetTypeCastedVariableValue(variableValue, featureVariable.Type); + return decisionDictionary; + } - if (typeCastedValue is OptimizelyJSON) - { - typeCastedValue = ((OptimizelyJSON)typeCastedValue).ToDictionary(); - } + private OptimizelyDecision CreateOptimizelyDecision( + OptimizelyUserContext user, + string flagKey, + FeatureDecision flagDecision, + DecisionReasons decisionReasons, + List allOptions, + ProjectConfig projectConfig + ) + { + var userId = user.GetUserId(); - variableMap.Add(featureVariable.Key, typeCastedValue); + var flagEnabled = false; + if (flagDecision.Variation != null) + { + if (flagDecision.Variation.IsFeatureEnabled) + { + flagEnabled = true; } } - var optimizelyJSON = new OptimizelyJSON(variableMap, ErrorHandler, Logger); + Logger.Log(LogLevel.INFO, + $"Feature \"{flagKey}\" is enabled for user \"{userId}\"? {flagEnabled}"); - var decisionSource = decision?.Source ?? FeatureDecision.DECISION_SOURCE_ROLLOUT; - if (!allOptions.Contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) + var variableMap = new Dictionary(); + if (!allOptions.Contains(OptimizelyDecideOption.EXCLUDE_VARIABLES)) { - decisionEventDispatched = SendImpressionEvent(decision?.Experiment, - decision?.Variation, userId, userAttributes, config, key, decisionSource, - featureEnabled); + var decisionVariables = GetDecisionVariableMap( + projectConfig.GetFeatureFlagFromKey(flagKey), + flagDecision.Variation, + flagEnabled); + variableMap = decisionVariables.ResultObject; + decisionReasons += decisionVariables.DecisionReasons; } - var reasonsToReport = decisionReasons - .ToReport(allOptions.Contains(OptimizelyDecideOption.INCLUDE_REASONS)) - .ToArray(); - var variationKey = decision?.Variation?.Key; + var optimizelyJson = new OptimizelyJSON(variableMap, ErrorHandler, Logger); + + var decisionSource = FeatureDecision.DECISION_SOURCE_ROLLOUT; + if (flagDecision.Source != null) + { + decisionSource = flagDecision.Source; + } + 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. - var ruleKey = decision?.Experiment?.Key; + // 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 { - { "flagKey", key }, - { "enabled", featureEnabled }, + { "flagKey", flagKey }, + { "enabled", flagEnabled }, { "variables", variableMap }, { "variationKey", variationKey }, { "ruleKey", ruleKey }, @@ -1001,72 +1065,49 @@ OptimizelyDecideOption[] options }; NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Decision, - DecisionNotificationTypes.FLAG, userId, - userAttributes ?? new UserAttributes(), decisionInfo); + DecisionNotificationTypes.FLAG, userId, user.GetAttributes(), decisionInfo); return new OptimizelyDecision( variationKey, - featureEnabled, - optimizelyJSON, + flagEnabled, + optimizelyJson, ruleKey, - key, + flagKey, user, reasonsToReport); } - internal Dictionary DecideAll(OptimizelyUserContext user, - OptimizelyDecideOption[] options - ) + private Result> GetDecisionVariableMap(FeatureFlag flag, Variation variation, bool featureEnabled) { - var decisionMap = new Dictionary(); - - var projectConfig = ProjectConfigManager?.GetConfig(); - if (projectConfig == null) - { - Logger.Log(LogLevel.ERROR, - "Optimizely instance is not valid, failing isFeatureEnabled call."); - return decisionMap; - } - - var allFlags = projectConfig.FeatureFlags; - var allFlagKeys = allFlags.Select(v => v.Key).ToArray(); - - return DecideForKeys(user, allFlagKeys, options); - } - - internal Dictionary DecideForKeys(OptimizelyUserContext user, - string[] keys, - OptimizelyDecideOption[] options - ) - { - var decisionDictionary = new Dictionary(); + var reasons = new DecisionReasons(); + var valuesMap = new Dictionary(); - var projectConfig = ProjectConfigManager?.GetConfig(); - if (projectConfig == null) + foreach (var variable in flag.Variables) { - Logger.Log(LogLevel.ERROR, - "Optimizely instance is not valid, failing isFeatureEnabled call."); - return decisionDictionary; - } - - if (keys.Length == 0) - { - return decisionDictionary; - } - - var allOptions = GetAllOptions(options); + var value = variable.DefaultValue; + if (featureEnabled) + { + var instance = variation.GetFeatureVariableUsageFromId(variable.Id); + if (instance != null) + { + value = instance.Value; + } + } - foreach (var key in keys) - { - var decision = Decide(user, key, options); - if (!allOptions.Contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || - decision.Enabled) + var convertedValue = GetTypeCastedVariableValue(value, variable.Type); + if (convertedValue == null) { - decisionDictionary.Add(key, decision); + reasons.AddError(DecisionMessage.Reason(DecisionMessage.VARIABLE_VALUE_INVALID, variable.Key)); } + else if (convertedValue is OptimizelyJSON optimizelyJson) + { + convertedValue = optimizelyJson.ToDictionary(); + } + + valuesMap[variable.Key] = convertedValue; } - return decisionDictionary; + return Result>.NewResult(valuesMap, reasons); } private OptimizelyDecideOption[] GetAllOptions(OptimizelyDecideOption[] options) 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 @@ +