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