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 @@ + 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