From 3abcf563075fd5ba18989b97a7d4d1737e02135a Mon Sep 17 00:00:00 2001
From: Mike Chu <104384559+mikechu-optimizely@users.noreply.github.com>
Date: Thu, 7 Nov 2024 15:10:08 -0500
Subject: [PATCH 01/18] [FSSDK-10848] Update Actions tool versions & add
workflow dispatch (#377)
* ci: bump Actions workflow step versions
* test: add workflow dispatch temporarily for testing
---
.github/workflows/csharp_release.yml | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/csharp_release.yml b/.github/workflows/csharp_release.yml
index cd80b0b2..80461161 100644
--- a/.github/workflows/csharp_release.yml
+++ b/.github/workflows/csharp_release.yml
@@ -3,6 +3,7 @@
on:
release:
types: [ published ] # Trigger on published pre-releases and releases
+ workflow_dispatch:
jobs:
variables:
@@ -33,13 +34,13 @@ jobs:
runs-on: windows-2019 # required version for Framework 4.0
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: ${{ needs.variables.outputs.tag }}
- name: Add msbuild to PATH
- uses: microsoft/setup-msbuild@v1
+ uses: microsoft/setup-msbuild@v2
- name: Setup NuGet
- uses: NuGet/setup-nuget@v1
+ uses: nuget/setup-nuget@v2
- name: Restore NuGet packages
run: nuget restore ./OptimizelySDK.NETFramework.sln
- name: Build and strongly name assemblies
@@ -57,11 +58,11 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: ${{ needs.variables.outputs.tag }}
- name: Setup .NET
- uses: actions/setup-dotnet@v2
+ uses: actions/setup-dotnet@v4
- name: Restore dependencies
run: dotnet restore OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
- name: Build and strongly name assemblies
@@ -79,11 +80,11 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: ${{ needs.variables.outputs.tag }}
- name: Setup .NET
- uses: actions/setup-dotnet@v2
+ uses: actions/setup-dotnet@v4
- name: Restore dependencies
run: dotnet restore OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
- name: Build and strongly name Standard 2.0 project
From 10b8af39c9235338608fa077e9a6c8cbc5cac5cc Mon Sep 17 00:00:00 2001
From: alexjoeyyong <96444887+alexjoeyyong@users.noreply.github.com>
Date: Wed, 22 Jan 2025 12:05:02 -0500
Subject: [PATCH 02/18] EC3-1687 Update sonarqube.yml (#378)
---
.github/workflows/sonarqube.yml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml
index b60e311d..54ba8165 100644
--- a/.github/workflows/sonarqube.yml
+++ b/.github/workflows/sonarqube.yml
@@ -9,21 +9,21 @@ jobs:
runs-on: windows-latest
steps:
- name: Set up JDK 11
- uses: actions/setup-java@v1
+ uses: actions/setup-java@v4
with:
java-version: 1.11
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Cache SonarCloud packages
- uses: actions/cache@v1
+ uses: actions/cache@v4
with:
path: ~\sonar\cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Cache SonarCloud scanner
id: cache-sonar-scanner
- uses: actions/cache@v1
+ uses: actions/cache@v4
with:
path: .\.sonar\scanner
key: ${{ runner.os }}-sonar-scanner
From 666abad0dd6c3df350ed6613f3cc8d59aca224d3 Mon Sep 17 00:00:00 2001
From: Jae Kim <45045038+jaeopt@users.noreply.github.com>
Date: Wed, 5 Mar 2025 09:45:10 -0800
Subject: [PATCH 03/18] [FSSDK-11077] clean up travis (#379)
---
.github/workflows/csharp.yml | 6 ++----
.github/workflows/integration_test.yml | 11 +++--------
OptimizelySDK.DemoApp/Scripts/README.md | 3 +--
README.md | 2 +-
4 files changed, 7 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml
index b55407af..3e4e0b69 100644
--- a/.github/workflows/csharp.yml
+++ b/.github/workflows/csharp.yml
@@ -103,17 +103,15 @@ jobs:
integration_tests:
name: Run Integration Tests
needs: [ netFrameworksAndUnitTest, netStandard16, netStandard20 ]
- uses: optimizely/csharp-sdk/.github/workflows/integration_test.yml@master
+ uses: optimizely/csharp-sdk/.github/workflows/integration_test.yml@jae/FSSDK-11077
secrets:
CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }}
- TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }}
fullstack_production_suite:
name: Run Performance Tests
needs: [ netFrameworksAndUnitTest, netStandard16, netStandard20 ]
- uses: optimizely/csharp-sdk/.github/workflows/integration_test.yml@master
+ uses: optimizely/csharp-sdk/.github/workflows/integration_test.yml@jae/FSSDK-11077
with:
FULLSTACK_TEST_REPO: ProdTesting
secrets:
CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }}
- TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }}
diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml
index cf9a96b3..b56cc881 100644
--- a/.github/workflows/integration_test.yml
+++ b/.github/workflows/integration_test.yml
@@ -9,8 +9,6 @@ on:
secrets:
CI_USER_TOKEN:
required: true
- TRAVIS_COM_TOKEN:
- required: true
jobs:
test:
runs-on: ubuntu-latest
@@ -19,8 +17,8 @@ jobs:
with:
# You should create a personal access token and store it in your repository
token: ${{ secrets.CI_USER_TOKEN }}
- repository: 'optimizely/travisci-tools'
- path: 'home/runner/travisci-tools'
+ repository: 'optimizely/ci-helper-tools'
+ path: 'home/runner/ci-helper-tools'
ref: 'master'
- name: set SDK Branch if PR
env:
@@ -28,14 +26,12 @@ jobs:
if: ${{ github.event_name == 'pull_request' }}
run: |
echo "SDK_BRANCH=$HEAD_REF" >> $GITHUB_ENV
- echo "TRAVIS_BRANCH=$HEAD_REF" >> $GITHUB_ENV
- name: set SDK Branch if not pull request
env:
REF_NAME: ${{ github.ref_name }}
if: ${{ github.event_name != 'pull_request' }}
run: |
echo "SDK_BRANCH=$REF_NAME" >> $GITHUB_ENV
- echo "TRAVIS_BRANCH=$REF_NAME" >> $GITHUB_ENV
- name: Trigger build
env:
SDK: csharp
@@ -51,9 +47,8 @@ jobs:
PULL_REQUEST_SHA: ${{ github.event.pull_request.head.sha }}
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
UPSTREAM_SHA: ${{ github.sha }}
- TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }}
EVENT_MESSAGE: ${{ github.event.message }}
HOME: 'home/runner'
run: |
echo "$GITHUB_CONTEXT"
- home/runner/travisci-tools/trigger-script-with-status-update.sh
+ home/runner/ci-helper-tools/trigger-script-with-status-update.sh
diff --git a/OptimizelySDK.DemoApp/Scripts/README.md b/OptimizelySDK.DemoApp/Scripts/README.md
index ff1e9551..12510e26 100644
--- a/OptimizelySDK.DemoApp/Scripts/README.md
+++ b/OptimizelySDK.DemoApp/Scripts/README.md
@@ -7,7 +7,6 @@
-
@@ -34,7 +33,7 @@ to make it possible to position it near a given reference element.
The engine is completely modular and most of its features are implemented as **modifiers**
(similar to middlewares or plugins).
-The whole code base is written in ES2015 and its features are automatically tested on real browsers thanks to [SauceLabs](https://saucelabs.com/) and [TravisCI](https://travis-ci.org/).
+The whole code base is written in ES2015 and its features are automatically tested on real browsers thanks to [SauceLabs](https://saucelabs.com/).
Popper.js has zero dependencies. No jQuery, no LoDash, nothing.
It's used by big companies like [Twitter in Bootstrap v4](https://getbootstrap.com/), [Microsoft in WebClipper](https://github.com/OneNoteDev/WebClipper) and [Atlassian in AtlasKit](https://aui-cdn.atlassian.com/atlaskit/registry/).
diff --git a/README.md b/README.md
index f6b04d5c..9de4ddc7 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Optimizely C# SDK

-[](https://travis-ci.org/optimizely/csharp-sdk)
+
[](https://www.nuget.org/packages/Optimizely.SDK/)
[](http://www.apache.org/licenses/LICENSE-2.0)
From 282b7cf7577d1adf2fedaad33625dab34aea5fa6 Mon Sep 17 00:00:00 2001
From: Jae Kim <45045038+jaeopt@users.noreply.github.com>
Date: Wed, 5 Mar 2025 13:02:26 -0800
Subject: [PATCH 04/18] restore master workflow (#380)
---
.github/workflows/csharp.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml
index 3e4e0b69..0205baa8 100644
--- a/.github/workflows/csharp.yml
+++ b/.github/workflows/csharp.yml
@@ -103,14 +103,14 @@ jobs:
integration_tests:
name: Run Integration Tests
needs: [ netFrameworksAndUnitTest, netStandard16, netStandard20 ]
- uses: optimizely/csharp-sdk/.github/workflows/integration_test.yml@jae/FSSDK-11077
+ uses: optimizely/csharp-sdk/.github/workflows/integration_test.yml@master
secrets:
CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }}
fullstack_production_suite:
name: Run Performance Tests
needs: [ netFrameworksAndUnitTest, netStandard16, netStandard20 ]
- uses: optimizely/csharp-sdk/.github/workflows/integration_test.yml@jae/FSSDK-11077
+ uses: optimizely/csharp-sdk/.github/workflows/integration_test.yml@master
with:
FULLSTACK_TEST_REPO: ProdTesting
secrets:
From c9d06cdb0b21f455508f4e74f548758aa44b8625 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Wed, 18 Jun 2025 23:08:18 +0600
Subject: [PATCH 05/18] [FSSDK-11450] experimentId + variationId to decision
notification listener (#381)
---
.github/workflows/csharp.yml | 4 +-
OptimizelySDK.Tests/OptimizelyTest.cs | 91 ++++++++++---------
.../OptimizelyUserContextTest.cs | 6 ++
OptimizelySDK/Optimizely.cs | 11 ++-
4 files changed, 63 insertions(+), 49 deletions(-)
diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml
index 0205baa8..1650a685 100644
--- a/.github/workflows/csharp.yml
+++ b/.github/workflows/csharp.yml
@@ -75,7 +75,7 @@ jobs:
- name: Restore dependencies
run: dotnet restore OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
- name: Build & strongly name assemblies
- run: dotnet build OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=D:\a\csharp-sdk\csharp-sdk\keypair.snk -c Release
+ run: dotnet build OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk -c Release
netStandard20:
name: Build Standard 2.0
@@ -98,7 +98,7 @@ jobs:
- name: Restore dependencies
run: dotnet restore OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
- name: Build & strongly name assemblies
- run: dotnet build OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=D:\a\csharp-sdk\csharp-sdk\keypair.snk -c Release
+ run: dotnet build OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk -c Release
integration_tests:
name: Run Integration Tests
diff --git a/OptimizelySDK.Tests/OptimizelyTest.cs b/OptimizelySDK.Tests/OptimizelyTest.cs
index 5dab3aec..0adb57ec 100644
--- a/OptimizelySDK.Tests/OptimizelyTest.cs
+++ b/OptimizelySDK.Tests/OptimizelyTest.cs
@@ -339,6 +339,12 @@ public void TestDecisionNotificationSentWhenSendFlagDecisionsFalseAndFeature()
{
"decisionEventDispatched", true
},
+ {
+ "experimentId", "7718750065"
+ },
+ {
+ "variationId", "7713030086"
+ }
}))), Times.Once);
EventDispatcherMock.Verify(dispatcher => dispatcher.DispatchEvent(It.IsAny()),
Times.Once);
@@ -405,6 +411,12 @@ public void TestDecisionNotificationSentWhenSendFlagDecisionsTrueAndFeature()
{
"decisionEventDispatched", true
},
+ {
+ "experimentId", "7718750065"
+ },
+ {
+ "variationId", "7713030086"
+ }
}))), Times.Once);
EventDispatcherMock.Verify(dispatcher => dispatcher.DispatchEvent(It.IsAny()),
Times.Once);
@@ -476,6 +488,12 @@ public void TestDecisionNotificationNotSentWhenSendFlagDecisionsFalseAndRollout(
{
"decisionEventDispatched", false
},
+ {
+ "experimentId", experiment.Id
+ },
+ {
+ "variationId", variation.Id
+ }
}))), Times.Once);
EventDispatcherMock.Verify(dispatcher => dispatcher.DispatchEvent(It.IsAny()),
Times.Never);
@@ -547,6 +565,12 @@ public void TestDecisionNotificationSentWhenSendFlagDecisionsTrueAndRollout()
{
"decisionEventDispatched", true
},
+ {
+ "experimentId", experiment.Id
+ },
+ {
+ "variationId", variation.Id
+ }
}))), Times.Once);
EventDispatcherMock.Verify(dispatcher => dispatcher.DispatchEvent(It.IsAny()),
Times.Once);
@@ -2361,8 +2385,7 @@ public void
Assert.AreEqual(expectedValue, variableValue);
LoggerMock.Verify(l => l.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}""."));
}
[Test]
@@ -2406,8 +2429,7 @@ public void
Assert.AreEqual(expectedValue, variableValue);
LoggerMock.Verify(l => l.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}""."));
}
[Test]
@@ -2439,8 +2461,7 @@ public void
Assert.AreEqual(expectedValue, variableValue);
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
- $@"Feature ""{featureKey}"" is not enabled for user {TestUserId
- }. Returning the default variable value ""{variableValue}""."));
+ $@"Feature ""{featureKey}"" is not enabled for user {TestUserId}. Returning the default variable value ""{variableValue}""."));
}
[Test]
@@ -2484,8 +2505,7 @@ public void
Assert.AreEqual(expectedValue, variableValue);
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
- $@"Feature ""{featureKey}"" is not enabled for user {TestUserId
- }. Returning the default variable value ""{variableValue}""."));
+ $@"Feature ""{featureKey}"" is not enabled for user {TestUserId}. Returning the default variable value ""{variableValue}""."));
}
[Test]
@@ -2515,8 +2535,7 @@ public void
Assert.AreEqual(expectedValue, variableValue);
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
- $@"Got variable value ""true"" for variable ""{variableKey}"" of feature flag ""{
- featureKey}""."));
+ $@"Got variable value ""true"" for variable ""{variableKey}"" of feature flag ""{featureKey}""."));
}
[Test]
@@ -2562,8 +2581,7 @@ public void
Assert.AreEqual(expectedStringValue, variableValue.GetValue("string_var"));
LoggerMock.Verify(l => l.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}""."));
}
[Test]
@@ -2609,8 +2627,7 @@ public void
Assert.AreEqual(expectedStringValue, variableValue.GetValue("string_var"));
LoggerMock.Verify(l => l.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}""."));
}
[Test]
@@ -2654,8 +2671,7 @@ public void
Assert.AreEqual(expectedValue, variableValue);
LoggerMock.Verify(l => l.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}""."));
}
[Test]
@@ -2684,8 +2700,7 @@ public void
variableKey, TestUserId, null);
Assert.AreEqual(expectedValue, variableValue);
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
- $@"Feature ""{featureKey}"" is not enabled for user {TestUserId
- }. Returning the default variable value ""true""."));
+ $@"Feature ""{featureKey}"" is not enabled for user {TestUserId}. Returning the default variable value ""true""."));
}
[Test]
@@ -2728,8 +2743,7 @@ public void
Assert.AreEqual(expectedValue, variableValue);
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
- $@"Feature ""{featureKey}"" is not enabled for user {TestUserId
- }. Returning the default variable value ""{variableValue}""."));
+ $@"Feature ""{featureKey}"" is not enabled for user {TestUserId}. Returning the default variable value ""{variableValue}""."));
}
[Test]
@@ -2758,8 +2772,7 @@ public void
Assert.AreEqual(expectedValue, variableValue);
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
- $@"User ""{TestUserId}"" is not in any variation for feature flag ""{featureKey
- }"", returning default value ""{variableValue}""."));
+ $@"User ""{TestUserId}"" is not in any variation for feature flag ""{featureKey}"", returning default value ""{variableValue}""."));
}
#endregion Feature Toggle Tests
@@ -2822,8 +2835,7 @@ public void TestGetFeatureVariableValueForTypeGivenFeatureKeyOrVariableKeyNotFou
LoggerMock.Verify(l =>
l.Log(LogLevel.ERROR, $@"Feature key ""{featureKey}"" is not in datafile."));
LoggerMock.Verify(l => l.Log(LogLevel.ERROR,
- $@"No feature variable was found for key ""{variableKey
- }"" in feature flag ""double_single_variable_feature""."));
+ $@"No feature variable was found for key ""{variableKey}"" in feature flag ""double_single_variable_feature""."));
}
// Should return null and log error message when variable type is invalid.
@@ -2851,17 +2863,13 @@ public void TestGetFeatureVariableValueForTypeGivenInvalidVariableType()
"string_single_variable_feature", "json_var", TestUserId, null, variableTypeInt));
LoggerMock.Verify(l => l.Log(LogLevel.ERROR,
- $@"Variable is of type ""double"", but you requested it as type ""{variableTypeBool
- }""."));
+ $@"Variable is of type ""double"", but you requested it as type ""{variableTypeBool}""."));
LoggerMock.Verify(l => l.Log(LogLevel.ERROR,
- $@"Variable is of type ""boolean"", but you requested it as type ""{
- variableTypeDouble}""."));
+ $@"Variable is of type ""boolean"", but you requested it as type ""{variableTypeDouble}""."));
LoggerMock.Verify(l => l.Log(LogLevel.ERROR,
- $@"Variable is of type ""integer"", but you requested it as type ""{
- variableTypeString}""."));
+ $@"Variable is of type ""integer"", but you requested it as type ""{variableTypeString}""."));
LoggerMock.Verify(l => l.Log(LogLevel.ERROR,
- $@"Variable is of type ""string"", but you requested it as type ""{variableTypeInt
- }""."));
+ $@"Variable is of type ""string"", but you requested it as type ""{variableTypeInt}""."));
}
[Test]
@@ -2913,8 +2921,7 @@ public void TestGetFeatureVariableValueForTypeGivenFeatureFlagIsNotEnabledForUse
Assert.AreEqual(expectedValue, variableValue);
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
- $@"Feature ""{featureKey}"" is not enabled for user {TestUserId
- }. Returning the default variable value ""{variableValue}""."));
+ $@"Feature ""{featureKey}"" is not enabled for user {TestUserId}. Returning the default variable value ""{variableValue}""."));
}
// Should return default value and log message when feature is enabled for the user
@@ -2954,9 +2961,7 @@ public void
Assert.AreEqual(expectedValue, variableValue);
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
- $@"Variable ""{variableKey
- }"" is not used in variation ""control"", returning default value ""{expectedValue
- }""."));
+ $@"Variable ""{variableKey}"" is not used in variation ""control"", returning default value ""{expectedValue}""."));
}
// Should return variable value from variation and log message when feature is enabled for the user
@@ -2994,8 +2999,7 @@ public void
Assert.AreEqual(expectedValue, variableValue);
LoggerMock.Verify(l => l.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}""."));
}
// Verify that GetFeatureVariableValueForType returns correct variable value for rollout rule.
@@ -3149,8 +3153,7 @@ public void TestIsFeatureEnabledGivenFeatureFlagIsEnabledAndUserIsNotBeingExperi
// SendImpressionEvent() does not get called.
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
- $@"The user ""{TestUserId}"" is not being experimented on feature ""{featureKey
- }""."), Times.Once);
+ $@"The user ""{TestUserId}"" is not being experimented on feature ""{featureKey}""."), Times.Once);
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
$@"Feature flag ""{featureKey}"" is enabled for user ""{TestUserId}""."));
@@ -3183,8 +3186,7 @@ public void TestIsFeatureEnabledGivenFeatureFlagIsEnabledAndUserIsBeingExperimen
// SendImpressionEvent() gets called.
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
- $@"The user ""{TestUserId}"" is not being experimented on feature ""{featureKey
- }""."), Times.Never);
+ $@"The user ""{TestUserId}"" is not being experimented on feature ""{featureKey}""."), Times.Never);
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
$@"Feature flag ""{featureKey}"" is enabled for user ""{TestUserId}""."));
@@ -3218,8 +3220,7 @@ public void TestIsFeatureEnabledGivenFeatureFlagIsNotEnabledAndUserIsBeingExperi
// SendImpressionEvent() gets called.
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
- $@"The user ""{TestUserId}"" is not being experimented on feature ""{featureKey
- }""."), Times.Never);
+ $@"The user ""{TestUserId}"" is not being experimented on feature ""{featureKey}""."), Times.Never);
LoggerMock.Verify(l => l.Log(LogLevel.INFO,
$@"Feature flag ""{featureKey}"" is not enabled for user ""{TestUserId}""."));
diff --git a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs
index 76d0d8b8..21ae10db 100644
--- a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs
+++ b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs
@@ -1081,6 +1081,12 @@ public void TestDecisionNotification()
{
"decisionEventDispatched", true
},
+ {
+ "experimentId", "122235"
+ },
+ {
+ "variationId", "122236"
+ },
};
var userAttributes = new UserAttributes
diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs
index b1766985..99cbdaaf 100644
--- a/OptimizelySDK/Optimizely.cs
+++ b/OptimizelySDK/Optimizely.cs
@@ -1000,7 +1000,8 @@ ProjectConfig projectConfig
)
{
var userId = user.GetUserId();
-
+ string experimentId = null;
+ string variationId = null;
var flagEnabled = false;
if (flagDecision.Variation != null)
{
@@ -1008,8 +1009,12 @@ ProjectConfig projectConfig
{
flagEnabled = true;
}
+ variationId = flagDecision.Variation.Id;
+ }
+ if (flagDecision.Experiment != null)
+ {
+ experimentId = flagDecision.Experiment.Id;
}
-
Logger.Log(LogLevel.INFO,
$"Feature \"{flagKey}\" is enabled for user \"{userId}\"? {flagEnabled}");
@@ -1062,6 +1067,8 @@ ProjectConfig projectConfig
{ "ruleKey", ruleKey },
{ "reasons", reasonsToReport },
{ "decisionEventDispatched", decisionEventDispatched },
+ { "experimentId", experimentId },
+ { "variationId", variationId },
};
NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Decision,
From c6548a875e4db3f9c644d56bbb96e17b1cb32ec9 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Fri, 8 Aug 2025 15:58:08 +0600
Subject: [PATCH 06/18] [FSSDK-11731] windows version update (#382)
---
.github/workflows/csharp.yml | 40 +++++++++++++++++++++++--
.github/workflows/csharp_release.yml | 44 ++++++++++++++++++++++++---
.github/workflows/sonarqube.yml | 45 ----------------------------
3 files changed, 78 insertions(+), 51 deletions(-)
delete mode 100644 .github/workflows/sonarqube.yml
diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml
index 1650a685..300016b8 100644
--- a/.github/workflows/csharp.yml
+++ b/.github/workflows/csharp.yml
@@ -29,7 +29,7 @@ jobs:
netFrameworksAndUnitTest:
name: Build Framework & Run Unit Tests
needs: [ lintCodebase ]
- runs-on: windows-2019 # required version for Framework 4.0
+ runs-on: windows-2022
env:
REPO_SLUG: ${{ github.repository }}
BUILD_NUMBER: ${{ github.run_id }}
@@ -44,15 +44,51 @@ jobs:
uses: microsoft/setup-msbuild@v1
- name: Setup NuGet
uses: NuGet/setup-nuget@v1
+ - name: Download and Extract .NET Framework Reference Assemblies
+ run: |
+ # Create temp directory
+ New-Item -ItemType Directory -Path "temp_ref_assemblies" -Force
+
+ # Download .NET 4.0 Reference Assemblies
+ echo "Downloading .NET 4.0 Reference Assemblies..."
+ Invoke-WebRequest -Uri "https://www.nuget.org/api/v2/package/Microsoft.NETFramework.ReferenceAssemblies.net40/1.0.3" -OutFile "temp_ref_assemblies/net40_ref.zip"
+
+ # Download .NET 4.5 Reference Assemblies
+ echo "Downloading .NET 4.5 Reference Assemblies..."
+ Invoke-WebRequest -Uri "https://www.nuget.org/api/v2/package/Microsoft.NETFramework.ReferenceAssemblies.net45/1.0.3" -OutFile "temp_ref_assemblies/net45_ref.zip"
+
+ # Extract .NET 4.0 Reference Assemblies
+ echo "Extracting .NET 4.0 Reference Assemblies..."
+ Expand-Archive -Path "temp_ref_assemblies/net40_ref.zip" -DestinationPath "temp_ref_assemblies/net40" -Force
+ if (Test-Path "temp_ref_assemblies/net40/build/.NETFramework/v4.0") {
+ echo "✓ .NET 4.0 Reference Assemblies extracted to workspace"
+ }
+
+ # Extract .NET 4.5 Reference Assemblies
+ echo "Extracting .NET 4.5 Reference Assemblies..."
+ Expand-Archive -Path "temp_ref_assemblies/net45_ref.zip" -DestinationPath "temp_ref_assemblies/net45" -Force
+ if (Test-Path "temp_ref_assemblies/net45/build/.NETFramework/v4.5") {
+ echo "✓ .NET 4.5 Reference Assemblies extracted to workspace"
+ }
- name: Restore NuGet packages
run: nuget restore ./OptimizelySDK.NETFramework.sln
- name: Build & strongly name assemblies
- run: msbuild /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk /p:Configuration=Release ./OptimizelySDK.NETFramework.sln
+ run: |
+ # Build with workspace-relative reference assembly paths
+ $Net40RefPath = "$(pwd)\temp_ref_assemblies\net40\build\.NETFramework\v4.0"
+ $Net45RefPath = "$(pwd)\temp_ref_assemblies\net45\build\.NETFramework\v4.5"
+
+ echo "Using .NET 4.0 Reference Assemblies from: $Net40RefPath"
+ echo "Using .NET 4.5 Reference Assemblies from: $Net45RefPath"
+
+ msbuild /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk /p:Configuration=Release /p:FrameworkPathOverride="$Net45RefPath" ./OptimizelySDK.NETFramework.sln
- name: Install & Run NUnit tests
run: |
nuget install NUnit.Console -Version 3.18.1 -DirectDownload -OutputDirectory .
# https://docs.nunit.org/articles/nunit/running-tests/Console-Command-Line.html
./NUnit.ConsoleRunner.3.18.1\tools\nunit3-console.exe /timeout 10000 /process Separate ./OptimizelySDK.Tests/bin/Release/OptimizelySDK.Tests.dll
+ - name: Cleanup reference assemblies
+ run: Remove-Item -Path "temp_ref_assemblies" -Recurse -Force
netStandard16:
name: Build Standard 1.6
diff --git a/.github/workflows/csharp_release.yml b/.github/workflows/csharp_release.yml
index 80461161..f4c1736c 100644
--- a/.github/workflows/csharp_release.yml
+++ b/.github/workflows/csharp_release.yml
@@ -31,7 +31,7 @@ jobs:
buildFrameworkVersions:
name: Build Framework versions
needs: [ variables ]
- runs-on: windows-2019 # required version for Framework 4.0
+ runs-on: windows-2022
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -41,10 +41,46 @@ jobs:
uses: microsoft/setup-msbuild@v2
- name: Setup NuGet
uses: nuget/setup-nuget@v2
+ - name: Download and Extract .NET Framework Reference Assemblies
+ run: |
+ # Create temp directory
+ New-Item -ItemType Directory -Path "temp_ref_assemblies" -Force
+
+ # Download .NET 4.0 Reference Assemblies
+ echo "Downloading .NET 4.0 Reference Assemblies..."
+ Invoke-WebRequest -Uri "https://www.nuget.org/api/v2/package/Microsoft.NETFramework.ReferenceAssemblies.net40/1.0.3" -OutFile "temp_ref_assemblies/net40_ref.zip"
+
+ # Download .NET 4.5 Reference Assemblies
+ echo "Downloading .NET 4.5 Reference Assemblies..."
+ Invoke-WebRequest -Uri "https://www.nuget.org/api/v2/package/Microsoft.NETFramework.ReferenceAssemblies.net45/1.0.3" -OutFile "temp_ref_assemblies/net45_ref.zip"
+
+ # Extract .NET 4.0 Reference Assemblies
+ echo "Extracting .NET 4.0 Reference Assemblies..."
+ Expand-Archive -Path "temp_ref_assemblies/net40_ref.zip" -DestinationPath "temp_ref_assemblies/net40" -Force
+ if (Test-Path "temp_ref_assemblies/net40/build/.NETFramework/v4.0") {
+ echo "✓ .NET 4.0 Reference Assemblies extracted to workspace"
+ }
+
+ # Extract .NET 4.5 Reference Assemblies
+ echo "Extracting .NET 4.5 Reference Assemblies..."
+ Expand-Archive -Path "temp_ref_assemblies/net45_ref.zip" -DestinationPath "temp_ref_assemblies/net45" -Force
+ if (Test-Path "temp_ref_assemblies/net45/build/.NETFramework/v4.5") {
+ echo "✓ .NET 4.5 Reference Assemblies extracted to workspace"
+ }
- name: Restore NuGet packages
run: nuget restore ./OptimizelySDK.NETFramework.sln
- name: Build and strongly name assemblies
- run: msbuild /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk /p:Configuration=Release ./OptimizelySDK.NETFramework.sln
+ run: |
+ # Build with workspace-relative reference assembly paths
+ $Net40RefPath = "$(pwd)\temp_ref_assemblies\net40\build\.NETFramework\v4.0"
+ $Net45RefPath = "$(pwd)\temp_ref_assemblies\net45\build\.NETFramework\v4.5"
+
+ echo "Using .NET 4.0 Reference Assemblies from: $Net40RefPath"
+ echo "Using .NET 4.5 Reference Assemblies from: $Net45RefPath"
+
+ msbuild /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk /p:Configuration=Release /p:FrameworkPathOverride="$Net45RefPath" ./OptimizelySDK.NETFramework.sln
+ - name: Cleanup reference assemblies
+ run: Remove-Item -Path "temp_ref_assemblies" -Recurse -Force
- name: Upload Framework artifacts
uses: actions/upload-artifact@v4
with:
@@ -55,7 +91,7 @@ jobs:
buildStandard16:
name: Build Standard 1.6 version
needs: [ variables ]
- runs-on: windows-latest
+ runs-on: windows-2022
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -77,7 +113,7 @@ jobs:
buildStandard20:
name: Build Standard 2.0 version
needs: [ variables ]
- runs-on: windows-latest
+ runs-on: windows-2022
steps:
- name: Checkout code
uses: actions/checkout@v4
diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml
deleted file mode 100644
index 54ba8165..00000000
--- a/.github/workflows/sonarqube.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-name: SonarQube
-on:
- push:
- branches:
- - dsier/sonarqube
-jobs:
- build:
- name: Build
- runs-on: windows-latest
- steps:
- - name: Set up JDK 11
- uses: actions/setup-java@v4
- with:
- java-version: 1.11
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- - name: Cache SonarCloud packages
- uses: actions/cache@v4
- with:
- path: ~\sonar\cache
- key: ${{ runner.os }}-sonar
- restore-keys: ${{ runner.os }}-sonar
- - name: Cache SonarCloud scanner
- id: cache-sonar-scanner
- uses: actions/cache@v4
- with:
- path: .\.sonar\scanner
- key: ${{ runner.os }}-sonar-scanner
- restore-keys: ${{ runner.os }}-sonar-scanner
- - name: Install SonarCloud scanner
- if: steps.cache-sonar-scanner.outputs.cache-hit != 'true'
- shell: powershell
- run: |
- New-Item -Path .\.sonar\scanner -ItemType Directory
- dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner
- - name: Build and analyze
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- shell: powershell
- run: |
- .\.sonar\scanner\dotnet-sonarscanner begin /k:"csharpsdk" /o:"optidevx" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io/"
- dotnet build .\OptimizelySDK\
- .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN
From d32000154f1fdf890a3a79524547798368a5cc55 Mon Sep 17 00:00:00 2001
From: esrakartalOpt <102107327+esrakartalOpt@users.noreply.github.com>
Date: Fri, 15 Aug 2025 10:55:20 -0500
Subject: [PATCH 07/18] [FSSDK-11463] C# - Add SDK Multi-Region Support for
Data Hosting (#383)
* [FSSDK-11463] C# - Add SDK Multi-Region Support for Data Hosting
* Fix lint whitespace errors
* Fix lint errors
* Fix last lint error
* Implement copilot review comments
* Fix test error
* Implement comments
* Fix lint
---
.../EventTests/EventBuilderTest.cs | 42 +-
.../EventTests/EventFactoryTest.cs | 410 +++++++++++++++++-
.../TestForwardingEventDispatcher.cs | 2 +-
OptimizelySDK.Tests/OptimizelyTest.cs | 2 +-
OptimizelySDK/Config/DatafileProjectConfig.cs | 34 +-
OptimizelySDK/Event/Builder/EventBuilder.cs | 15 +-
OptimizelySDK/Event/Entity/EventContext.cs | 11 +
OptimizelySDK/Event/EventFactory.cs | 32 +-
OptimizelySDK/Event/UserEventFactory.cs | 2 +
OptimizelySDK/ProjectConfig.cs | 5 +
10 files changed, 484 insertions(+), 71 deletions(-)
diff --git a/OptimizelySDK.Tests/EventTests/EventBuilderTest.cs b/OptimizelySDK.Tests/EventTests/EventBuilderTest.cs
index c869d831..8b4c3ad5 100644
--- a/OptimizelySDK.Tests/EventTests/EventBuilderTest.cs
+++ b/OptimizelySDK.Tests/EventTests/EventBuilderTest.cs
@@ -115,7 +115,7 @@ public void TestCreateImpressionEventNoAttributes()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -207,7 +207,7 @@ public void TestCreateImpressionEventWithAttributes()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -327,7 +327,7 @@ public void TestCreateImpressionEventWithTypedAttributes()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -441,7 +441,7 @@ public void TestCreateImpressionEventRemovesInvalidAttributesFromPayload()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -531,7 +531,7 @@ public void TestCreateConversionEventNoAttributesNoValue()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -617,7 +617,7 @@ public void TestCreateConversionEventWithAttributesNoValue()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -709,7 +709,7 @@ public void TestCreateConversionEventNoAttributesWithValue()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -809,7 +809,7 @@ public void TestCreateConversionEventWithAttributesWithValue()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -909,7 +909,7 @@ public void TestCreateConversionEventNoAttributesWithInvalidValue()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1003,7 +1003,7 @@ public void TestConversionEventWithNumericTag()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1096,7 +1096,7 @@ public void TestConversionEventWithFalsyNumericAndRevenueValues()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1189,7 +1189,7 @@ public void TestConversionEventWithNumericValue1()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1282,7 +1282,7 @@ public void TestConversionEventWithRevenueValue1()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1381,7 +1381,7 @@ public void TestCreateConversionEventWithBucketingIDAttribute()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1487,7 +1487,7 @@ public void TestCreateImpressionEventWithBucketingIDAttribute()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1587,7 +1587,7 @@ public void TestCreateImpressionEventWhenBotFilteringIsProvidedInDatafile()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1680,7 +1680,7 @@ public void TestCreateImpressionEventWhenBotFilteringIsNotProvidedInDatafile()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1770,7 +1770,7 @@ public void TestCreateConversionEventWhenBotFilteringIsProvidedInDatafile()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1856,7 +1856,7 @@ public void TestCreateConversionEventWhenBotFilteringIsNotProvidedInDatafile()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1989,7 +1989,7 @@ public void TestCreateConversionEventWhenEventUsedInMultipleExp()
var expectedLogEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -2082,7 +2082,7 @@ public void TestCreateConversionEventRemovesInvalidAttributesFromPayload()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
diff --git a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs
index 3199a8ae..8c839d67 100644
--- a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs
+++ b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs
@@ -66,6 +66,208 @@ public void
Assert.IsNull(impressionEvent);
}
+ [Test]
+ public void TestCreateImpressionEventNoAttributesEU()
+ {
+ var guid = Guid.NewGuid();
+ var timeStamp = TestData.SecondsSince1970();
+
+ var payloadParams = new Dictionary
+ {
+ {
+ "visitors", new object[]
+ {
+ new Dictionary()
+ {
+ {
+ "snapshots", new object[]
+ {
+ new Dictionary
+ {
+ {
+ "decisions", new object[]
+ {
+ new Dictionary
+ {
+ { "campaign_id", "7719770039" },
+ { "experiment_id", "7716830082" },
+ { "variation_id", "7722370027" },
+ {
+ "metadata",
+ new Dictionary
+ {
+ { "rule_type", "experiment" },
+ { "rule_key", "test_experiment" },
+ { "flag_key", "test_experiment" },
+ { "variation_key", "control" },
+ { "enabled", false },
+ }
+ },
+ },
+ }
+ },
+ {
+ "events", new object[]
+ {
+ new Dictionary
+ {
+ { "entity_id", "7719770039" },
+ { "timestamp", timeStamp },
+ { "uuid", guid },
+ { "key", "campaign_activated" },
+ },
+ }
+ },
+ },
+ }
+ },
+ {
+ "attributes", new object[]
+ {
+ new Dictionary
+ {
+ { "entity_id", ControlAttributes.BOT_FILTERING_ATTRIBUTE },
+ { "key", ControlAttributes.BOT_FILTERING_ATTRIBUTE },
+ { "type", "custom" },
+ { "value", true },
+ },
+ }
+ },
+ { "visitor_id", TestUserId },
+ },
+ }
+ },
+ { "project_id", "7720880029" },
+ { "account_id", "1592310167" },
+ { "enrich_decisions", true },
+ { "client_name", "csharp-sdk" },
+ { "client_version", Optimizely.SDK_VERSION },
+ { "revision", "15" },
+ { "anonymize_ip", false },
+ };
+
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["EU"],
+ payloadParams,
+ "POST",
+ new Dictionary
+ {
+ { "Content-Type", "application/json" },
+ });
+
+ Config.Region = "EU";
+ var impressionEvent = UserEventFactory.CreateImpressionEvent(
+ Config, Config.GetExperimentFromKey("test_experiment"), "7722370027", TestUserId,
+ null, "test_experiment", "experiment");
+
+ var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger);
+
+ TestData.ChangeGUIDAndTimeStamp(expectedLogEvent.Params, impressionEvent.Timestamp,
+ Guid.Parse(impressionEvent.UUID));
+
+ Assert.IsTrue(TestData.CompareObjects(expectedLogEvent, logEvent));
+ }
+
+ [Test]
+ public void TestCreateImpressionEventNoAttributesInvalid()
+ {
+ var guid = Guid.NewGuid();
+ var timeStamp = TestData.SecondsSince1970();
+
+ var payloadParams = new Dictionary
+ {
+ {
+ "visitors", new object[]
+ {
+ new Dictionary()
+ {
+ {
+ "snapshots", new object[]
+ {
+ new Dictionary
+ {
+ {
+ "decisions", new object[]
+ {
+ new Dictionary
+ {
+ { "campaign_id", "7719770039" },
+ { "experiment_id", "7716830082" },
+ { "variation_id", "7722370027" },
+ {
+ "metadata",
+ new Dictionary
+ {
+ { "rule_type", "experiment" },
+ { "rule_key", "test_experiment" },
+ { "flag_key", "test_experiment" },
+ { "variation_key", "control" },
+ { "enabled", false },
+ }
+ },
+ },
+ }
+ },
+ {
+ "events", new object[]
+ {
+ new Dictionary
+ {
+ { "entity_id", "7719770039" },
+ { "timestamp", timeStamp },
+ { "uuid", guid },
+ { "key", "campaign_activated" },
+ },
+ }
+ },
+ },
+ }
+ },
+ {
+ "attributes", new object[]
+ {
+ new Dictionary
+ {
+ { "entity_id", ControlAttributes.BOT_FILTERING_ATTRIBUTE },
+ { "key", ControlAttributes.BOT_FILTERING_ATTRIBUTE },
+ { "type", "custom" },
+ { "value", true },
+ },
+ }
+ },
+ { "visitor_id", TestUserId },
+ },
+ }
+ },
+ { "project_id", "7720880029" },
+ { "account_id", "1592310167" },
+ { "enrich_decisions", true },
+ { "client_name", "csharp-sdk" },
+ { "client_version", Optimizely.SDK_VERSION },
+ { "revision", "15" },
+ { "anonymize_ip", false },
+ };
+
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
+ payloadParams,
+ "POST",
+ new Dictionary
+ {
+ { "Content-Type", "application/json" },
+ });
+
+ Config.Region = "ZZ";
+ var impressionEvent = UserEventFactory.CreateImpressionEvent(
+ Config, Config.GetExperimentFromKey("test_experiment"), "7722370027", TestUserId,
+ null, "test_experiment", "experiment");
+
+ var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger);
+
+ TestData.ChangeGUIDAndTimeStamp(expectedLogEvent.Params, impressionEvent.Timestamp,
+ Guid.Parse(impressionEvent.UUID));
+
+ Assert.IsTrue(TestData.CompareObjects(expectedLogEvent, logEvent));
+ }
+
[Test]
public void TestCreateImpressionEventNoAttributes()
{
@@ -146,7 +348,7 @@ public void TestCreateImpressionEventNoAttributes()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -251,7 +453,7 @@ public void TestCreateImpressionEventWithAttributes()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -383,7 +585,7 @@ public void TestCreateImpressionEventWithTypedAttributes()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -509,7 +711,7 @@ public void TestCreateImpressionEventRemovesInvalidAttributesFromPayload()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -642,7 +844,7 @@ public void TestCreateImpressionEventRemovesInvalidAttributesFromPayloadRollout(
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -735,7 +937,170 @@ public void TestCreateConversionEventNoAttributesNoValue()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
+ payloadParams,
+ "POST",
+ new Dictionary
+ {
+ { "Content-Type", "application/json" },
+ });
+ var experimentToVariationMap = new Dictionary
+ {
+ { "7716830082", new Variation { Id = "7722370027", Key = "control" } },
+ };
+
+ var conversionEvent =
+ UserEventFactory.CreateConversionEvent(Config, "purchase", TestUserId, null, null);
+ var logEvent = EventFactory.CreateLogEvent(conversionEvent, Logger);
+
+ TestData.ChangeGUIDAndTimeStamp(expectedEvent.Params, conversionEvent.Timestamp,
+ Guid.Parse(conversionEvent.UUID));
+
+ Assert.IsTrue(TestData.CompareObjects(expectedEvent, logEvent));
+ }
+
+ [Test]
+ public void TestCreateConversionEventNoAttributesNoValueEU()
+ {
+ var guid = Guid.NewGuid();
+ var timeStamp = TestData.SecondsSince1970();
+
+ var payloadParams = new Dictionary
+ {
+ {
+ "visitors", new object[]
+ {
+ new Dictionary
+ {
+ {
+ "snapshots", new object[]
+ {
+ new Dictionary
+ {
+ {
+ "events", new object[]
+ {
+ new Dictionary
+ {
+ { "entity_id", "7718020063" },
+ { "timestamp", timeStamp },
+ { "uuid", guid },
+ { "key", "purchase" },
+ },
+ }
+ },
+ },
+ }
+ },
+ { "visitor_id", TestUserId },
+ {
+ "attributes", new object[]
+ {
+ new Dictionary
+ {
+ { "entity_id", ControlAttributes.BOT_FILTERING_ATTRIBUTE },
+ { "key", ControlAttributes.BOT_FILTERING_ATTRIBUTE },
+ { "type", "custom" },
+ { "value", true },
+ },
+ }
+ },
+ },
+ }
+ },
+ { "project_id", "7720880029" },
+ { "enrich_decisions", true },
+ { "account_id", "1592310167" },
+ { "client_name", "csharp-sdk" },
+ { "client_version", Optimizely.SDK_VERSION },
+ { "revision", "15" },
+ { "anonymize_ip", false },
+ };
+
+ var expectedEvent = new LogEvent(
+ EventFactory.EventEndpoints["EU"],
+ payloadParams,
+ "POST",
+ new Dictionary
+ {
+ { "Content-Type", "application/json" },
+ });
+ var experimentToVariationMap = new Dictionary
+ {
+ { "7716830082", new Variation { Id = "7722370027", Key = "control" } },
+ };
+
+ Config.Region = "EU";
+ var conversionEvent =
+ UserEventFactory.CreateConversionEvent(Config, "purchase", TestUserId, null, null);
+ var logEvent = EventFactory.CreateLogEvent(conversionEvent, Logger);
+
+ TestData.ChangeGUIDAndTimeStamp(expectedEvent.Params, conversionEvent.Timestamp,
+ Guid.Parse(conversionEvent.UUID));
+
+ Assert.IsTrue(TestData.CompareObjects(expectedEvent, logEvent));
+ }
+
+ [Test]
+ public void TestCreateConversionEventNoAttributesNoValueInvalid()
+ {
+ var guid = Guid.NewGuid();
+ var timeStamp = TestData.SecondsSince1970();
+
+ var payloadParams = new Dictionary
+ {
+ {
+ "visitors", new object[]
+ {
+ new Dictionary
+ {
+ {
+ "snapshots", new object[]
+ {
+ new Dictionary
+ {
+ {
+ "events", new object[]
+ {
+ new Dictionary
+ {
+ { "entity_id", "7718020063" },
+ { "timestamp", timeStamp },
+ { "uuid", guid },
+ { "key", "purchase" },
+ },
+ }
+ },
+ },
+ }
+ },
+ { "visitor_id", TestUserId },
+ {
+ "attributes", new object[]
+ {
+ new Dictionary
+ {
+ { "entity_id", ControlAttributes.BOT_FILTERING_ATTRIBUTE },
+ { "key", ControlAttributes.BOT_FILTERING_ATTRIBUTE },
+ { "type", "custom" },
+ { "value", true },
+ },
+ }
+ },
+ },
+ }
+ },
+ { "project_id", "7720880029" },
+ { "enrich_decisions", true },
+ { "account_id", "1592310167" },
+ { "client_name", "csharp-sdk" },
+ { "client_version", Optimizely.SDK_VERSION },
+ { "revision", "15" },
+ { "anonymize_ip", false },
+ };
+
+ var expectedEvent = new LogEvent(
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -747,6 +1112,7 @@ public void TestCreateConversionEventNoAttributesNoValue()
{ "7716830082", new Variation { Id = "7722370027", Key = "control" } },
};
+ Config.Region = "ZZ";
var conversionEvent =
UserEventFactory.CreateConversionEvent(Config, "purchase", TestUserId, null, null);
var logEvent = EventFactory.CreateLogEvent(conversionEvent, Logger);
@@ -823,7 +1189,7 @@ public void TestCreateConversionEventWithAttributesNoValue()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -918,7 +1284,7 @@ public void TestCreateConversionEventNoAttributesWithValue()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1020,7 +1386,7 @@ public void TestCreateConversionEventWithAttributesWithValue()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1122,7 +1488,7 @@ public void TestCreateConversionEventNoAttributesWithInvalidValue()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1218,7 +1584,7 @@ public void TestConversionEventWithNumericTag()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1316,7 +1682,7 @@ public void TestConversionEventWithFalsyNumericAndRevenueValues()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1412,7 +1778,7 @@ public void TestConversionEventWithNumericValue1()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1507,7 +1873,7 @@ public void TestConversionEventWithRevenueValue1()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1609,7 +1975,7 @@ public void TestCreateConversionEventWithBucketingIDAttribute()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1727,7 +2093,7 @@ public void TestCreateImpressionEventWithBucketingIDAttribute()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1838,7 +2204,7 @@ public void TestCreateImpressionEventWhenBotFilteringIsProvidedInDatafile()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -1945,7 +2311,7 @@ public void TestCreateImpressionEventWhenBotFilteringIsNotProvidedInDatafile()
{ "anonymize_ip", false },
};
- var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var expectedLogEvent = new LogEvent(EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -2039,7 +2405,7 @@ public void TestCreateConversionEventWhenBotFilteringIsProvidedInDatafile()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -2128,7 +2494,7 @@ public void TestCreateConversionEventWhenBotFilteringIsNotProvidedInDatafile()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -2250,7 +2616,7 @@ public void TestCreateConversionEventWhenEventUsedInMultipleExp()
var expectedLogEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
@@ -2358,7 +2724,7 @@ public void TestCreateConversionEventRemovesInvalidAttributesFromPayload()
};
var expectedEvent = new LogEvent(
- "https://logx.optimizely.com/v1/events",
+ EventFactory.EventEndpoints["US"],
payloadParams,
"POST",
new Dictionary
diff --git a/OptimizelySDK.Tests/EventTests/TestForwardingEventDispatcher.cs b/OptimizelySDK.Tests/EventTests/TestForwardingEventDispatcher.cs
index 01eca1ee..9b7cf533 100644
--- a/OptimizelySDK.Tests/EventTests/TestForwardingEventDispatcher.cs
+++ b/OptimizelySDK.Tests/EventTests/TestForwardingEventDispatcher.cs
@@ -18,7 +18,7 @@ public class TestForwardingEventDispatcher : IEventDispatcher
public void DispatchEvent(LogEvent logEvent)
{
Assert.AreEqual(logEvent.HttpVerb, "POST");
- Assert.AreEqual(logEvent.Url, EventFactory.EVENT_ENDPOINT);
+ Assert.AreEqual(logEvent.Url, EventFactory.EventEndpoints["US"]);
IsUpdated = true;
}
}
diff --git a/OptimizelySDK.Tests/OptimizelyTest.cs b/OptimizelySDK.Tests/OptimizelyTest.cs
index 0adb57ec..034b4bc0 100644
--- a/OptimizelySDK.Tests/OptimizelyTest.cs
+++ b/OptimizelySDK.Tests/OptimizelyTest.cs
@@ -3490,7 +3490,7 @@ public void TestTrackListener(UserAttributes userAttributes, EventTags eventTags
var variation =
Result.NewResult(Config.GetVariationFromKey(experimentKey, variationKey),
DecisionReasons);
- var logEvent = new LogEvent("https://logx.optimizely.com/v1/events",
+ var logEvent = new LogEvent(EventFactory.EventEndpoints["US"],
OptimizelyHelper.SingleParameter,
"POST", new Dictionary());
diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs
index cb248f8c..465b384a 100644
--- a/OptimizelySDK/Config/DatafileProjectConfig.cs
+++ b/OptimizelySDK/Config/DatafileProjectConfig.cs
@@ -103,7 +103,7 @@ public enum OPTLYSDKVersion
public string Datafile { get; set; }
///
- /// Configured host name for the Optimizely Data Platform.
+ /// Configured host name for the Optimizely Data Platform.
///
public string HostForOdp { get; private set; }
@@ -492,8 +492,7 @@ private static DatafileProjectConfig GetConfig(string configData)
!(((int)supportedVersion).ToString() == config.Version)))
{
throw new ConfigParseException(
- $@"This version of the C# SDK does not support the given datafile version: {
- config.Version}");
+ $@"This version of the C# SDK does not support the given datafile version: {config.Version}");
}
return config;
@@ -632,8 +631,7 @@ public Variation GetVariationFromKey(string experimentKey, string variationKey)
return _VariationKeyMap[experimentKey][variationKey];
}
- var message = $@"No variation key ""{variationKey
- }"" defined in datafile for experiment ""{experimentKey}"".";
+ var message = $@"No variation key ""{variationKey}"" defined in datafile for experiment ""{experimentKey}"".";
Logger.Log(LogLevel.ERROR, message);
ErrorHandler.HandleError(
new InvalidVariationException("Provided variation is not in datafile."));
@@ -655,8 +653,7 @@ public Variation GetVariationFromKeyByExperimentId(string experimentId, string v
return _VariationKeyMapByExperimentId[experimentId][variationKey];
}
- var message = $@"No variation key ""{variationKey
- }"" defined in datafile for experiment ""{experimentId}"".";
+ var message = $@"No variation key ""{variationKey}"" defined in datafile for experiment ""{experimentId}"".";
Logger.Log(LogLevel.ERROR, message);
ErrorHandler.HandleError(
new InvalidVariationException("Provided variation is not in datafile."));
@@ -678,8 +675,7 @@ public Variation GetVariationFromId(string experimentKey, string variationId)
return _VariationIdMap[experimentKey][variationId];
}
- var message = $@"No variation ID ""{variationId
- }"" defined in datafile for experiment ""{experimentKey}"".";
+ var message = $@"No variation ID ""{variationId}"" defined in datafile for experiment ""{experimentKey}"".";
Logger.Log(LogLevel.ERROR, message);
ErrorHandler.HandleError(
new InvalidVariationException("Provided variation is not in datafile."));
@@ -701,11 +697,9 @@ public Variation GetVariationFromIdByExperimentId(string experimentId, string va
return _VariationIdMapByExperimentId[experimentId][variationId];
}
- var message = $@"No variation ID ""{variationId
- }"" defined in datafile for experiment ""{experimentId}"".";
+ var message = $@"No variation ID ""{variationId}"" defined in datafile for experiment ""{experimentId}"".";
Logger.Log(LogLevel.ERROR, message);
- ErrorHandler.HandleError(
- new InvalidVariationException("Provided variation is not in datafile."));
+ ErrorHandler.HandleError(new InvalidVariationException("Provided variation is not in datafile."));
return new Variation();
}
@@ -788,9 +782,7 @@ public string GetAttributeId(string attributeKey)
if (hasReservedPrefix)
{
Logger.Log(LogLevel.WARN,
- $@"Attribute {attributeKey} unexpectedly has reserved prefix {
- RESERVED_ATTRIBUTE_PREFIX
- }; using attribute ID instead of reserved attribute name.");
+ $@"Attribute {attributeKey} unexpectedly has reserved prefix {RESERVED_ATTRIBUTE_PREFIX}; using attribute ID instead of reserved attribute name.");
}
return attribute.Id;
@@ -825,12 +817,20 @@ public bool IsFeatureExperiment(string experimentId)
}
///
- ///Returns the datafile corresponding to ProjectConfig
+ /// Gets or sets the region associated with the project configuration.
+ /// This typically indicates the data residency or deployment region (e.g., "us", "eu").
+ /// Valid values depend on the Optimizely environment and configuration.
///
/// the datafile string corresponding to ProjectConfig
public string ToDatafile()
{
return _datafile;
}
+
+ ///
+ /// Returns the datafile corresponding to ProjectConfig
+ ///
+ /// the datafile string corresponding to ProjectConfig
+ public string Region { get; set; }
}
}
diff --git a/OptimizelySDK/Event/Builder/EventBuilder.cs b/OptimizelySDK/Event/Builder/EventBuilder.cs
index 1d73ff94..0dd4562a 100644
--- a/OptimizelySDK/Event/Builder/EventBuilder.cs
+++ b/OptimizelySDK/Event/Builder/EventBuilder.cs
@@ -27,9 +27,6 @@ namespace OptimizelySDK.Event.Builder
[Obsolete("This class is deprecated. Use 'OptimizelySDK.Event.EventFactory'.")]
public class EventBuilder
{
- private const string IMPRESSION_ENDPOINT = "https://logx.optimizely.com/v1/events";
-
- private const string CONVERSION_ENDPOINT = "https://logx.optimizely.com/v1/events";
private const string HTTP_VERB = "POST";
@@ -245,7 +242,11 @@ public virtual LogEvent CreateImpressionEvent(ProjectConfig config, Experiment e
GetImpressionOrConversionParamsWithCommonParams(commonParams,
new object[] { impressionOnlyParams });
- return new LogEvent(IMPRESSION_ENDPOINT, impressionParams, HTTP_VERB, HTTP_HEADERS);
+ var region = !string.IsNullOrEmpty(config.Region) && EventFactory.EventEndpoints.ContainsKey(config.Region) ? config.Region : "US";
+
+ var endpoint = EventFactory.EventEndpoints[region];
+
+ return new LogEvent(endpoint, impressionParams, HTTP_VERB, HTTP_HEADERS);
}
@@ -271,7 +272,11 @@ public virtual LogEvent CreateConversionEvent(ProjectConfig config, string event
var conversionParams =
GetImpressionOrConversionParamsWithCommonParams(commonParams, conversionOnlyParams);
- return new LogEvent(CONVERSION_ENDPOINT, conversionParams, HTTP_VERB, HTTP_HEADERS);
+ var region = !string.IsNullOrEmpty(config.Region) && EventFactory.EventEndpoints.ContainsKey(config.Region) ? config.Region : "US";
+
+ var endpoint = EventFactory.EventEndpoints[region];
+
+ return new LogEvent(endpoint, conversionParams, HTTP_VERB, HTTP_HEADERS);
}
}
}
diff --git a/OptimizelySDK/Event/Entity/EventContext.cs b/OptimizelySDK/Event/Entity/EventContext.cs
index 44b77644..718deba6 100644
--- a/OptimizelySDK/Event/Entity/EventContext.cs
+++ b/OptimizelySDK/Event/Entity/EventContext.cs
@@ -41,6 +41,9 @@ public class EventContext
[JsonProperty("anonymize_ip")]
public bool AnonymizeIP { get; protected set; }
+ [JsonProperty("region")]
+ public string Region { get; protected set; }
+
///
/// EventContext builder
///
@@ -50,6 +53,7 @@ public class Builder
private string ProjectId;
private string Revision;
private bool AnonymizeIP;
+ private string Region;
public Builder WithAccountId(string accountId)
{
@@ -75,6 +79,12 @@ public Builder WithAnonymizeIP(bool anonymizeIP)
return this;
}
+ public Builder WithRegion(string region)
+ {
+ Region = region;
+ return this;
+ }
+
///
/// Build EventContext instance
///
@@ -89,6 +99,7 @@ public EventContext Build()
eventContext.ClientName = Optimizely.SDK_TYPE;
eventContext.ClientVersion = Optimizely.SDK_VERSION;
eventContext.AnonymizeIP = AnonymizeIP;
+ eventContext.Region = Region;
return eventContext;
}
diff --git a/OptimizelySDK/Event/EventFactory.cs b/OptimizelySDK/Event/EventFactory.cs
index 841b650f..771e0f39 100644
--- a/OptimizelySDK/Event/EventFactory.cs
+++ b/OptimizelySDK/Event/EventFactory.cs
@@ -34,9 +34,15 @@ public class EventFactory
{
private const string CUSTOM_ATTRIBUTE_FEATURE_TYPE = "custom";
- public const string
- EVENT_ENDPOINT =
- "https://logx.optimizely.com/v1/events"; // Should be part of the datafile
+ // Supported regions for event endpoints
+ public static string[] SupportedRegions => EventEndpoints.Keys.ToArray();
+
+ // Dictionary of event endpoints for different regions
+ public static readonly Dictionary EventEndpoints = new Dictionary
+ {
+ {"US", "https://logx.optimizely.com/v1/events"},
+ {"EU", "https://eu.logx.optimizely.com/v1/events"}
+ };
private const string ACTIVATE_EVENT_KEY = "campaign_activated";
@@ -63,6 +69,9 @@ public static LogEvent CreateLogEvent(UserEvent[] userEvents, ILogger logger)
var visitors = new List(userEvents.Count());
+ // Default to US region
+ string region = "US";
+
foreach (var userEvent in userEvents)
{
if (userEvent is ImpressionEvent)
@@ -81,6 +90,12 @@ public static LogEvent CreateLogEvent(UserEvent[] userEvents, ILogger logger)
var userContext = userEvent.Context;
+ // Get region from the event's context, default to US if not specified
+ if (!string.IsNullOrEmpty(userContext.Region))
+ {
+ region = userContext.Region;
+ }
+
builder.WithClientName(userContext.ClientName).
WithClientVersion(userContext.ClientVersion).
WithAccountId(userContext.AccountId).
@@ -102,7 +117,16 @@ public static LogEvent CreateLogEvent(UserEvent[] userEvents, ILogger logger)
var eventBatchDictionary =
JObject.FromObject(eventBatch).ToObject>();
- return new LogEvent(EVENT_ENDPOINT, eventBatchDictionary, "POST",
+ // Use the region to determine the endpoint URL, falling back to US if the region is not found or not supported
+ string endpointUrl = EventEndpoints["US"]; // Default to US endpoint
+
+ // Only try to use the region-specific endpoint if it's a supported region
+ if (SupportedRegions.Contains(region) && EventEndpoints.ContainsKey(region))
+ {
+ endpointUrl = EventEndpoints[region];
+ }
+
+ return new LogEvent(endpointUrl, eventBatchDictionary, "POST",
new Dictionary
{
{ "Content-Type", "application/json" },
diff --git a/OptimizelySDK/Event/UserEventFactory.cs b/OptimizelySDK/Event/UserEventFactory.cs
index f073237a..28d6fb87 100644
--- a/OptimizelySDK/Event/UserEventFactory.cs
+++ b/OptimizelySDK/Event/UserEventFactory.cs
@@ -80,6 +80,7 @@ public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig,
WithAccountId(projectConfig.AccountId).
WithAnonymizeIP(projectConfig.AnonymizeIP).
WithRevision(projectConfig.Revision).
+ WithRegion(projectConfig.Region).
Build();
var variationKey = "";
@@ -123,6 +124,7 @@ EventTags eventTags
WithAccountId(projectConfig.AccountId).
WithAnonymizeIP(projectConfig.AnonymizeIP).
WithRevision(projectConfig.Revision).
+ WithRegion(projectConfig.Region).
Build();
return new ConversionEvent.Builder().
diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs
index 58272aa7..8aab34f7 100644
--- a/OptimizelySDK/ProjectConfig.cs
+++ b/OptimizelySDK/ProjectConfig.cs
@@ -312,5 +312,10 @@ public interface ProjectConfig
/// Returns the datafile corresponding to ProjectConfig
///
string ToDatafile();
+
+ ///
+ /// Returns the datafile region to ProjectConfig
+ ///
+ string Region { get; set; }
}
}
From 150380ff3467e188d51f8adddfef01d39f48a30b Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Wed, 20 Aug 2025 18:52:30 +0600
Subject: [PATCH 08/18] [FSSDK-11544] Parsing holdout configuration from
datafile + project config holdout impl. (#384)
---
.../OptimizelySDK.Net35.csproj | 9 +
.../OptimizelySDK.Net40.csproj | 9 +
.../OptimizelySDK.NetStandard16.csproj | 3 +
.../OptimizelySDK.NetStandard20.csproj | 10 +-
.../EntityTests/HoldoutTests.cs | 194 ++++++++++
.../OptimizelySDK.Tests.csproj | 5 +
OptimizelySDK.Tests/ProjectConfigTest.cs | 110 +++++-
.../TestData/HoldoutTestData.json | 192 ++++++++++
.../UtilsTests/HoldoutConfigTests.cs | 344 ++++++++++++++++++
OptimizelySDK/Config/DatafileProjectConfig.cs | 33 ++
OptimizelySDK/Entity/Experiment.cs | 224 +-----------
OptimizelySDK/Entity/ExperimentCore.cs | 276 ++++++++++++++
OptimizelySDK/Entity/Holdout.cs | 54 +++
.../Exceptions/OptimizelyException.cs | 6 +
OptimizelySDK/OptimizelySDK.csproj | 3 +
OptimizelySDK/ProjectConfig.cs | 19 +
OptimizelySDK/Utils/HoldoutConfig.cs | 193 ++++++++++
17 files changed, 1464 insertions(+), 220 deletions(-)
create mode 100644 OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
create mode 100644 OptimizelySDK.Tests/TestData/HoldoutTestData.json
create mode 100644 OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs
create mode 100644 OptimizelySDK/Entity/ExperimentCore.cs
create mode 100644 OptimizelySDK/Entity/Holdout.cs
create mode 100644 OptimizelySDK/Utils/HoldoutConfig.cs
diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
index a4495471..303a742e 100644
--- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
+++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
@@ -88,6 +88,12 @@
Entity\Experiment.cs
+
+ Entity\Holdout.cs
+
+
+ Entity\ExperimentCore.cs
+
Entity\FeatureDecision.cs
@@ -215,6 +221,9 @@
Bucketing\ExperimentUtils
+
+ Utils\HoldoutConfig.cs
+
Bucketing\UserProfileUtil
diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
index 05785575..3e0a9ea5 100644
--- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
+++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
@@ -90,6 +90,12 @@
Entity\Experiment.cs
+
+ Entity\Holdout.cs
+
+
+ Entity\ExperimentCore.cs
+
Entity\FeatureDecision.cs
@@ -214,6 +220,9 @@
Bucketing\ExperimentUtils
+
+ Utils\HoldoutConfig.cs
+
Bucketing\UserProfileUtil
diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
index b17f79e7..44240b2a 100644
--- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
+++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
@@ -26,6 +26,8 @@
+
+
@@ -64,6 +66,7 @@
+
diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index b7114653..12f9cb55 100644
--- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
+++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
@@ -178,7 +178,12 @@
Entity\Experiment.cs
-
+
+ Entity\Holdout.cs
+
+
+ Entity\ExperimentCore.cs
+
Entity\FeatureDecision.cs
@@ -331,6 +336,9 @@
Utils\ExperimentUtils.cs
+
+ Utils\HoldoutConfig.cs
+
Utils\Schema.cs
diff --git a/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
new file mode 100644
index 00000000..c17cc088
--- /dev/null
+++ b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2025, Optimizely
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.IO;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using OptimizelySDK.Entity;
+
+namespace OptimizelySDK.Tests
+{
+ [TestFixture]
+ public class HoldoutTests
+ {
+ private JObject testData;
+
+ [SetUp]
+ public void Setup()
+ {
+ // Load test data
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ testData = JObject.Parse(jsonContent);
+ }
+
+ [Test]
+ public void TestHoldoutDeserialization()
+ {
+ // Test global holdout deserialization
+ var globalHoldoutJson = testData["globalHoldout"].ToString();
+ var globalHoldout = JsonConvert.DeserializeObject(globalHoldoutJson);
+
+ Assert.IsNotNull(globalHoldout);
+ Assert.AreEqual("holdout_global_1", globalHoldout.Id);
+ Assert.AreEqual("global_holdout", globalHoldout.Key);
+ Assert.AreEqual("Running", globalHoldout.Status);
+ Assert.IsNotNull(globalHoldout.Variations);
+ Assert.AreEqual(1, globalHoldout.Variations.Length);
+ Assert.IsNotNull(globalHoldout.TrafficAllocation);
+ Assert.AreEqual(1, globalHoldout.TrafficAllocation.Length);
+ Assert.IsNotNull(globalHoldout.IncludedFlags);
+ Assert.AreEqual(0, globalHoldout.IncludedFlags.Length);
+ Assert.IsNotNull(globalHoldout.ExcludedFlags);
+ Assert.AreEqual(0, globalHoldout.ExcludedFlags.Length);
+ }
+
+ [Test]
+ public void TestHoldoutWithIncludedFlags()
+ {
+ var includedHoldoutJson = testData["includedFlagsHoldout"].ToString();
+ var includedHoldout = JsonConvert.DeserializeObject(includedHoldoutJson);
+
+ Assert.IsNotNull(includedHoldout);
+ Assert.AreEqual("holdout_included_1", includedHoldout.Id);
+ Assert.AreEqual("included_holdout", includedHoldout.Key);
+ Assert.IsNotNull(includedHoldout.IncludedFlags);
+ Assert.AreEqual(2, includedHoldout.IncludedFlags.Length);
+ Assert.Contains("flag_1", includedHoldout.IncludedFlags);
+ Assert.Contains("flag_2", includedHoldout.IncludedFlags);
+ Assert.IsNotNull(includedHoldout.ExcludedFlags);
+ Assert.AreEqual(0, includedHoldout.ExcludedFlags.Length);
+ }
+
+ [Test]
+ public void TestHoldoutWithExcludedFlags()
+ {
+ var excludedHoldoutJson = testData["excludedFlagsHoldout"].ToString();
+ var excludedHoldout = JsonConvert.DeserializeObject(excludedHoldoutJson);
+
+ Assert.IsNotNull(excludedHoldout);
+ Assert.AreEqual("holdout_excluded_1", excludedHoldout.Id);
+ Assert.AreEqual("excluded_holdout", excludedHoldout.Key);
+ Assert.IsNotNull(excludedHoldout.IncludedFlags);
+ Assert.AreEqual(0, excludedHoldout.IncludedFlags.Length);
+ Assert.IsNotNull(excludedHoldout.ExcludedFlags);
+ Assert.AreEqual(2, excludedHoldout.ExcludedFlags.Length);
+ Assert.Contains("flag_3", excludedHoldout.ExcludedFlags);
+ Assert.Contains("flag_4", excludedHoldout.ExcludedFlags);
+ }
+
+ [Test]
+ public void TestHoldoutWithEmptyFlags()
+ {
+ var globalHoldoutJson = testData["globalHoldout"].ToString();
+ var globalHoldout = JsonConvert.DeserializeObject(globalHoldoutJson);
+
+ Assert.IsNotNull(globalHoldout);
+ Assert.IsNotNull(globalHoldout.IncludedFlags);
+ Assert.AreEqual(0, globalHoldout.IncludedFlags.Length);
+ Assert.IsNotNull(globalHoldout.ExcludedFlags);
+ Assert.AreEqual(0, globalHoldout.ExcludedFlags.Length);
+ }
+
+ [Test]
+ public void TestHoldoutEquality()
+ {
+ var holdoutJson = testData["globalHoldout"].ToString();
+ var holdout1 = JsonConvert.DeserializeObject(holdoutJson);
+ var holdout2 = JsonConvert.DeserializeObject(holdoutJson);
+
+ Assert.IsNotNull(holdout1);
+ Assert.IsNotNull(holdout2);
+ // Note: This test depends on how Holdout implements equality
+ // If Holdout doesn't override Equals, this will test reference equality
+ // You may need to implement custom equality logic for Holdout
+ }
+
+ [Test]
+ public void TestHoldoutStatusParsing()
+ {
+ var globalHoldoutJson = testData["globalHoldout"].ToString();
+ var globalHoldout = JsonConvert.DeserializeObject(globalHoldoutJson);
+
+ Assert.IsNotNull(globalHoldout);
+ Assert.AreEqual("Running", globalHoldout.Status);
+
+ // Test that the holdout is considered activated when status is "Running"
+ // This assumes there's an IsActivated property or similar logic
+ // Adjust based on actual Holdout implementation
+ }
+
+ [Test]
+ public void TestHoldoutVariationsDeserialization()
+ {
+ var holdoutJson = testData["includedFlagsHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ Assert.IsNotNull(holdout);
+ Assert.IsNotNull(holdout.Variations);
+ Assert.AreEqual(1, holdout.Variations.Length);
+
+ var variation = holdout.Variations[0];
+ Assert.AreEqual("var_2", variation.Id);
+ Assert.AreEqual("treatment", variation.Key);
+ Assert.AreEqual(true, variation.FeatureEnabled);
+ }
+
+ [Test]
+ public void TestHoldoutTrafficAllocationDeserialization()
+ {
+ var holdoutJson = testData["excludedFlagsHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ Assert.IsNotNull(holdout);
+ Assert.IsNotNull(holdout.TrafficAllocation);
+ Assert.AreEqual(1, holdout.TrafficAllocation.Length);
+
+ var trafficAllocation = holdout.TrafficAllocation[0];
+ Assert.AreEqual("var_3", trafficAllocation.EntityId);
+ Assert.AreEqual(10000, trafficAllocation.EndOfRange);
+ }
+
+ [Test]
+ public void TestHoldoutNullSafety()
+ {
+ // Test that holdout can handle null/missing includedFlags and excludedFlags
+ var minimalHoldoutJson = @"{
+ ""id"": ""test_holdout"",
+ ""key"": ""test_key"",
+ ""status"": ""Running"",
+ ""variations"": [],
+ ""trafficAllocation"": [],
+ ""audienceIds"": [],
+ ""audienceConditions"": []
+ }";
+
+ var holdout = JsonConvert.DeserializeObject(minimalHoldoutJson);
+
+ Assert.IsNotNull(holdout);
+ Assert.AreEqual("test_holdout", holdout.Id);
+ Assert.AreEqual("test_key", holdout.Key);
+
+ // Verify that missing includedFlags and excludedFlags are handled properly
+ // This depends on how the Holdout entity handles missing properties
+ Assert.IsNotNull(holdout.IncludedFlags);
+ Assert.IsNotNull(holdout.ExcludedFlags);
+ }
+ }
+}
diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
index 6792d934..1db35b8f 100644
--- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
+++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
@@ -119,6 +119,7 @@
+
@@ -126,12 +127,16 @@
+
+
+ PreserveNewest
+
diff --git a/OptimizelySDK.Tests/ProjectConfigTest.cs b/OptimizelySDK.Tests/ProjectConfigTest.cs
index 55d6e63b..b7afcba6 100644
--- a/OptimizelySDK.Tests/ProjectConfigTest.cs
+++ b/OptimizelySDK.Tests/ProjectConfigTest.cs
@@ -16,6 +16,7 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using Moq;
using Newtonsoft.Json;
@@ -1175,9 +1176,7 @@ public void TestGetAttributeIdWithReservedPrefix()
Assert.AreEqual(reservedAttrConfig.GetAttributeId(reservedPrefixAttrKey),
reservedAttrConfig.GetAttribute(reservedPrefixAttrKey).Id);
LoggerMock.Verify(l => l.Log(LogLevel.WARN,
- $@"Attribute {reservedPrefixAttrKey} unexpectedly has reserved prefix {
- DatafileProjectConfig.RESERVED_ATTRIBUTE_PREFIX
- }; using attribute ID instead of reserved attribute name."));
+ $@"Attribute {reservedPrefixAttrKey} unexpectedly has reserved prefix {DatafileProjectConfig.RESERVED_ATTRIBUTE_PREFIX}; using attribute ID instead of reserved attribute name."));
}
[Test]
@@ -1351,5 +1350,110 @@ public void TestProjectConfigWithOtherIntegrationsInCollection()
Assert.IsNull(datafileProjectConfig.HostForOdp);
Assert.IsNull(datafileProjectConfig.PublicKeyForOdp);
}
+
+ #region Holdout Integration Tests
+
+ [Test]
+ public void TestHoldoutDeserialization_FromDatafile()
+ {
+ // Test that holdouts can be deserialized from a datafile with holdouts
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ var testData = JObject.Parse(jsonContent);
+
+ var datafileJson = testData["datafileWithHoldouts"].ToString();
+
+ var datafileProjectConfig = DatafileProjectConfig.Create(datafileJson,
+ new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig;
+
+ Assert.IsNotNull(datafileProjectConfig.Holdouts);
+ Assert.AreEqual(3, datafileProjectConfig.Holdouts.Length);
+ }
+
+ [Test]
+ public void TestGetHoldoutsForFlag_Integration()
+ {
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ var testData = JObject.Parse(jsonContent);
+
+ var datafileJson = testData["datafileWithHoldouts"].ToString();
+
+ var datafileProjectConfig = DatafileProjectConfig.Create(datafileJson,
+ new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig;
+
+ // Test GetHoldoutsForFlag method
+ var holdoutsForFlag1 = datafileProjectConfig.GetHoldoutsForFlag("flag_1");
+ Assert.IsNotNull(holdoutsForFlag1);
+ Assert.AreEqual(3, holdoutsForFlag1.Length); // Global + excluded holdout (applies to all except flag_3/flag_4) + included holdout
+
+ var holdoutsForFlag3 = datafileProjectConfig.GetHoldoutsForFlag("flag_3");
+ Assert.IsNotNull(holdoutsForFlag3);
+ Assert.AreEqual(1, holdoutsForFlag3.Length); // Only true global (excluded holdout excludes flag_3)
+
+ var holdoutsForUnknownFlag = datafileProjectConfig.GetHoldoutsForFlag("unknown_flag");
+ Assert.IsNotNull(holdoutsForUnknownFlag);
+ Assert.AreEqual(2, holdoutsForUnknownFlag.Length); // Global + excluded holdout (unknown_flag not in excluded list)
+ }
+
+ [Test]
+ public void TestGetHoldout_Integration()
+ {
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ var testData = JObject.Parse(jsonContent);
+
+ var datafileJson = testData["datafileWithHoldouts"].ToString();
+
+ var datafileProjectConfig = DatafileProjectConfig.Create(datafileJson,
+ new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig;
+
+ // Test GetHoldout method
+ var globalHoldout = datafileProjectConfig.GetHoldout("holdout_global_1");
+ Assert.IsNotNull(globalHoldout);
+ Assert.AreEqual("holdout_global_1", globalHoldout.Id);
+ Assert.AreEqual("global_holdout", globalHoldout.Key);
+
+ var invalidHoldout = datafileProjectConfig.GetHoldout("invalid_id");
+ Assert.IsNull(invalidHoldout);
+ }
+
+ [Test]
+ public void TestMissingHoldoutsField_BackwardCompatibility()
+ {
+ // Test that a datafile without holdouts field still works
+ var datafileWithoutHoldouts = @"{
+ ""version"": ""4"",
+ ""rollouts"": [],
+ ""projectId"": ""test_project"",
+ ""experiments"": [],
+ ""groups"": [],
+ ""attributes"": [],
+ ""audiences"": [],
+ ""layers"": [],
+ ""events"": [],
+ ""revision"": ""1"",
+ ""featureFlags"": []
+ }";
+
+ var datafileProjectConfig = DatafileProjectConfig.Create(datafileWithoutHoldouts,
+ new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig;
+
+ Assert.IsNotNull(datafileProjectConfig.Holdouts);
+ Assert.AreEqual(0, datafileProjectConfig.Holdouts.Length);
+
+ // Methods should still work with empty holdouts
+ var holdouts = datafileProjectConfig.GetHoldoutsForFlag("any_flag");
+ Assert.IsNotNull(holdouts);
+ Assert.AreEqual(0, holdouts.Length);
+
+ var holdout = datafileProjectConfig.GetHoldout("any_id");
+ Assert.IsNull(holdout);
+ }
+
+ #endregion
}
}
diff --git a/OptimizelySDK.Tests/TestData/HoldoutTestData.json b/OptimizelySDK.Tests/TestData/HoldoutTestData.json
new file mode 100644
index 00000000..b5c17b26
--- /dev/null
+++ b/OptimizelySDK.Tests/TestData/HoldoutTestData.json
@@ -0,0 +1,192 @@
+{
+ "globalHoldout": {
+ "id": "holdout_global_1",
+ "key": "global_holdout",
+ "status": "Running",
+ "layerId": "layer_1",
+ "variations": [
+ {
+ "id": "var_1",
+ "key": "control",
+ "featureEnabled": false,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "var_1",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedFlags": [],
+ "excludedFlags": []
+ },
+ "includedFlagsHoldout": {
+ "id": "holdout_included_1",
+ "key": "included_holdout",
+ "status": "Running",
+ "layerId": "layer_2",
+ "variations": [
+ {
+ "id": "var_2",
+ "key": "treatment",
+ "featureEnabled": true,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "var_2",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedFlags": ["flag_1", "flag_2"],
+ "excludedFlags": []
+ },
+ "excludedFlagsHoldout": {
+ "id": "holdout_excluded_1",
+ "key": "excluded_holdout",
+ "status": "Running",
+ "layerId": "layer_3",
+ "variations": [
+ {
+ "id": "var_3",
+ "key": "excluded_var",
+ "featureEnabled": false,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "var_3",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedFlags": [],
+ "excludedFlags": ["flag_3", "flag_4"]
+ },
+ "datafileWithHoldouts": {
+ "version": "4",
+ "rollouts": [],
+ "projectId": "test_project",
+ "experiments": [],
+ "groups": [],
+ "attributes": [],
+ "audiences": [],
+ "layers": [],
+ "events": [],
+ "revision": "1",
+ "accountId": "12345",
+ "anonymizeIP": false,
+ "featureFlags": [
+ {
+ "id": "flag_1",
+ "key": "test_flag_1",
+ "experimentIds": [],
+ "rolloutId": "",
+ "variables": []
+ },
+ {
+ "id": "flag_2",
+ "key": "test_flag_2",
+ "experimentIds": [],
+ "rolloutId": "",
+ "variables": []
+ },
+ {
+ "id": "flag_3",
+ "key": "test_flag_3",
+ "experimentIds": [],
+ "rolloutId": "",
+ "variables": []
+ },
+ {
+ "id": "flag_4",
+ "key": "test_flag_4",
+ "experimentIds": [],
+ "rolloutId": "",
+ "variables": []
+ }
+ ],
+ "holdouts": [
+ {
+ "id": "holdout_global_1",
+ "key": "global_holdout",
+ "status": "Running",
+ "layerId": "layer_1",
+ "variations": [
+ {
+ "id": "var_1",
+ "key": "control",
+ "featureEnabled": false,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "var_1",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedFlags": [],
+ "excludedFlags": []
+ },
+ {
+ "id": "holdout_included_1",
+ "key": "included_holdout",
+ "status": "Running",
+ "layerId": "layer_2",
+ "variations": [
+ {
+ "id": "var_2",
+ "key": "treatment",
+ "featureEnabled": true,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "var_2",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedFlags": ["flag_1", "flag_2"],
+ "excludedFlags": []
+ },
+ {
+ "id": "holdout_excluded_1",
+ "key": "excluded_holdout",
+ "status": "Running",
+ "layerId": "layer_3",
+ "variations": [
+ {
+ "id": "var_3",
+ "key": "excluded_var",
+ "featureEnabled": false,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "var_3",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedFlags": [],
+ "excludedFlags": ["flag_3", "flag_4"]
+ }
+ ]
+ }
+}
diff --git a/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs b/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs
new file mode 100644
index 00000000..57593a55
--- /dev/null
+++ b/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs
@@ -0,0 +1,344 @@
+/*
+ * Copyright 2025, Optimizely
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using OptimizelySDK.Entity;
+using OptimizelySDK.Utils;
+
+namespace OptimizelySDK.Tests
+{
+ [TestFixture]
+ public class HoldoutConfigTests
+ {
+ private JObject testData;
+ private Holdout globalHoldout;
+ private Holdout includedHoldout;
+ private Holdout excludedHoldout;
+
+ [SetUp]
+ public void Setup()
+ {
+ // Load test data
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ testData = JObject.Parse(jsonContent);
+
+ // Deserialize test holdouts
+ globalHoldout = JsonConvert.DeserializeObject(testData["globalHoldout"].ToString());
+ includedHoldout = JsonConvert.DeserializeObject(testData["includedFlagsHoldout"].ToString());
+ excludedHoldout = JsonConvert.DeserializeObject(testData["excludedFlagsHoldout"].ToString());
+ }
+
+ [Test]
+ public void TestEmptyHoldouts_ShouldHaveEmptyMaps()
+ {
+ var config = new HoldoutConfig(new Holdout[0]);
+
+ Assert.IsNotNull(config.HoldoutIdMap);
+ Assert.AreEqual(0, config.HoldoutIdMap.Count);
+ Assert.IsNotNull(config.GetHoldoutsForFlag("any_flag"));
+ Assert.AreEqual(0, config.GetHoldoutsForFlag("any_flag").Count);
+ }
+
+ [Test]
+ public void TestHoldoutIdMapping()
+ {
+ var allHoldouts = new[] { globalHoldout, includedHoldout, excludedHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ Assert.IsNotNull(config.HoldoutIdMap);
+ Assert.AreEqual(3, config.HoldoutIdMap.Count);
+
+ Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_global_1"));
+ Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_included_1"));
+ Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_excluded_1"));
+
+ Assert.AreEqual(globalHoldout.Id, config.HoldoutIdMap["holdout_global_1"].Id);
+ Assert.AreEqual(includedHoldout.Id, config.HoldoutIdMap["holdout_included_1"].Id);
+ Assert.AreEqual(excludedHoldout.Id, config.HoldoutIdMap["holdout_excluded_1"].Id);
+ }
+
+ [Test]
+ public void TestGetHoldoutById()
+ {
+ var allHoldouts = new[] { globalHoldout, includedHoldout, excludedHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ var retrievedGlobal = config.GetHoldout("holdout_global_1");
+ var retrievedIncluded = config.GetHoldout("holdout_included_1");
+ var retrievedExcluded = config.GetHoldout("holdout_excluded_1");
+
+ Assert.IsNotNull(retrievedGlobal);
+ Assert.AreEqual("holdout_global_1", retrievedGlobal.Id);
+ Assert.AreEqual("global_holdout", retrievedGlobal.Key);
+
+ Assert.IsNotNull(retrievedIncluded);
+ Assert.AreEqual("holdout_included_1", retrievedIncluded.Id);
+ Assert.AreEqual("included_holdout", retrievedIncluded.Key);
+
+ Assert.IsNotNull(retrievedExcluded);
+ Assert.AreEqual("holdout_excluded_1", retrievedExcluded.Id);
+ Assert.AreEqual("excluded_holdout", retrievedExcluded.Key);
+ }
+
+ [Test]
+ public void TestGetHoldoutById_InvalidId()
+ {
+ var allHoldouts = new[] { globalHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ var result = config.GetHoldout("invalid_id");
+ Assert.IsNull(result);
+ }
+
+ [Test]
+ public void TestGlobalHoldoutsForFlag()
+ {
+ var allHoldouts = new[] { globalHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ var holdoutsForFlag = config.GetHoldoutsForFlag("any_flag_id");
+
+ Assert.IsNotNull(holdoutsForFlag);
+ Assert.AreEqual(1, holdoutsForFlag.Count);
+ Assert.AreEqual("holdout_global_1", holdoutsForFlag[0].Id);
+ }
+
+ [Test]
+ public void TestIncludedHoldoutsForFlag()
+ {
+ var allHoldouts = new[] { includedHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ // Test for included flags
+ var holdoutsForFlag1 = config.GetHoldoutsForFlag("flag_1");
+ var holdoutsForFlag2 = config.GetHoldoutsForFlag("flag_2");
+ var holdoutsForOtherFlag = config.GetHoldoutsForFlag("other_flag");
+
+ Assert.IsNotNull(holdoutsForFlag1);
+ Assert.AreEqual(1, holdoutsForFlag1.Count);
+ Assert.AreEqual("holdout_included_1", holdoutsForFlag1[0].Id);
+
+ Assert.IsNotNull(holdoutsForFlag2);
+ Assert.AreEqual(1, holdoutsForFlag2.Count);
+ Assert.AreEqual("holdout_included_1", holdoutsForFlag2[0].Id);
+
+ Assert.IsNotNull(holdoutsForOtherFlag);
+ Assert.AreEqual(0, holdoutsForOtherFlag.Count);
+ }
+
+ [Test]
+ public void TestExcludedHoldoutsForFlag()
+ {
+ var allHoldouts = new[] { excludedHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ // Test for excluded flags - should NOT appear
+ var holdoutsForFlag3 = config.GetHoldoutsForFlag("flag_3");
+ var holdoutsForFlag4 = config.GetHoldoutsForFlag("flag_4");
+ var holdoutsForOtherFlag = config.GetHoldoutsForFlag("other_flag");
+
+ // Excluded flags should not get this holdout
+ Assert.IsNotNull(holdoutsForFlag3);
+ Assert.AreEqual(0, holdoutsForFlag3.Count);
+
+ Assert.IsNotNull(holdoutsForFlag4);
+ Assert.AreEqual(0, holdoutsForFlag4.Count);
+
+ // Other flags should get this global holdout (with exclusions)
+ Assert.IsNotNull(holdoutsForOtherFlag);
+ Assert.AreEqual(1, holdoutsForOtherFlag.Count);
+ Assert.AreEqual("holdout_excluded_1", holdoutsForOtherFlag[0].Id);
+ }
+
+ [Test]
+ public void TestHoldoutOrdering_GlobalThenIncluded()
+ {
+ // Create additional test holdouts with specific IDs for ordering test
+ var global1 = CreateTestHoldout("global_1", "g1", new string[0], new string[0]);
+ var global2 = CreateTestHoldout("global_2", "g2", new string[0], new string[0]);
+ var included = CreateTestHoldout("included_1", "i1", new[] { "test_flag" }, new string[0]);
+
+ var allHoldouts = new[] { included, global1, global2 };
+ var config = new HoldoutConfig(allHoldouts);
+
+ var holdoutsForFlag = config.GetHoldoutsForFlag("test_flag");
+
+ Assert.IsNotNull(holdoutsForFlag);
+ Assert.AreEqual(3, holdoutsForFlag.Count);
+
+ // Should be: global1, global2, included (global first, then included)
+ var ids = holdoutsForFlag.Select(h => h.Id).ToArray();
+ Assert.Contains("global_1", ids);
+ Assert.Contains("global_2", ids);
+ Assert.Contains("included_1", ids);
+
+ // Included should be last (after globals)
+ Assert.AreEqual("included_1", holdoutsForFlag.Last().Id);
+ }
+
+ [Test]
+ public void TestComplexFlagScenarios_MultipleRules()
+ {
+ var global1 = CreateTestHoldout("global_1", "g1", new string[0], new string[0]);
+ var global2 = CreateTestHoldout("global_2", "g2", new string[0], new string[0]);
+ var included = CreateTestHoldout("included_1", "i1", new[] { "flag_1" }, new string[0]);
+ var excluded = CreateTestHoldout("excluded_1", "e1", new string[0], new[] { "flag_2" });
+
+ var allHoldouts = new[] { included, excluded, global1, global2 };
+ var config = new HoldoutConfig(allHoldouts);
+
+ // Test flag_1: should get globals + excluded global + included
+ var holdoutsForFlag1 = config.GetHoldoutsForFlag("flag_1");
+ Assert.AreEqual(4, holdoutsForFlag1.Count);
+ var flag1Ids = holdoutsForFlag1.Select(h => h.Id).ToArray();
+ Assert.Contains("global_1", flag1Ids);
+ Assert.Contains("global_2", flag1Ids);
+ Assert.Contains("excluded_1", flag1Ids); // excluded global should appear for other flags
+ Assert.Contains("included_1", flag1Ids);
+
+ // Test flag_2: should get only regular globals (excluded global should NOT appear)
+ var holdoutsForFlag2 = config.GetHoldoutsForFlag("flag_2");
+ Assert.AreEqual(2, holdoutsForFlag2.Count);
+ var flag2Ids = holdoutsForFlag2.Select(h => h.Id).ToArray();
+ Assert.Contains("global_1", flag2Ids);
+ Assert.Contains("global_2", flag2Ids);
+ Assert.IsFalse(flag2Ids.Contains("excluded_1")); // Should be excluded
+ Assert.IsFalse(flag2Ids.Contains("included_1")); // Not included for this flag
+
+ // Test flag_3: should get globals + excluded global
+ var holdoutsForFlag3 = config.GetHoldoutsForFlag("flag_3");
+ Assert.AreEqual(3, holdoutsForFlag3.Count);
+ var flag3Ids = holdoutsForFlag3.Select(h => h.Id).ToArray();
+ Assert.Contains("global_1", flag3Ids);
+ Assert.Contains("global_2", flag3Ids);
+ Assert.Contains("excluded_1", flag3Ids);
+ }
+
+ [Test]
+ public void TestExcludedHoldout_ShouldNotAppearInGlobal()
+ {
+ var global = CreateTestHoldout("global_1", "global", new string[0], new string[0]);
+ var excluded = CreateTestHoldout("excluded_1", "excluded", new string[0], new[] { "target_flag" });
+
+ var allHoldouts = new[] { global, excluded };
+ var config = new HoldoutConfig(allHoldouts);
+
+ var holdoutsForTargetFlag = config.GetHoldoutsForFlag("target_flag");
+
+ Assert.IsNotNull(holdoutsForTargetFlag);
+ Assert.AreEqual(1, holdoutsForTargetFlag.Count);
+ Assert.AreEqual("global_1", holdoutsForTargetFlag[0].Id);
+ // excluded should NOT appear for target_flag
+ }
+
+ [Test]
+ public void TestCaching_SecondCallUsesCachedResult()
+ {
+ var allHoldouts = new[] { globalHoldout, includedHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ // First call
+ var firstResult = config.GetHoldoutsForFlag("flag_1");
+
+ // Second call - should use cache
+ var secondResult = config.GetHoldoutsForFlag("flag_1");
+
+ Assert.IsNotNull(firstResult);
+ Assert.IsNotNull(secondResult);
+ Assert.AreEqual(firstResult.Count, secondResult.Count);
+
+ // Results should be the same (caching working)
+ for (int i = 0; i < firstResult.Count; i++)
+ {
+ Assert.AreEqual(firstResult[i].Id, secondResult[i].Id);
+ }
+ }
+
+ [Test]
+ public void TestNullFlagId_ReturnsEmptyList()
+ {
+ var config = new HoldoutConfig(new[] { globalHoldout });
+
+ var result = config.GetHoldoutsForFlag(null);
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(0, result.Count);
+ }
+
+ [Test]
+ public void TestEmptyFlagId_ReturnsEmptyList()
+ {
+ var config = new HoldoutConfig(new[] { globalHoldout });
+
+ var result = config.GetHoldoutsForFlag("");
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(0, result.Count);
+ }
+
+ [Test]
+ public void TestGetHoldoutsForFlag_WithNullHoldouts()
+ {
+ var config = new HoldoutConfig(null);
+
+ var result = config.GetHoldoutsForFlag("any_flag");
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(0, result.Count);
+ }
+
+ [Test]
+ public void TestUpdateHoldoutMapping()
+ {
+ var config = new HoldoutConfig(new[] { globalHoldout });
+
+ // Initial state
+ Assert.AreEqual(1, config.HoldoutIdMap.Count);
+
+ // Update with new holdouts
+ config.UpdateHoldoutMapping(new[] { globalHoldout, includedHoldout });
+
+ Assert.AreEqual(2, config.HoldoutIdMap.Count);
+ Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_global_1"));
+ Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_included_1"));
+ }
+
+ // Helper method to create test holdouts
+ private Holdout CreateTestHoldout(string id, string key, string[] includedFlags, string[] excludedFlags)
+ {
+ return new Holdout
+ {
+ Id = id,
+ Key = key,
+ Status = "Running",
+ Variations = new Variation[0],
+ TrafficAllocation = new TrafficAllocation[0],
+ AudienceIds = new string[0],
+ AudienceConditions = null,
+ IncludedFlags = includedFlags,
+ ExcludedFlags = excludedFlags
+ };
+ }
+ }
+}
diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs
index 465b384a..f940ffe6 100644
--- a/OptimizelySDK/Config/DatafileProjectConfig.cs
+++ b/OptimizelySDK/Config/DatafileProjectConfig.cs
@@ -232,6 +232,11 @@ private Dictionary> _VariationIdMap
public Dictionary> FlagVariationMap =>
_FlagVariationMap;
+ ///
+ /// Holdout configuration manager for flag-to-holdout relationships.
+ ///
+ private HoldoutConfig _holdoutConfig;
+
//========================= Interfaces ===========================
///
@@ -286,6 +291,11 @@ private Dictionary> _VariationIdMap
///
public Rollout[] Rollouts { get; set; }
+ ///
+ /// Associative list of Holdouts.
+ ///
+ public Holdout[] Holdouts { get; set; }
+
///
/// Associative list of Integrations.
///
@@ -309,6 +319,7 @@ private void Initialize()
TypedAudiences = TypedAudiences ?? new Audience[0];
FeatureFlags = FeatureFlags ?? new FeatureFlag[0];
Rollouts = Rollouts ?? new Rollout[0];
+ Holdouts = Holdouts ?? new Holdout[0];
Integrations = Integrations ?? new Integration[0];
_ExperimentKeyMap = new Dictionary();
@@ -450,6 +461,9 @@ private void Initialize()
}
_FlagVariationMap = flagToVariationsMap;
+
+ // Initialize HoldoutConfig for managing flag-to-holdout relationships
+ _holdoutConfig = new HoldoutConfig(Holdouts ?? new Holdout[0]);
}
///
@@ -767,6 +781,16 @@ public Rollout GetRolloutFromId(string rolloutId)
return new Rollout();
}
+ ///
+ /// Get the holdout from the ID
+ ///
+ /// ID for holdout
+ /// Holdout Entity corresponding to the holdout ID or null if ID is invalid
+ public Holdout GetHoldout(string holdoutId)
+ {
+ return _holdoutConfig.GetHoldout(holdoutId);
+ }
+
///
/// Get attribute ID for the provided attribute key
///
@@ -828,6 +852,15 @@ public string ToDatafile()
}
///
+ /// Get holdout instances associated with the given feature flag key.
+ ///
+ /// Feature flag key
+ /// Array of holdouts associated with the flag, empty array if none
+ public Holdout[] GetHoldoutsForFlag(string flagKey)
+ {
+ var holdouts = _holdoutConfig?.GetHoldoutsForFlag(flagKey);
+ return holdouts?.ToArray() ?? new Holdout[0];
+ }
/// Returns the datafile corresponding to ProjectConfig
///
/// the datafile string corresponding to ProjectConfig
diff --git a/OptimizelySDK/Entity/Experiment.cs b/OptimizelySDK/Entity/Experiment.cs
index e1eee5f2..dd25f68c 100644
--- a/OptimizelySDK/Entity/Experiment.cs
+++ b/OptimizelySDK/Entity/Experiment.cs
@@ -15,38 +15,22 @@
*/
using System.Collections.Generic;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-using OptimizelySDK.AudienceConditions;
-using OptimizelySDK.Utils;
namespace OptimizelySDK.Entity
{
- public class Experiment : IdKeyEntity
+ public class Experiment : ExperimentCore
{
- private const string STATUS_RUNNING = "Running";
-
private const string MUTEX_GROUP_POLICY = "random";
- ///
- /// Experiment Status
- ///
- public string Status { get; set; }
-
- ///
- /// Layer ID for the experiment
- ///
- public string LayerId { get; set; }
-
///
/// Group ID for the experiment
///
public string GroupId { get; set; }
///
- /// Variations for the experiment
+ /// Layer ID for the experiment
///
- public Variation[] Variations { get; set; }
+ public string LayerId { get; set; }
///
/// ForcedVariations for the experiment
@@ -63,203 +47,6 @@ public class Experiment : IdKeyEntity
///
public string GroupPolicy { get; set; }
- ///
- /// ID(s) of audience(s) the experiment is targeted to
- ///
- public string[] AudienceIds { get; set; }
-
- private ICondition _audienceIdsList = null;
-
- ///
- /// De-serialized audience conditions
- ///
- public ICondition AudienceIdsList
- {
- get
- {
- if (AudienceIds == null || AudienceIds.Length == 0)
- {
- return null;
- }
-
- if (_audienceIdsList == null)
- {
- var conditions = new List();
- foreach (var audienceId in AudienceIds)
- {
- conditions.Add(
- new AudienceIdCondition() { AudienceId = (string)audienceId });
- }
-
- _audienceIdsList = new OrCondition() { Conditions = conditions.ToArray() };
- }
-
- return _audienceIdsList;
- }
- }
-
- private string _audienceIdsString = null;
-
- ///
- /// Stringified audience conditions
- ///
- public string AudienceIdsString
- {
- get
- {
- if (AudienceIds == null)
- {
- return null;
- }
-
- if (_audienceIdsString == null)
- {
- _audienceIdsString = JsonConvert.SerializeObject(AudienceIds, Formatting.None);
- }
-
- return _audienceIdsString;
- }
- }
-
- ///
- /// Traffic allocation of variations in the experiment
- ///
- public TrafficAllocation[] TrafficAllocation { get; set; }
-
- ///
- /// Audience Conditions
- ///
- public object AudienceConditions { get; set; }
-
- private ICondition _audienceConditionsList = null;
-
- ///
- /// De-serialized audience conditions
- ///
- public ICondition AudienceConditionsList
- {
- get
- {
- if (AudienceConditions == null)
- {
- return null;
- }
-
- if (_audienceConditionsList == null)
- {
- if (AudienceConditions is string)
- {
- _audienceConditionsList =
- ConditionParser.ParseAudienceConditions(
- JToken.Parse((string)AudienceConditions));
- }
- else
- {
- _audienceConditionsList =
- ConditionParser.ParseAudienceConditions((JToken)AudienceConditions);
- }
- }
-
- return _audienceConditionsList;
- }
- }
-
- private string _audienceConditionsString = null;
-
- ///
- /// Stringified audience conditions
- ///
- public string AudienceConditionsString
- {
- get
- {
- if (AudienceConditions == null)
- {
- return null;
- }
-
- if (_audienceConditionsString == null)
- {
- if (AudienceConditions is JToken token)
- {
- _audienceConditionsString = token.ToString(Formatting.None);
- }
- else
- {
- _audienceConditionsString = AudienceConditions.ToString();
- }
- }
-
- return _audienceConditionsString;
- }
- }
-
- private bool isGenerateKeyMapCalled = false;
-
- private Dictionary _VariationKeyToVariationMap;
-
- public Dictionary VariationKeyToVariationMap
- {
- get
- {
- if (!isGenerateKeyMapCalled)
- {
- GenerateVariationKeyMap();
- }
-
- return _VariationKeyToVariationMap;
- }
- }
-
- private Dictionary _VariationIdToVariationMap;
-
- public Dictionary VariationIdToVariationMap
- {
- get
- {
- if (!isGenerateKeyMapCalled)
- {
- GenerateVariationKeyMap();
- }
-
- return _VariationIdToVariationMap;
- }
- }
-
- public void GenerateVariationKeyMap()
- {
- if (Variations == null)
- {
- return;
- }
-
- _VariationIdToVariationMap =
- ConfigParser.GenerateMap(Variations, a => a.Id, true);
- _VariationKeyToVariationMap =
- ConfigParser.GenerateMap(Variations, a => a.Key, true);
- isGenerateKeyMapCalled = true;
- }
-
- // Code from PHP, need to build traffic and variations from config
-#if false
- /**
- * @param $variations array Variations in experiment.
- */
- public function setVariations($variations)
- {
- $this->_variations = ConfigParser::generateMap($variations, null, Variation::class);
- }
-
- /**
- * @param $trafficAllocation array Traffic allocation of variations in experiment.
- */
- public function setTrafficAllocation($trafficAllocation)
- {
- $this->_trafficAllocation =
- ConfigParser::generateMap($trafficAllocation, null, TrafficAllocation::class);
- }
-#endif
-
///
/// Determine if experiment is in a mutually exclusive group
///
@@ -281,5 +68,10 @@ public bool IsUserInForcedVariation(string userId)
{
return ForcedVariations != null && ForcedVariations.ContainsKey(userId);
}
+
+ ///
+ /// Determine if experiment is currently activated/running (implementation of abstract property)
+ ///
+ public override bool IsActivated => IsExperimentRunning;
}
}
diff --git a/OptimizelySDK/Entity/ExperimentCore.cs b/OptimizelySDK/Entity/ExperimentCore.cs
new file mode 100644
index 00000000..61dba9d8
--- /dev/null
+++ b/OptimizelySDK/Entity/ExperimentCore.cs
@@ -0,0 +1,276 @@
+/*
+ * Copyright 2025, Optimizely
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using OptimizelySDK.AudienceConditions;
+using OptimizelySDK.Utils;
+
+namespace OptimizelySDK.Entity
+{
+ ///
+ /// Abstract base class containing common properties and behaviors shared between Experiment and Holdout
+ ///
+ public abstract class ExperimentCore : IdKeyEntity
+ {
+ protected const string STATUS_RUNNING = "Running";
+
+ ///
+ /// Status of the experiment/holdout
+ ///
+ public string Status { get; set; }
+
+ ///
+ /// Variations for the experiment/holdout
+ ///
+ public Variation[] Variations { get; set; }
+
+ ///
+ /// Traffic allocation of variations in the experiment/holdout
+ ///
+ public TrafficAllocation[] TrafficAllocation { get; set; }
+
+ ///
+ /// ID(s) of audience(s) the experiment/holdout is targeted to
+ ///
+ public string[] AudienceIds { get; set; }
+
+ ///
+ /// Audience Conditions
+ ///
+ public object AudienceConditions { get; set; }
+
+ #region Audience Processing Properties
+
+ private ICondition _audienceIdsList = null;
+
+ ///
+ /// De-serialized audience conditions from audience IDs
+ ///
+ public ICondition AudienceIdsList
+ {
+ get
+ {
+ if (AudienceIds == null || AudienceIds.Length == 0)
+ {
+ return null;
+ }
+
+ if (_audienceIdsList == null)
+ {
+ var conditions = new List();
+ foreach (var audienceId in AudienceIds)
+ {
+ conditions.Add(new AudienceIdCondition() { AudienceId = audienceId });
+ }
+
+ _audienceIdsList = new OrCondition() { Conditions = conditions.ToArray() };
+ }
+
+ return _audienceIdsList;
+ }
+ }
+
+ private string _audienceIdsString = null;
+
+ ///
+ /// Stringified audience IDs
+ ///
+ public string AudienceIdsString
+ {
+ get
+ {
+ if (AudienceIds == null)
+ {
+ return null;
+ }
+
+ if (_audienceIdsString == null)
+ {
+ _audienceIdsString = JsonConvert.SerializeObject(AudienceIds, Formatting.None);
+ }
+
+ return _audienceIdsString;
+ }
+ }
+
+ private ICondition _audienceConditionsList = null;
+
+ ///
+ /// De-serialized audience conditions
+ ///
+ public ICondition AudienceConditionsList
+ {
+ get
+ {
+ if (AudienceConditions == null)
+ {
+ return null;
+ }
+
+ if (_audienceConditionsList == null)
+ {
+ if (AudienceConditions is string)
+ {
+ _audienceConditionsList =
+ ConditionParser.ParseAudienceConditions(
+ JToken.Parse((string)AudienceConditions));
+ }
+ else
+ {
+ _audienceConditionsList =
+ ConditionParser.ParseAudienceConditions((JToken)AudienceConditions);
+ }
+ }
+
+ return _audienceConditionsList;
+ }
+ }
+
+ private string _audienceConditionsString = null;
+
+ ///
+ /// Stringified audience conditions
+ ///
+ public string AudienceConditionsString
+ {
+ get
+ {
+ if (AudienceConditions == null)
+ {
+ _audienceConditionsString = null;
+ return null;
+ }
+
+ if (_audienceConditionsString == null)
+ {
+ if (AudienceConditions is JToken token)
+ {
+ _audienceConditionsString = token.ToString(Formatting.None);
+ }
+ else
+ {
+ _audienceConditionsString = AudienceConditions.ToString();
+ }
+ }
+
+ return _audienceConditionsString;
+ }
+ }
+
+ #endregion
+
+ #region Variation Mapping Properties
+
+ private bool isGenerateKeyMapCalled = false;
+
+ private Dictionary _VariationKeyToVariationMap;
+
+ ///
+ /// Variation key to variation mapping
+ ///
+ public Dictionary VariationKeyToVariationMap
+ {
+ get
+ {
+ if (!isGenerateKeyMapCalled)
+ {
+ GenerateVariationKeyMap();
+ }
+
+ return _VariationKeyToVariationMap;
+ }
+ }
+
+ private Dictionary _VariationIdToVariationMap;
+
+ ///
+ /// Variation ID to variation mapping
+ ///
+ public Dictionary VariationIdToVariationMap
+ {
+ get
+ {
+ if (!isGenerateKeyMapCalled)
+ {
+ GenerateVariationKeyMap();
+ }
+
+ return _VariationIdToVariationMap;
+ }
+ }
+
+ ///
+ /// Generate variation key maps for performance optimization
+ ///
+ public void GenerateVariationKeyMap()
+ {
+ if (Variations == null)
+ {
+ return;
+ }
+
+ _VariationIdToVariationMap =
+ ConfigParser.GenerateMap(Variations, a => a.Id, true);
+ _VariationKeyToVariationMap =
+ ConfigParser.GenerateMap(Variations, a => a.Key, true);
+ isGenerateKeyMapCalled = true;
+ }
+
+ #endregion
+
+ #region Variation Helper Methods
+
+ ///
+ /// Get variation by ID
+ ///
+ /// Variation ID to search for
+ /// Variation with the specified ID, or null if not found
+ public virtual Variation GetVariation(string id)
+ {
+ if (Variations == null || string.IsNullOrEmpty(id))
+ {
+ return null;
+ }
+
+ return Variations.FirstOrDefault(v => v.Id == id);
+ }
+
+ ///
+ /// Get variation by key
+ ///
+ /// Variation key to search for
+ /// Variation with the specified key, or null if not found
+ public virtual Variation GetVariationByKey(string key)
+ {
+ if (Variations == null || string.IsNullOrEmpty(key))
+ {
+ return null;
+ }
+
+ return Variations.FirstOrDefault(v => v.Key == key);
+ }
+
+ #endregion
+
+ ///
+ /// Determine if experiment/holdout is currently activated/running
+ ///
+ public abstract bool IsActivated { get; }
+ }
+}
diff --git a/OptimizelySDK/Entity/Holdout.cs b/OptimizelySDK/Entity/Holdout.cs
new file mode 100644
index 00000000..17f4c2bd
--- /dev/null
+++ b/OptimizelySDK/Entity/Holdout.cs
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2025, Optimizely
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System.Collections.Generic;
+
+namespace OptimizelySDK.Entity
+{
+ ///
+ /// Represents a holdout in an Optimizely project
+ ///
+ public class Holdout : ExperimentCore
+ {
+ ///
+ /// Holdout status enumeration
+ ///
+ public enum HoldoutStatus
+ {
+ Draft,
+ Running,
+ Concluded,
+ Archived
+ }
+
+ ///
+ /// Flags included in this holdout
+ ///
+ public string[] IncludedFlags { get; set; } = new string[0];
+
+ ///
+ /// Flags excluded from this holdout
+ ///
+ public string[] ExcludedFlags { get; set; } = new string[0];
+
+ ///
+ /// Determine if holdout is currently activated/running
+ ///
+ public override bool IsActivated =>
+ !string.IsNullOrEmpty(Status) && Status == STATUS_RUNNING;
+
+ }
+}
diff --git a/OptimizelySDK/Exceptions/OptimizelyException.cs b/OptimizelySDK/Exceptions/OptimizelyException.cs
index ad9cf4d5..ba150b2d 100644
--- a/OptimizelySDK/Exceptions/OptimizelyException.cs
+++ b/OptimizelySDK/Exceptions/OptimizelyException.cs
@@ -102,4 +102,10 @@ public class ParseException : OptimizelyException
public ParseException(string message)
: base(message) { }
}
+ public class InvalidHoldoutException : OptimizelyException
+ {
+ public InvalidHoldoutException(string message)
+ : base(message) { }
+ }
}
+
diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj
index 5f041ac1..14201b4e 100644
--- a/OptimizelySDK/OptimizelySDK.csproj
+++ b/OptimizelySDK/OptimizelySDK.csproj
@@ -84,6 +84,8 @@
+
+
@@ -171,6 +173,7 @@
+
diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs
index 8aab34f7..6a2b5259 100644
--- a/OptimizelySDK/ProjectConfig.cs
+++ b/OptimizelySDK/ProjectConfig.cs
@@ -175,6 +175,11 @@ public interface ProjectConfig
///
Rollout[] Rollouts { get; set; }
+ ///
+ /// Associative list of Holdouts.
+ ///
+ Holdout[] Holdouts { get; set; }
+
///
/// Associative list of Integrations.
///
@@ -308,6 +313,20 @@ public interface ProjectConfig
/// List| Feature flag ids list, null otherwise
List GetExperimentFeatureList(string experimentId);
+ ///
+ /// Get the holdout from the ID
+ ///
+ /// ID for holdout
+ /// Holdout Entity corresponding to the holdout ID or null if ID is invalid
+ Holdout GetHoldout(string holdoutId);
+
+ ///
+ /// Get holdout instances associated with the given feature flag key.
+ ///
+ /// Feature flag key
+ /// Array of holdouts associated with the flag, empty array if none
+ Holdout[] GetHoldoutsForFlag(string flagKey);
+
///
/// Returns the datafile corresponding to ProjectConfig
///
diff --git a/OptimizelySDK/Utils/HoldoutConfig.cs b/OptimizelySDK/Utils/HoldoutConfig.cs
new file mode 100644
index 00000000..6b717af2
--- /dev/null
+++ b/OptimizelySDK/Utils/HoldoutConfig.cs
@@ -0,0 +1,193 @@
+/*
+ * Copyright 2025, Optimizely
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System.Collections.Generic;
+using System.Linq;
+using OptimizelySDK.Entity;
+
+namespace OptimizelySDK.Utils
+{
+ ///
+ /// Configuration manager for holdouts, providing flag-to-holdout relationship mapping and optimization logic.
+ ///
+ public class HoldoutConfig
+ {
+ private List _allHoldouts;
+ private readonly List _globalHoldouts;
+ private readonly Dictionary _holdoutIdMap;
+ private readonly Dictionary> _includedHoldouts;
+ private readonly Dictionary> _excludedHoldouts;
+ private readonly Dictionary> _flagHoldoutCache;
+
+ ///
+ /// Initializes a new instance of the HoldoutConfig class.
+ ///
+ /// Array of all holdouts from the datafile
+ public HoldoutConfig(Holdout[] allHoldouts = null)
+ {
+ _allHoldouts = allHoldouts?.ToList() ?? new List();
+ _globalHoldouts = new List();
+ _holdoutIdMap = new Dictionary();
+ _includedHoldouts = new Dictionary>();
+ _excludedHoldouts = new Dictionary>();
+ _flagHoldoutCache = new Dictionary>();
+
+ UpdateHoldoutMapping();
+ }
+
+ ///
+ /// Gets a read-only dictionary mapping holdout IDs to holdout instances.
+ ///
+ public IDictionary HoldoutIdMap => _holdoutIdMap;
+
+ ///
+ /// Updates internal mappings of holdouts including the id map, global list, and per-flag inclusion/exclusion maps.
+ ///
+ private void UpdateHoldoutMapping()
+ {
+ // Clear existing mappings
+ _holdoutIdMap.Clear();
+ _globalHoldouts.Clear();
+ _includedHoldouts.Clear();
+ _excludedHoldouts.Clear();
+ _flagHoldoutCache.Clear();
+
+ foreach (var holdout in _allHoldouts)
+ {
+ // Build ID mapping
+ _holdoutIdMap[holdout.Id] = holdout;
+
+ var hasIncludedFlags = holdout.IncludedFlags != null && holdout.IncludedFlags.Length > 0;
+ var hasExcludedFlags = holdout.ExcludedFlags != null && holdout.ExcludedFlags.Length > 0;
+
+ if (hasIncludedFlags)
+ {
+ // Local/targeted holdout - only applies to specific included flags
+ foreach (var flagId in holdout.IncludedFlags)
+ {
+ if (!_includedHoldouts.ContainsKey(flagId))
+ _includedHoldouts[flagId] = new List();
+
+ _includedHoldouts[flagId].Add(holdout);
+ }
+ }
+ else
+ {
+ // Global holdout (applies to all flags)
+ _globalHoldouts.Add(holdout);
+
+ // If it has excluded flags, track which flags to exclude it from
+ if (hasExcludedFlags)
+ {
+ foreach (var flagId in holdout.ExcludedFlags)
+ {
+ if (!_excludedHoldouts.ContainsKey(flagId))
+ _excludedHoldouts[flagId] = new List();
+
+ _excludedHoldouts[flagId].Add(holdout);
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Returns the applicable holdouts for the given flag ID by combining global holdouts (excluding any specified) and included holdouts, in that order.
+ /// Caches the result for future calls.
+ ///
+ /// The flag identifier
+ /// A list of Holdout objects relevant to the given flag
+ public List GetHoldoutsForFlag(string flagId)
+ {
+ if (string.IsNullOrEmpty(flagId) || _allHoldouts.Count == 0)
+ return new List();
+
+ // Check cache first
+ if (_flagHoldoutCache.ContainsKey(flagId))
+ return _flagHoldoutCache[flagId];
+
+ var activeHoldouts = new List();
+ // Start with global holdouts, excluding any that are specifically excluded for this flag
+ var excludedForFlag = _excludedHoldouts.ContainsKey(flagId) ? _excludedHoldouts[flagId] : new List();
+
+ if (excludedForFlag.Count > 0)
+ {
+ // Only iterate if we have exclusions to check
+ foreach (var globalHoldout in _globalHoldouts)
+ {
+ if (!excludedForFlag.Contains(globalHoldout))
+ {
+ activeHoldouts.Add(globalHoldout);
+ }
+ }
+ }
+ else
+ {
+ // No exclusions, add all global holdouts directly
+ activeHoldouts.AddRange(_globalHoldouts);
+ }
+
+ // Add included holdouts for this flag
+ if (_includedHoldouts.ContainsKey(flagId))
+ {
+ activeHoldouts.AddRange(_includedHoldouts[flagId]);
+ }
+
+ // Cache the result
+ _flagHoldoutCache[flagId] = activeHoldouts;
+
+ return activeHoldouts;
+ }
+
+ ///
+ /// Get a Holdout object for an ID.
+ ///
+ /// The holdout identifier
+ /// The Holdout object if found, null otherwise
+ public Holdout GetHoldout(string holdoutId)
+ {
+ if (string.IsNullOrEmpty(holdoutId))
+ {
+ return null;
+ }
+
+ _holdoutIdMap.TryGetValue(holdoutId, out var holdout);
+
+ return holdout;
+ }
+
+ ///
+ /// Gets the total number of holdouts.
+ ///
+ public int HoldoutCount => _allHoldouts.Count;
+
+ ///
+ /// Gets the number of global holdouts.
+ ///
+ public int GlobalHoldoutCount => _globalHoldouts.Count;
+
+ ///
+ /// Updates the holdout configuration with a new set of holdouts.
+ /// This method is useful for testing or when the holdout configuration needs to be updated at runtime.
+ ///
+ /// The new array of holdouts to use
+ public void UpdateHoldoutMapping(Holdout[] newHoldouts)
+ {
+ _allHoldouts = newHoldouts?.ToList() ?? new List();
+ UpdateHoldoutMapping();
+ }
+ }
+}
From 8dbaeb1ae1c7d0e2c30474a650d38ccd20618e46 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Mon, 25 Aug 2025 18:27:28 +0600
Subject: [PATCH 09/18] [FSSDK-11546] holdout + decision service impl. (#386)
---
OptimizelySDK.Tests/Assertions.cs | 31 +-
OptimizelySDK.Tests/BucketerHoldoutTest.cs | 369 +++++++++++
.../DecisionServiceHoldoutTest.cs | 246 ++++++++
.../EntityTests/HoldoutTests.cs | 10 -
.../OptimizelySDK.Tests.csproj | 3 +
.../OptimizelyUserContextHoldoutTest.cs | 577 ++++++++++++++++++
OptimizelySDK.Tests/ProjectConfigTest.cs | 8 +-
.../TestData/HoldoutTestData.json | 22 +
OptimizelySDK/Bucketing/Bucketer.cs | 10 +-
OptimizelySDK/Bucketing/DecisionService.cs | 179 ++++--
OptimizelySDK/Config/DatafileProjectConfig.cs | 23 +
OptimizelySDK/Entity/Experiment.cs | 16 -
OptimizelySDK/Entity/ExperimentCore.cs | 9 +-
OptimizelySDK/Entity/FeatureDecision.cs | 6 +-
OptimizelySDK/Entity/Holdout.cs | 13 +-
OptimizelySDK/Event/Entity/ImpressionEvent.cs | 6 +-
OptimizelySDK/Event/UserEventFactory.cs | 2 +-
OptimizelySDK/Optimizely.cs | 4 +-
OptimizelySDK/Utils/ExperimentUtils.cs | 13 +-
19 files changed, 1451 insertions(+), 96 deletions(-)
create mode 100644 OptimizelySDK.Tests/BucketerHoldoutTest.cs
create mode 100644 OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs
create mode 100644 OptimizelySDK.Tests/OptimizelyUserContextHoldoutTest.cs
diff --git a/OptimizelySDK.Tests/Assertions.cs b/OptimizelySDK.Tests/Assertions.cs
index 9dfe2f7b..3544d621 100644
--- a/OptimizelySDK.Tests/Assertions.cs
+++ b/OptimizelySDK.Tests/Assertions.cs
@@ -488,7 +488,7 @@ public static void AreEqual(Experiment expected, Experiment actual)
Assert.AreEqual(expected.GroupId, actual.GroupId);
Assert.AreEqual(expected.GroupPolicy, actual.GroupPolicy);
Assert.AreEqual(expected.Id, actual.Id);
- Assert.AreEqual(expected.IsExperimentRunning, actual.IsExperimentRunning);
+ Assert.AreEqual(expected.isRunning, actual.isRunning);
Assert.AreEqual(expected.IsInMutexGroup, actual.IsInMutexGroup);
Assert.AreEqual(expected.Key, actual.Key);
Assert.AreEqual(expected.LayerId, actual.LayerId);
@@ -500,6 +500,33 @@ public static void AreEqual(Experiment expected, Experiment actual)
AreEquivalent(expected.Variations, actual.Variations);
}
+ public static void AreEqual(ExperimentCore expected, ExperimentCore actual)
+ {
+ if (expected == null && actual == null)
+ {
+ return;
+ }
+
+ Assert.IsNotNull(expected, "Expected ExperimentCore should not be null");
+ Assert.IsNotNull(actual, "Actual ExperimentCore should not be null");
+
+ Assert.AreEqual(expected.AudienceConditions, actual.AudienceConditions);
+ Assert.AreEqual(expected.AudienceConditionsList, actual.AudienceConditionsList);
+ Assert.AreEqual(expected.AudienceConditionsString, actual.AudienceConditionsString);
+ AreEquivalent(expected.AudienceIds, actual.AudienceIds);
+ Assert.AreEqual(expected.AudienceIdsList, actual.AudienceIdsList);
+ Assert.AreEqual(expected.AudienceIdsString, actual.AudienceIdsString);
+ Assert.AreEqual(expected.Id, actual.Id);
+ Assert.AreEqual(expected.isRunning, actual.isRunning);
+ Assert.AreEqual(expected.Key, actual.Key);
+ Assert.AreEqual(expected.LayerId, actual.LayerId);
+ Assert.AreEqual(expected.Status, actual.Status);
+ AreEquivalent(expected.TrafficAllocation, actual.TrafficAllocation);
+ AreEquivalent(expected.VariationIdToVariationMap, actual.VariationIdToVariationMap);
+ AreEquivalent(expected.VariationKeyToVariationMap, actual.VariationKeyToVariationMap);
+ AreEquivalent(expected.Variations, actual.Variations);
+ }
+
#endregion Experiment
#region FeatureDecision
@@ -507,6 +534,8 @@ public static void AreEqual(Experiment expected, Experiment actual)
public static void AreEqual(FeatureDecision expected, FeatureDecision actual)
{
AreEqual(expected.Experiment, actual.Experiment);
+ AreEqual(expected.Variation, actual.Variation);
+ Assert.AreEqual(expected.Source, actual.Source);
}
#endregion FeatureDecision
diff --git a/OptimizelySDK.Tests/BucketerHoldoutTest.cs b/OptimizelySDK.Tests/BucketerHoldoutTest.cs
new file mode 100644
index 00000000..742d8215
--- /dev/null
+++ b/OptimizelySDK.Tests/BucketerHoldoutTest.cs
@@ -0,0 +1,369 @@
+/*
+ * Copyright 2025, Optimizely
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System.IO;
+using Moq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using OptimizelySDK.Bucketing;
+using OptimizelySDK.Config;
+using OptimizelySDK.Entity;
+using OptimizelySDK.Logger;
+using OptimizelySDK.OptimizelyDecisions;
+
+namespace OptimizelySDK.Tests
+{
+ [TestFixture]
+ public class BucketerHoldoutTest
+ {
+ private Mock LoggerMock;
+ private Bucketer Bucketer;
+ private TestBucketer TestBucketer;
+ private ProjectConfig Config;
+ private JObject TestData;
+ private const string TestUserId = "test_user_id";
+ private const string TestBucketingId = "test_bucketing_id";
+
+ [SetUp]
+ public void Initialize()
+ {
+ LoggerMock = new Mock();
+
+ // Load holdout test data
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ TestData = JObject.Parse(jsonContent);
+
+ // Use datafile with holdouts for proper config setup
+ var datafileWithHoldouts = TestData["datafileWithHoldouts"].ToString();
+ Config = DatafileProjectConfig.Create(datafileWithHoldouts, LoggerMock.Object,
+ new ErrorHandler.NoOpErrorHandler());
+ TestBucketer = new TestBucketer(LoggerMock.Object);
+
+ // Verify that the config contains holdouts
+ Assert.IsNotNull(Config.Holdouts, "Config should have holdouts");
+ Assert.IsTrue(Config.Holdouts.Length > 0, "Config should contain holdouts");
+ }
+
+ [Test]
+ public void TestBucketHoldout_ValidTrafficAllocation()
+ {
+ // Test user bucketed within traffic allocation range
+ // Use the global holdout from config which has multiple variations
+ var holdout = Config.GetHoldout("holdout_global_1");
+ Assert.IsNotNull(holdout, "Holdout should exist in config");
+
+ // Set bucket value to be within first variation's traffic allocation (0-5000 range)
+ TestBucketer.SetBucketValues(new[] { 2500 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNotNull(result.ResultObject);
+ Assert.AreEqual("var_1", result.ResultObject.Id);
+ Assert.AreEqual("control", result.ResultObject.Key);
+
+ // Verify logging
+ LoggerMock.Verify(l => l.Log(LogLevel.DEBUG,
+ It.Is(s => s.Contains($"Assigned bucket [2500] to user [{TestUserId}]"))),
+ Times.Once);
+ }
+
+ [Test]
+ public void TestBucketHoldout_UserOutsideAllocation()
+ {
+ // Test user not bucketed when outside traffic allocation range
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Modify traffic allocation to be smaller (0-1000 range = 10%)
+ holdout.TrafficAllocation[0].EndOfRange = 1000;
+
+ // Set bucket value outside traffic allocation range
+ TestBucketer.SetBucketValues(new[] { 1500 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNull(result.ResultObject.Id);
+ Assert.IsNull(result.ResultObject.Key);
+
+ // Verify user was assigned bucket value but no variation was found
+ LoggerMock.Verify(l => l.Log(LogLevel.DEBUG,
+ It.Is(s => s.Contains($"Assigned bucket [1500] to user [{TestUserId}]"))),
+ Times.Once);
+ }
+
+ [Test]
+ public void TestBucketHoldout_NoTrafficAllocation()
+ {
+ // Test holdout with empty traffic allocation
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Clear traffic allocation
+ holdout.TrafficAllocation = new TrafficAllocation[0];
+
+ TestBucketer.SetBucketValues(new[] { 5000 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNull(result.ResultObject.Id);
+ Assert.IsNull(result.ResultObject.Key);
+
+ // Verify bucket was assigned but no variation found
+ LoggerMock.Verify(l => l.Log(LogLevel.DEBUG,
+ It.Is(s => s.Contains($"Assigned bucket [5000] to user [{TestUserId}]"))),
+ Times.Once);
+ }
+
+ [Test]
+ public void TestBucketHoldout_InvalidVariationId()
+ {
+ // Test holdout with invalid variation ID in traffic allocation
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Set traffic allocation to point to non-existent variation
+ holdout.TrafficAllocation[0].EntityId = "invalid_variation_id";
+
+ TestBucketer.SetBucketValues(new[] { 5000 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNull(result.ResultObject.Id);
+ Assert.IsNull(result.ResultObject.Key);
+
+ // Verify bucket was assigned
+ LoggerMock.Verify(l => l.Log(LogLevel.DEBUG,
+ It.Is(s => s.Contains($"Assigned bucket [5000] to user [{TestUserId}]"))),
+ Times.Once);
+ }
+
+ [Test]
+ public void TestBucketHoldout_EmptyVariations()
+ {
+ // Test holdout with no variations - use holdout from datafile that has no variations
+ var holdout = Config.GetHoldout("holdout_empty_1");
+ Assert.IsNotNull(holdout, "Empty holdout should exist in config");
+ Assert.AreEqual(0, holdout.Variations?.Length ?? 0, "Holdout should have no variations");
+
+ TestBucketer.SetBucketValues(new[] { 5000 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNull(result.ResultObject.Id);
+ Assert.IsNull(result.ResultObject.Key);
+
+ // Verify bucket was assigned
+ LoggerMock.Verify(l => l.Log(LogLevel.DEBUG,
+ It.Is(s => s.Contains($"Assigned bucket [5000] to user [{TestUserId}]"))),
+ Times.Once);
+ }
+
+ [Test]
+ public void TestBucketHoldout_EmptyExperimentKey()
+ {
+ // Test holdout with empty key
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Clear holdout key
+ holdout.Key = "";
+
+ TestBucketer.SetBucketValues(new[] { 5000 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ // Should return empty variation for invalid experiment key
+ Assert.IsNotNull(result.ResultObject);
+ Assert.IsNull(result.ResultObject.Id);
+ Assert.IsNull(result.ResultObject.Key);
+ }
+
+ [Test]
+ public void TestBucketHoldout_NullExperimentKey()
+ {
+ // Test holdout with null key
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Set holdout key to null
+ holdout.Key = null;
+
+ TestBucketer.SetBucketValues(new[] { 5000 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ // Should return empty variation for null experiment key
+ Assert.IsNotNull(result.ResultObject);
+ Assert.IsNull(result.ResultObject.Id);
+ Assert.IsNull(result.ResultObject.Key);
+ }
+
+ [Test]
+ public void TestBucketHoldout_MultipleVariationsInRange()
+ {
+ // Test holdout with multiple variations and user buckets into first one
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Add a second variation
+ var variation2 = new Variation
+ {
+ Id = "var_2",
+ Key = "treatment",
+ FeatureEnabled = true
+ };
+ holdout.Variations = new[] { holdout.Variations[0], variation2 };
+
+ // Set traffic allocation for first variation (0-5000) and second (5000-10000)
+ holdout.TrafficAllocation = new[]
+ {
+ new TrafficAllocation { EntityId = "var_1", EndOfRange = 5000 },
+ new TrafficAllocation { EntityId = "var_2", EndOfRange = 10000 }
+ };
+
+ // Test user buckets into first variation
+ TestBucketer.SetBucketValues(new[] { 2500 });
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNotNull(result.ResultObject);
+ Assert.AreEqual("var_1", result.ResultObject.Id);
+ Assert.AreEqual("control", result.ResultObject.Key);
+ }
+
+ [Test]
+ public void TestBucketHoldout_MultipleVariationsInSecondRange()
+ {
+ // Test holdout with multiple variations and user buckets into second one
+ // Use the global holdout from config which now has multiple variations
+ var holdout = Config.GetHoldout("holdout_global_1");
+ Assert.IsNotNull(holdout, "Holdout should exist in config");
+
+ // Verify holdout has multiple variations
+ Assert.IsTrue(holdout.Variations.Length >= 2, "Holdout should have multiple variations");
+ Assert.AreEqual("var_1", holdout.Variations[0].Id);
+ Assert.AreEqual("var_2", holdout.Variations[1].Id);
+
+ // Test user buckets into second variation (bucket value 7500 should be in 5000-10000 range)
+ TestBucketer.SetBucketValues(new[] { 7500 });
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNotNull(result.ResultObject);
+ Assert.AreEqual("var_2", result.ResultObject.Id);
+ Assert.AreEqual("treatment", result.ResultObject.Key);
+ }
+
+ [Test]
+ public void TestBucketHoldout_EdgeCaseBoundaryValues()
+ {
+ // Test edge cases at traffic allocation boundaries
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Set traffic allocation to 5000 (50%)
+ holdout.TrafficAllocation[0].EndOfRange = 5000;
+
+ // Test exact boundary value (should be included)
+ TestBucketer.SetBucketValues(new[] { 4999 });
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNotNull(result.ResultObject);
+ Assert.AreEqual("var_1", result.ResultObject.Id);
+
+ // Test value just outside boundary (should not be included)
+ TestBucketer.SetBucketValues(new[] { 5000 });
+ result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNull(result.ResultObject.Id);
+ }
+
+ [Test]
+ public void TestBucketHoldout_ConsistentBucketingWithSameInputs()
+ {
+ // Test that same inputs produce consistent results
+ // Use holdout from config instead of creating at runtime
+ var holdout = Config.GetHoldout("holdout_global_1");
+ Assert.IsNotNull(holdout, "Holdout should exist in config");
+
+ // Create a real bucketer (not test bucketer) for consistent hashing
+ var realBucketer = new Bucketing.Bucketer(LoggerMock.Object);
+ var result1 = realBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+ var result2 = realBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ // Results should be identical
+ Assert.IsNotNull(result1);
+ Assert.IsNotNull(result2);
+
+ if (result1.ResultObject?.Id != null)
+ {
+ Assert.AreEqual(result1.ResultObject.Id, result2.ResultObject.Id);
+ Assert.AreEqual(result1.ResultObject.Key, result2.ResultObject.Key);
+ }
+ else
+ {
+ Assert.IsNull(result2.ResultObject?.Id);
+ }
+ }
+
+ [Test]
+ public void TestBucketHoldout_DifferentBucketingIdsProduceDifferentResults()
+ {
+ // Test that different bucketing IDs can produce different results
+ // Use holdout from config instead of creating at runtime
+ var holdout = Config.GetHoldout("holdout_global_1");
+ Assert.IsNotNull(holdout, "Holdout should exist in config");
+
+ // Create a real bucketer (not test bucketer) for real hashing behavior
+ var realBucketer = new Bucketing.Bucketer(LoggerMock.Object);
+ var result1 = realBucketer.Bucket(Config, holdout, "bucketingId1", TestUserId);
+ var result2 = realBucketer.Bucket(Config, holdout, "bucketingId2", TestUserId);
+
+ // Results may be different (though not guaranteed due to hashing)
+ // This test mainly ensures no exceptions are thrown with different inputs
+ Assert.IsNotNull(result1);
+ Assert.IsNotNull(result2);
+ Assert.IsNotNull(result1.ResultObject);
+ Assert.IsNotNull(result2.ResultObject);
+ }
+
+ [Test]
+ public void TestBucketHoldout_VerifyDecisionReasons()
+ {
+ // Test that decision reasons are properly populated
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ TestBucketer.SetBucketValues(new[] { 5000 });
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNotNull(result.DecisionReasons);
+ // Decision reasons should be populated from the bucketing process
+ // The exact content depends on whether the user was bucketed or not
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ LoggerMock = null;
+ Bucketer = null;
+ TestBucketer = null;
+ Config = null;
+ TestData = null;
+ }
+ }
+}
diff --git a/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs b/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs
new file mode 100644
index 00000000..a9457cdb
--- /dev/null
+++ b/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs
@@ -0,0 +1,246 @@
+/*
+ * Copyright 2025, Optimizely
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Moq;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using OptimizelySDK.Bucketing;
+using OptimizelySDK.Config;
+using OptimizelySDK.Entity;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Logger;
+using OptimizelySDK.OptimizelyDecisions;
+
+namespace OptimizelySDK.Tests
+{
+ [TestFixture]
+ public class DecisionServiceHoldoutTest
+ {
+ private Mock LoggerMock;
+ private DecisionService DecisionService;
+ private DatafileProjectConfig Config;
+ private JObject TestData;
+ private Optimizely OptimizelyInstance;
+
+ private const string TestUserId = "testUserId";
+ private const string TestBucketingId = "testBucketingId";
+
+ [SetUp]
+ public void Initialize()
+ {
+ LoggerMock = new Mock();
+
+ // Load test data
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ TestData = JObject.Parse(jsonContent);
+
+ // Use datafile with holdouts for proper config setup
+ var datafileWithHoldouts = TestData["datafileWithHoldouts"].ToString();
+ Config = DatafileProjectConfig.Create(datafileWithHoldouts, LoggerMock.Object,
+ new ErrorHandler.NoOpErrorHandler()) as DatafileProjectConfig;
+
+ // Use real Bucketer instead of mock
+ var realBucketer = new Bucketer(LoggerMock.Object);
+ DecisionService = new DecisionService(realBucketer,
+ new ErrorHandler.NoOpErrorHandler(), null, LoggerMock.Object);
+
+ // Create an Optimizely instance for creating user contexts
+ var eventDispatcher = new Event.Dispatcher.DefaultEventDispatcher(LoggerMock.Object);
+ OptimizelyInstance = new Optimizely(datafileWithHoldouts, eventDispatcher, LoggerMock.Object);
+
+ // Verify that the config contains holdouts
+ Assert.IsNotNull(Config.Holdouts, "Config should have holdouts");
+ Assert.IsTrue(Config.Holdouts.Length > 0, "Config should contain holdouts");
+ }
+
+ [Test]
+ public void TestGetVariationsForFeatureList_HoldoutActiveVariationBucketed()
+ {
+ // Test GetVariationsForFeatureList with holdout that has an active variation
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"]; // Use actual feature flag from test data
+ var holdout = Config.GetHoldout("holdout_included_1"); // This holdout includes flag_1
+ Assert.IsNotNull(holdout, "Holdout should exist in config");
+
+ // Create user context
+ var userContext = new OptimizelyUserContext(OptimizelyInstance, TestUserId, null,
+ new ErrorHandler.NoOpErrorHandler(), LoggerMock.Object);
+
+ var result = DecisionService.GetVariationsForFeatureList(
+ new List { featureFlag }, userContext, Config,
+ new UserAttributes(), new OptimizelyDecideOption[0]);
+
+ Assert.IsNotNull(result);
+ Assert.IsTrue(result.Count > 0, "Should have at least one decision");
+
+ // Find the holdout decision
+ var holdoutDecision = result.FirstOrDefault(r => r.ResultObject?.Source == FeatureDecision.DECISION_SOURCE_HOLDOUT);
+ Assert.IsNotNull(holdoutDecision, "Should have a holdout decision");
+
+ // Verify that we got a valid variation (real bucketer should determine this based on traffic allocation)
+ Assert.IsNotNull(holdoutDecision.ResultObject?.Variation, "Should have a variation");
+ }
+
+ [Test]
+ public void TestGetVariationsForFeatureList_HoldoutInactiveNoBucketing()
+ {
+ // Test that inactive holdouts don't bucket users
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"]; // Use actual feature flag from test data
+
+ // Get one of the holdouts that's actually processed for test_flag_1 (based on debug output)
+ var holdout = Config.GetHoldout("holdout_global_1"); // global_holdout is one of the holdouts being processed
+ Assert.IsNotNull(holdout, "Holdout should exist in config");
+
+ // Mock holdout as inactive
+ holdout.Status = "Paused";
+
+ var userContext = new OptimizelyUserContext(OptimizelyInstance, TestUserId, null,
+ new ErrorHandler.NoOpErrorHandler(), LoggerMock.Object);
+
+ var result = DecisionService.GetVariationsForFeatureList(
+ new List { featureFlag }, userContext, Config,
+ new UserAttributes(), new OptimizelyDecideOption[0]);
+
+ // Verify appropriate log message for inactive holdout
+ LoggerMock.Verify(l => l.Log(LogLevel.INFO,
+ It.Is(s => s.Contains("Holdout") && s.Contains("is not running"))),
+ Times.AtLeastOnce);
+ }
+
+ [Test]
+ public void TestGetVariationsForFeatureList_HoldoutUserNotBucketed()
+ {
+ // Test when user is not bucketed into holdout (outside traffic allocation)
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"]; // Use actual feature flag from test data
+ var holdout = Config.GetHoldout("holdout_included_1"); // This holdout includes flag_1
+ Assert.IsNotNull(holdout, "Holdout should exist in config");
+
+ var userContext = new OptimizelyUserContext(OptimizelyInstance, TestUserId, null,
+ new ErrorHandler.NoOpErrorHandler(), LoggerMock.Object);
+
+ var result = DecisionService.GetVariationsForFeatureList(
+ new List { featureFlag }, userContext, Config,
+ new UserAttributes(), new OptimizelyDecideOption[0]);
+
+ // With real bucketer, we can't guarantee specific bucketing results
+ // but we can verify the method executes successfully
+ Assert.IsNotNull(result, "Result should not be null");
+ }
+
+ [Test]
+ public void TestGetVariationsForFeatureList_HoldoutWithUserAttributes()
+ {
+ // Test holdout evaluation with user attributes for audience targeting
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"]; // Use actual feature flag from test data
+ var holdout = Config.GetHoldout("holdout_included_1"); // This holdout includes flag_1
+ Assert.IsNotNull(holdout, "Holdout should exist in config");
+
+ var userAttributes = new UserAttributes
+ {
+ { "browser", "chrome" },
+ { "location", "us" }
+ };
+
+ var userContext = new OptimizelyUserContext(OptimizelyInstance, TestUserId, userAttributes,
+ new ErrorHandler.NoOpErrorHandler(), LoggerMock.Object);
+
+ var result = DecisionService.GetVariationsForFeatureList(
+ new List { featureFlag }, userContext, Config,
+ userAttributes, new OptimizelyDecideOption[0]);
+
+ Assert.IsNotNull(result, "Result should not be null");
+
+ // With real bucketer, we can't guarantee specific variations but can verify execution
+ // Additional assertions would depend on the holdout configuration and user bucketing
+ }
+
+ [Test]
+ public void TestGetVariationsForFeatureList_MultipleHoldouts()
+ {
+ // Test multiple holdouts for a single feature flag
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"]; // Use actual feature flag from test data
+
+ var userContext = new OptimizelyUserContext(OptimizelyInstance, TestUserId, null,
+ new ErrorHandler.NoOpErrorHandler(), LoggerMock.Object);
+
+ var result = DecisionService.GetVariationsForFeatureList(
+ new List { featureFlag }, userContext, Config,
+ new UserAttributes(), new OptimizelyDecideOption[0]);
+
+ Assert.IsNotNull(result, "Result should not be null");
+
+ // With real bucketer, we can't guarantee specific bucketing results
+ // but we can verify the method executes successfully
+ }
+
+ [Test]
+ public void TestGetVariationsForFeatureList_Holdout_EmptyUserId()
+ {
+ // Test GetVariationsForFeatureList with empty user ID
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"]; // Use actual feature flag from test data
+
+ var userContext = new OptimizelyUserContext(OptimizelyInstance, "", null,
+ new ErrorHandler.NoOpErrorHandler(), LoggerMock.Object);
+
+ var result = DecisionService.GetVariationsForFeatureList(
+ new List { featureFlag }, userContext, Config,
+ new UserAttributes(), new OptimizelyDecideOption[0]);
+
+ Assert.IsNotNull(result);
+
+ // Empty user ID should still allow holdout bucketing (matches Swift SDK behavior)
+ // The Swift SDK's testBucketToVariation_EmptyBucketingId shows empty string is valid
+ var holdoutDecisions = result.Where(r => r.ResultObject?.Source == FeatureDecision.DECISION_SOURCE_HOLDOUT).ToList();
+
+ // Should not log error about invalid user ID since empty string is valid for bucketing
+ LoggerMock.Verify(l => l.Log(LogLevel.ERROR,
+ It.Is(s => s.Contains("User ID") && (s.Contains("null") || s.Contains("empty")))),
+ Times.Never);
+ }
+
+ [Test]
+ public void TestGetVariationsForFeatureList_Holdout_DecisionReasons()
+ {
+ // Test that decision reasons are properly populated for holdouts
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"]; // Use actual feature flag from test data
+ var holdout = Config.GetHoldout("holdout_included_1"); // This holdout includes flag_1
+ Assert.IsNotNull(holdout, "Holdout should exist in config");
+
+ var userContext = new OptimizelyUserContext(OptimizelyInstance, TestUserId, null,
+ new ErrorHandler.NoOpErrorHandler(), LoggerMock.Object);
+
+ var result = DecisionService.GetVariationsForFeatureList(
+ new List { featureFlag }, userContext, Config,
+ new UserAttributes(), new OptimizelyDecideOption[0]);
+
+ Assert.IsNotNull(result, "Result should not be null");
+
+ // With real bucketer, we expect proper decision reasons to be generated
+ // Find any decision with reasons
+ var decisionWithReasons = result.FirstOrDefault(r => r.DecisionReasons != null && r.DecisionReasons.ToReport().Count > 0);
+
+ if (decisionWithReasons != null)
+ {
+ Assert.IsTrue(decisionWithReasons.DecisionReasons.ToReport().Count > 0, "Should have decision reasons");
+ }
+ }
+ }
+}
diff --git a/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
index c17cc088..a0f16fd0 100644
--- a/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
+++ b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
@@ -115,9 +115,6 @@ public void TestHoldoutEquality()
Assert.IsNotNull(holdout1);
Assert.IsNotNull(holdout2);
- // Note: This test depends on how Holdout implements equality
- // If Holdout doesn't override Equals, this will test reference equality
- // You may need to implement custom equality logic for Holdout
}
[Test]
@@ -128,10 +125,6 @@ public void TestHoldoutStatusParsing()
Assert.IsNotNull(globalHoldout);
Assert.AreEqual("Running", globalHoldout.Status);
-
- // Test that the holdout is considered activated when status is "Running"
- // This assumes there's an IsActivated property or similar logic
- // Adjust based on actual Holdout implementation
}
[Test]
@@ -184,9 +177,6 @@ public void TestHoldoutNullSafety()
Assert.IsNotNull(holdout);
Assert.AreEqual("test_holdout", holdout.Id);
Assert.AreEqual("test_key", holdout.Key);
-
- // Verify that missing includedFlags and excludedFlags are handled properly
- // This depends on how the Holdout entity handles missing properties
Assert.IsNotNull(holdout.IncludedFlags);
Assert.IsNotNull(holdout.ExcludedFlags);
}
diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
index 1db35b8f..026dd5b8 100644
--- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
+++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
@@ -106,6 +106,9 @@
+
+
+
diff --git a/OptimizelySDK.Tests/OptimizelyUserContextHoldoutTest.cs b/OptimizelySDK.Tests/OptimizelyUserContextHoldoutTest.cs
new file mode 100644
index 00000000..369dabb9
--- /dev/null
+++ b/OptimizelySDK.Tests/OptimizelyUserContextHoldoutTest.cs
@@ -0,0 +1,577 @@
+/*
+ * Copyright 2025, Optimizely
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Moq;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using OptimizelySDK.Bucketing;
+using OptimizelySDK.Config;
+using OptimizelySDK.Entity;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Event;
+using OptimizelySDK.Event.Dispatcher;
+using OptimizelySDK.Logger;
+using OptimizelySDK.OptimizelyDecisions;
+
+namespace OptimizelySDK.Tests
+{
+ [TestFixture]
+ public class OptimizelyUserContextHoldoutTest
+ {
+ private Mock LoggerMock;
+ private Mock EventDispatcherMock;
+ private DatafileProjectConfig Config;
+ private JObject TestData;
+ private Optimizely OptimizelyInstance;
+
+ private const string TestUserId = "testUserId";
+ private const string TestBucketingId = "testBucketingId";
+
+ [SetUp]
+ public void Initialize()
+ {
+ LoggerMock = new Mock();
+ EventDispatcherMock = new Mock();
+
+ // Load test data
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ TestData = JObject.Parse(jsonContent);
+
+ // Use datafile with holdouts for proper config setup
+ var datafileWithHoldouts = TestData["datafileWithHoldouts"].ToString();
+
+ // Create an Optimizely instance with the test data
+ OptimizelyInstance = new Optimizely(datafileWithHoldouts, EventDispatcherMock.Object, LoggerMock.Object);
+
+ // Get the config from the Optimizely instance to ensure they're synchronized
+ Config = OptimizelyInstance.ProjectConfigManager.GetConfig() as DatafileProjectConfig;
+
+ // Verify that the config contains holdouts
+ Assert.IsNotNull(Config.Holdouts, "Config should have holdouts");
+ Assert.IsTrue(Config.Holdouts.Length > 0, "Config should contain holdouts");
+ }
+
+ #region Core Holdout Functionality Tests
+
+ [Test]
+ public void TestDecide_GlobalHoldout()
+ {
+ // Test Decide() method with global holdout decision
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"];
+ Assert.IsNotNull(featureFlag, "Feature flag should exist");
+
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+
+ var decision = userContext.Decide("test_flag_1");
+
+ Assert.IsNotNull(decision, "Decision should not be null");
+ Assert.AreEqual("test_flag_1", decision.FlagKey, "Flag key should match");
+
+ // With real bucketer, we can't guarantee specific variation but can verify structure
+ // The decision should either be from holdout, experiment, or rollout
+ Assert.IsTrue(!string.IsNullOrEmpty(decision.VariationKey) || decision.VariationKey == null,
+ "Variation key should be valid or null");
+ }
+
+ [Test]
+ public void TestDecide_IncludedFlagsHoldout()
+ {
+ // Test holdout with includedFlags configuration
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"];
+ Assert.IsNotNull(featureFlag, "Feature flag should exist");
+
+ // Check if there's a holdout that includes this flag
+ var includedHoldout = Config.Holdouts.FirstOrDefault(h =>
+ h.IncludedFlags != null && h.IncludedFlags.Contains(featureFlag.Id));
+
+ if (includedHoldout != null)
+ {
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+
+ var decision = userContext.Decide("test_flag_1");
+
+ Assert.IsNotNull(decision, "Decision should not be null");
+ Assert.AreEqual("test_flag_1", decision.FlagKey, "Flag key should match");
+
+ // Verify decision is valid
+ Assert.IsTrue(decision.VariationKey != null || decision.VariationKey == null,
+ "Decision should have valid structure");
+ }
+ else
+ {
+ Assert.Inconclusive("No included holdout found for test_flag_1");
+ }
+ }
+
+ [Test]
+ public void TestDecide_ExcludedFlagsHoldout()
+ {
+ // Test holdout with excludedFlags configuration
+ // Based on test data, flag_3 and flag_4 are excluded by holdout_excluded_1
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+
+ // Test with an excluded flag (test_flag_3 maps to flag_3)
+ var excludedDecision = userContext.Decide("test_flag_3");
+
+ Assert.IsNotNull(excludedDecision, "Decision should not be null for excluded flag");
+ Assert.AreEqual("test_flag_3", excludedDecision.FlagKey, "Flag key should match");
+
+ // For excluded flags, the decision should not come from the excluded holdout
+ // The excluded holdout has key "excluded_holdout"
+ Assert.AreNotEqual("excluded_holdout", excludedDecision.RuleKey,
+ "Decision should not come from excluded holdout for flag_3");
+
+ // Also test with a non-excluded flag (test_flag_1 maps to flag_1)
+ var nonExcludedDecision = userContext.Decide("test_flag_1");
+
+ Assert.IsNotNull(nonExcludedDecision, "Decision should not be null for non-excluded flag");
+ Assert.AreEqual("test_flag_1", nonExcludedDecision.FlagKey, "Flag key should match");
+
+ // For non-excluded flags, they can potentially be affected by holdouts
+ // (depending on other holdout configurations like global or included holdouts)
+ }
+
+ [Test]
+ public void TestDecideAll_MultipleHoldouts()
+ {
+ // Test DecideAll() with multiple holdouts affecting different flags
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+
+ var decisions = userContext.DecideAll();
+
+ Assert.IsNotNull(decisions, "Decisions should not be null");
+ Assert.IsTrue(decisions.Count > 0, "Should have at least one decision");
+
+ // Verify each decision has proper structure
+ foreach (var kvp in decisions)
+ {
+ var flagKey = kvp.Key;
+ var decision = kvp.Value;
+
+ Assert.AreEqual(flagKey, decision.FlagKey, $"Flag key should match for {flagKey}");
+ Assert.IsNotNull(decision, $"Decision should not be null for {flagKey}");
+
+ // Decision should have either a variation or be properly null
+ Assert.IsTrue(decision.VariationKey != null || decision.VariationKey == null,
+ $"Decision structure should be valid for {flagKey}");
+ }
+ }
+
+ [Test]
+ public void TestDecide_HoldoutImpressionEvent()
+ {
+ // Test that impression events are sent for holdout decisions
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+
+ var decision = userContext.Decide("test_flag_1");
+
+ Assert.IsNotNull(decision, "Decision should not be null");
+
+ // Verify that event dispatcher was called
+ // Note: With real bucketer, we can't guarantee holdout selection,
+ // but we can verify event structure
+ EventDispatcherMock.Verify(
+ e => e.DispatchEvent(It.IsAny()),
+ Times.AtLeastOnce,
+ "Event should be dispatched for decision"
+ );
+ }
+
+ [Test]
+ public void TestDecide_HoldoutWithDecideOptions()
+ {
+ // Test decide options (like ExcludeVariables) with holdout decisions
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+
+ // Test with exclude variables option
+ var decisionWithVariables = userContext.Decide("test_flag_1");
+ var decisionWithoutVariables = userContext.Decide("test_flag_1",
+ new OptimizelyDecideOption[] { OptimizelyDecideOption.EXCLUDE_VARIABLES });
+
+ Assert.IsNotNull(decisionWithVariables, "Decision with variables should not be null");
+ Assert.IsNotNull(decisionWithoutVariables, "Decision without variables should not be null");
+
+ // When variables are excluded, the Variables object should be empty
+ Assert.IsTrue(decisionWithoutVariables.Variables.ToDictionary().Count == 0,
+ "Variables should be empty when excluded");
+ }
+
+ [Test]
+ public void TestDecide_HoldoutWithAudienceTargeting()
+ {
+ // Test holdout decisions with different user attributes for audience targeting
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"];
+ Assert.IsNotNull(featureFlag, "Feature flag should exist");
+
+ // Test with matching attributes
+ var userContextMatch = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+ var decisionMatch = userContextMatch.Decide("test_flag_1");
+
+ // Test with non-matching attributes
+ var userContextNoMatch = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "ca" } });
+ var decisionNoMatch = userContextNoMatch.Decide("test_flag_1");
+
+ Assert.IsNotNull(decisionMatch, "Decision with matching attributes should not be null");
+ Assert.IsNotNull(decisionNoMatch, "Decision with non-matching attributes should not be null");
+
+ // Both decisions should have proper structure regardless of targeting
+ Assert.AreEqual("test_flag_1", decisionMatch.FlagKey, "Flag key should match");
+ Assert.AreEqual("test_flag_1", decisionNoMatch.FlagKey, "Flag key should match");
+ }
+
+ [Test]
+ public void TestDecide_InactiveHoldout()
+ {
+ // Test decide when holdout is not running
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"];
+ Assert.IsNotNull(featureFlag, "Feature flag should exist");
+
+ // Find a holdout and set it to inactive
+ var holdout = Config.Holdouts.FirstOrDefault();
+ if (holdout != null)
+ {
+ var originalStatus = holdout.Status;
+ holdout.Status = "Paused"; // Make holdout inactive
+
+ try
+ {
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+
+ var decision = userContext.Decide("test_flag_1");
+
+ Assert.IsNotNull(decision, "Decision should not be null even with inactive holdout");
+ Assert.AreEqual("test_flag_1", decision.FlagKey, "Flag key should match");
+
+ // Should not get decision from the inactive holdout
+ if (!string.IsNullOrEmpty(decision.RuleKey))
+ {
+ Assert.AreNotEqual(holdout.Key, decision.RuleKey,
+ "Decision should not come from inactive holdout");
+ }
+ }
+ finally
+ {
+ holdout.Status = originalStatus; // Restore original status
+ }
+ }
+ else
+ {
+ Assert.Inconclusive("No holdout found to test inactive scenario");
+ }
+ }
+
+ [Test]
+ public void TestDecide_EmptyUserId()
+ {
+ // Test decide with empty user ID (should still work per Swift SDK behavior)
+ var userContext = OptimizelyInstance.CreateUserContext("",
+ new UserAttributes { { "country", "us" } });
+
+ var decision = userContext.Decide("test_flag_1");
+
+ Assert.IsNotNull(decision, "Decision should not be null with empty user ID");
+ Assert.AreEqual("test_flag_1", decision.FlagKey, "Flag key should match");
+
+ // Should not log error about invalid user ID since empty string is valid for bucketing
+ LoggerMock.Verify(l => l.Log(LogLevel.ERROR,
+ It.Is(s => s.Contains("User ID") && (s.Contains("null") || s.Contains("empty")))),
+ Times.Never);
+ }
+
+ [Test]
+ public void TestDecide_WithDecisionReasons()
+ {
+ // Test that decision reasons are properly populated for holdout decisions
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+
+ var decision = userContext.Decide("test_flag_1",
+ new OptimizelyDecideOption[] { OptimizelyDecideOption.INCLUDE_REASONS });
+
+ Assert.IsNotNull(decision, "Decision should not be null");
+ Assert.AreEqual("test_flag_1", decision.FlagKey, "Flag key should match");
+
+ // Decision reasons should be populated when requested
+ Assert.IsNotNull(decision.Reasons, "Decision reasons should not be null");
+ // With real bucketer, we expect some decision reasons to be generated
+ Assert.IsTrue(decision.Reasons.Length >= 0, "Decision reasons should be present");
+ }
+
+ [Test]
+ public void TestDecide_HoldoutPriority()
+ {
+ // Test holdout evaluation priority (global vs included vs excluded)
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"];
+ Assert.IsNotNull(featureFlag, "Feature flag should exist");
+
+ // Check if we have multiple holdouts
+ var globalHoldouts = Config.Holdouts.Where(h =>
+ h.IncludedFlags == null || h.IncludedFlags.Length == 0).ToList();
+ var includedHoldouts = Config.Holdouts.Where(h =>
+ h.IncludedFlags != null && h.IncludedFlags.Contains(featureFlag.Id)).ToList();
+
+ if (globalHoldouts.Count > 0 || includedHoldouts.Count > 0)
+ {
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+
+ var decision = userContext.Decide("test_flag_1");
+
+ Assert.IsNotNull(decision, "Decision should not be null");
+ Assert.AreEqual("test_flag_1", decision.FlagKey, "Flag key should match");
+
+ // Decision should be valid regardless of which holdout is selected
+ Assert.IsTrue(decision.VariationKey != null || decision.VariationKey == null,
+ "Decision should have valid structure");
+ }
+ else
+ {
+ Assert.Inconclusive("No holdouts found to test priority");
+ }
+ }
+
+ #endregion
+
+ #region Holdout Decision Reasons Tests
+
+ [Test]
+ public void TestDecideReasons_WithIncludeReasonsOption()
+ {
+ var featureKey = "test_flag_1";
+
+ // Create user context
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId);
+
+ // Call decide with reasons option
+ var decision = userContext.Decide(featureKey, new OptimizelyDecideOption[] { OptimizelyDecideOption.INCLUDE_REASONS });
+
+ // Assertions
+ Assert.AreEqual(featureKey, decision.FlagKey, "Expected flagKey to match");
+ Assert.IsNotNull(decision.Reasons, "Decision reasons should not be null");
+ Assert.IsTrue(decision.Reasons.Length >= 0, "Decision reasons should be present");
+ }
+
+ [Test]
+ public void TestDecideReasons_WithoutIncludeReasonsOption()
+ {
+ var featureKey = "test_flag_1";
+
+ // Create user context
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId);
+
+ // Call decide WITHOUT reasons option
+ var decision = userContext.Decide(featureKey);
+
+ // Assertions
+ Assert.AreEqual(featureKey, decision.FlagKey, "Expected flagKey to match");
+ Assert.IsNotNull(decision.Reasons, "Decision reasons should not be null");
+ Assert.AreEqual(0, decision.Reasons.Length, "Should not include reasons when not requested");
+ }
+
+ [Test]
+ public void TestDecideReasons_UserBucketedIntoHoldoutVariation()
+ {
+ var featureKey = "test_flag_1";
+
+ // Create user context that should be bucketed into holdout
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+
+ // Call decide with reasons
+ var decision = userContext.Decide(featureKey, new OptimizelyDecideOption[] { OptimizelyDecideOption.INCLUDE_REASONS });
+
+ // Assertions
+ Assert.AreEqual(featureKey, decision.FlagKey, "Expected flagKey to match");
+ Assert.IsNotNull(decision.Reasons, "Decision reasons should not be null");
+ Assert.IsTrue(decision.Reasons.Length > 0, "Should have decision reasons");
+
+ // Check for specific holdout bucketing messages (matching C# DecisionService patterns)
+ var reasonsText = string.Join(" ", decision.Reasons);
+ var hasHoldoutBucketingMessage = decision.Reasons.Any(r =>
+ r.Contains("is bucketed into holdout variation") ||
+ r.Contains("is not bucketed into holdout variation"));
+
+ Assert.IsTrue(hasHoldoutBucketingMessage,
+ "Should contain holdout bucketing decision message");
+ }
+
+ [Test]
+ public void TestDecideReasons_HoldoutNotRunning()
+ {
+ // This test would require a holdout with inactive status
+ // For now, test that the structure is correct and reasons are generated
+ var featureKey = "test_flag_1";
+
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId);
+ var decision = userContext.Decide(featureKey, new OptimizelyDecideOption[] { OptimizelyDecideOption.INCLUDE_REASONS });
+
+ // Verify reasons are generated (specific holdout status would depend on test data configuration)
+ Assert.IsNotNull(decision.Reasons, "Decision reasons should not be null");
+ Assert.IsTrue(decision.Reasons.Length > 0, "Should have decision reasons");
+
+ // Check if any holdout status messages are present
+ var hasHoldoutStatusMessage = decision.Reasons.Any(r =>
+ r.Contains("is not running") ||
+ r.Contains("is running") ||
+ r.Contains("holdout"));
+
+ // Note: This assertion may pass or fail depending on holdout configuration in test data
+ // The important thing is that reasons are being generated
+ }
+
+ [Test]
+ public void TestDecideReasons_UserMeetsAudienceConditions()
+ {
+ var featureKey = "test_flag_1";
+
+ // Create user context with attributes that should match audience conditions
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+
+ // Call decide with reasons
+ var decision = userContext.Decide(featureKey, new OptimizelyDecideOption[] { OptimizelyDecideOption.INCLUDE_REASONS });
+
+ // Assertions
+ Assert.AreEqual(featureKey, decision.FlagKey, "Expected flagKey to match");
+ Assert.IsNotNull(decision.Reasons, "Decision reasons should not be null");
+ Assert.IsTrue(decision.Reasons.Length > 0, "Should have decision reasons");
+
+ // Check for audience evaluation messages (matching C# ExperimentUtils patterns)
+ var hasAudienceEvaluation = decision.Reasons.Any(r =>
+ r.Contains("Audiences for experiment") && r.Contains("collectively evaluated to"));
+
+ Assert.IsTrue(hasAudienceEvaluation,
+ "Should contain audience evaluation result message");
+ }
+
+ [Test]
+ public void TestDecideReasons_UserDoesNotMeetHoldoutConditions()
+ {
+ var featureKey = "test_flag_1";
+
+ // Since the test holdouts have empty audience conditions (they match everyone),
+ // let's test with a holdout that's not running to simulate condition failure
+ // First, let's verify what's actually happening
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "unknown_country" } });
+
+ // Call decide with reasons
+ var decision = userContext.Decide(featureKey, new OptimizelyDecideOption[] { OptimizelyDecideOption.INCLUDE_REASONS });
+
+ // Assertions
+ Assert.AreEqual(featureKey, decision.FlagKey, "Expected flagKey to match");
+ Assert.IsNotNull(decision.Reasons, "Decision reasons should not be null");
+ Assert.IsTrue(decision.Reasons.Length > 0, "Should have decision reasons");
+
+ // Since the current test data holdouts have no audience restrictions,
+ // they evaluate to TRUE for any user. This is actually correct behavior.
+ // The test should verify that when audience conditions ARE met, we get appropriate messages.
+ var hasAudienceEvaluation = decision.Reasons.Any(r =>
+ r.Contains("collectively evaluated to TRUE") ||
+ r.Contains("collectively evaluated to FALSE") ||
+ r.Contains("does not meet conditions"));
+
+ Assert.IsTrue(hasAudienceEvaluation,
+ "Should contain audience evaluation message (TRUE or FALSE)");
+
+ // For this specific case with empty audience conditions, expect TRUE evaluation
+ var hasTrueEvaluation = decision.Reasons.Any(r =>
+ r.Contains("collectively evaluated to TRUE"));
+
+ Assert.IsTrue(hasTrueEvaluation,
+ "With empty audience conditions, should evaluate to TRUE");
+ }
+
+ [Test]
+ public void TestDecideReasons_HoldoutEvaluationReasoning()
+ {
+ var featureKey = "test_flag_1";
+
+ // Since the current test data doesn't include non-running holdouts,
+ // this test documents the expected behavior when a holdout is not running
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId);
+
+ var decision = userContext.Decide(featureKey, new OptimizelyDecideOption[] { OptimizelyDecideOption.INCLUDE_REASONS });
+
+ // Assertions
+ Assert.AreEqual(featureKey, decision.FlagKey, "Expected flagKey to match");
+ Assert.IsNotNull(decision.Reasons, "Decision reasons should not be null");
+ Assert.IsTrue(decision.Reasons.Length > 0, "Should have decision reasons");
+
+ // Note: If we had a non-running holdout in the test data, we would expect:
+ // decision.Reasons.Any(r => r.Contains("is not running"))
+
+ // For now, verify we get some form of holdout evaluation reasoning
+ var hasHoldoutReasoning = decision.Reasons.Any(r =>
+ r.Contains("holdout") ||
+ r.Contains("bucketed into"));
+
+ Assert.IsTrue(hasHoldoutReasoning,
+ "Should contain holdout-related reasoning");
+ }
+
+ [Test]
+ public void TestDecideReasons_HoldoutDecisionContainsRelevantReasons()
+ {
+ var featureKey = "test_flag_1";
+
+ // Create user context that might be bucketed into holdout
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+
+ // Call decide with reasons
+ var decision = userContext.Decide(featureKey, new OptimizelyDecideOption[] { OptimizelyDecideOption.INCLUDE_REASONS });
+
+ // Assertions
+ Assert.AreEqual(featureKey, decision.FlagKey, "Expected flagKey to match");
+ Assert.IsNotNull(decision.Reasons, "Decision reasons should not be null");
+ Assert.IsTrue(decision.Reasons.Length > 0, "Should have decision reasons");
+
+ // Check if reasons contain holdout-related information
+ var reasonsText = string.Join(" ", decision.Reasons);
+
+ // Verify that reasons provide information about the decision process
+ Assert.IsTrue(!string.IsNullOrWhiteSpace(reasonsText), "Reasons should contain meaningful information");
+
+ // Check for any holdout-related keywords in reasons
+ var hasHoldoutRelatedReasons = decision.Reasons.Any(r =>
+ r.Contains("holdout") ||
+ r.Contains("bucketed") ||
+ r.Contains("audiences") ||
+ r.Contains("conditions"));
+
+ Assert.IsTrue(hasHoldoutRelatedReasons,
+ "Should contain holdout-related decision reasoning");
+ }
+
+ #endregion
+ }
+}
diff --git a/OptimizelySDK.Tests/ProjectConfigTest.cs b/OptimizelySDK.Tests/ProjectConfigTest.cs
index b7afcba6..15e95926 100644
--- a/OptimizelySDK.Tests/ProjectConfigTest.cs
+++ b/OptimizelySDK.Tests/ProjectConfigTest.cs
@@ -1368,7 +1368,7 @@ public void TestHoldoutDeserialization_FromDatafile()
new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig;
Assert.IsNotNull(datafileProjectConfig.Holdouts);
- Assert.AreEqual(3, datafileProjectConfig.Holdouts.Length);
+ Assert.AreEqual(4, datafileProjectConfig.Holdouts.Length);
}
[Test]
@@ -1387,15 +1387,15 @@ public void TestGetHoldoutsForFlag_Integration()
// Test GetHoldoutsForFlag method
var holdoutsForFlag1 = datafileProjectConfig.GetHoldoutsForFlag("flag_1");
Assert.IsNotNull(holdoutsForFlag1);
- Assert.AreEqual(3, holdoutsForFlag1.Length); // Global + excluded holdout (applies to all except flag_3/flag_4) + included holdout
+ Assert.AreEqual(4, holdoutsForFlag1.Length); // Global + excluded holdout (applies to all except flag_3/flag_4) + included holdout + empty holdout
var holdoutsForFlag3 = datafileProjectConfig.GetHoldoutsForFlag("flag_3");
Assert.IsNotNull(holdoutsForFlag3);
- Assert.AreEqual(1, holdoutsForFlag3.Length); // Only true global (excluded holdout excludes flag_3)
+ Assert.AreEqual(2, holdoutsForFlag3.Length); // Global + empty holdout (excluded holdout excludes flag_3, included holdout doesn't include flag_3)
var holdoutsForUnknownFlag = datafileProjectConfig.GetHoldoutsForFlag("unknown_flag");
Assert.IsNotNull(holdoutsForUnknownFlag);
- Assert.AreEqual(2, holdoutsForUnknownFlag.Length); // Global + excluded holdout (unknown_flag not in excluded list)
+ Assert.AreEqual(3, holdoutsForUnknownFlag.Length); // Global + excluded holdout (unknown_flag not in excluded list) + empty holdout
}
[Test]
diff --git a/OptimizelySDK.Tests/TestData/HoldoutTestData.json b/OptimizelySDK.Tests/TestData/HoldoutTestData.json
index b5c17b26..777c0a3a 100644
--- a/OptimizelySDK.Tests/TestData/HoldoutTestData.json
+++ b/OptimizelySDK.Tests/TestData/HoldoutTestData.json
@@ -126,11 +126,21 @@
"key": "control",
"featureEnabled": false,
"variables": []
+ },
+ {
+ "id": "var_2",
+ "key": "treatment",
+ "featureEnabled": true,
+ "variables": []
}
],
"trafficAllocation": [
{
"entityId": "var_1",
+ "endOfRange": 5000
+ },
+ {
+ "entityId": "var_2",
"endOfRange": 10000
}
],
@@ -186,6 +196,18 @@
"audienceConditions": [],
"includedFlags": [],
"excludedFlags": ["flag_3", "flag_4"]
+ },
+ {
+ "id": "holdout_empty_1",
+ "key": "empty_holdout",
+ "status": "Running",
+ "layerId": "layer_4",
+ "variations": [],
+ "trafficAllocation": [],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedFlags": [],
+ "excludedFlags": []
}
]
}
diff --git a/OptimizelySDK/Bucketing/Bucketer.cs b/OptimizelySDK/Bucketing/Bucketer.cs
index 33df35a3..f891fc76 100644
--- a/OptimizelySDK/Bucketing/Bucketer.cs
+++ b/OptimizelySDK/Bucketing/Bucketer.cs
@@ -112,7 +112,7 @@ IEnumerable trafficAllocations
/// A customer-assigned value used to create the key for the murmur hash.
/// User identifier
/// Variation which will be shown to the user
- public virtual Result Bucket(ProjectConfig config, Experiment experiment,
+ public virtual Result Bucket(ProjectConfig config, ExperimentCore experiment,
string bucketingId, string userId
)
{
@@ -127,9 +127,9 @@ public virtual Result Bucket(ProjectConfig config, Experiment experim
}
// Determine if experiment is in a mutually exclusive group.
- if (experiment.IsInMutexGroup)
+ if (experiment is Experiment exp && exp.IsInMutexGroup)
{
- var group = config.GetGroup(experiment.GroupId);
+ var group = config.GetGroup(exp.GroupId);
if (string.IsNullOrEmpty(group.Id))
{
return Result.NewResult(new Variation(), reasons);
@@ -147,13 +147,13 @@ public virtual Result Bucket(ProjectConfig config, Experiment experim
if (userExperimentId != experiment.Id)
{
message =
- $"User [{userId}] is not in experiment [{experiment.Key}] of group [{experiment.GroupId}].";
+ $"User [{userId}] is not in experiment [{exp.Key}] of group [{exp.GroupId}].";
Logger.Log(LogLevel.INFO, reasons.AddInfo(message));
return Result.NewResult(new Variation(), reasons);
}
message =
- $"User [{userId}] is in experiment [{experiment.Key}] of group [{experiment.GroupId}].";
+ $"User [{userId}] is in experiment [{exp.Key}] of group [{exp.GroupId}].";
Logger.Log(LogLevel.INFO, reasons.AddInfo(message));
}
diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs
index 1e364b29..ad5487a2 100644
--- a/OptimizelySDK/Bucketing/DecisionService.cs
+++ b/OptimizelySDK/Bucketing/DecisionService.cs
@@ -22,6 +22,7 @@
using OptimizelySDK.Logger;
using OptimizelySDK.OptimizelyDecisions;
using OptimizelySDK.Utils;
+using static OptimizelySDK.Entity.Holdout;
namespace OptimizelySDK.Bucketing
{
@@ -727,6 +728,84 @@ public virtual Result GetVariationForFeature(FeatureFlag featur
new OptimizelyDecideOption[] { });
}
+ ///
+ /// Get the decision for a single feature flag, following Swift SDK pattern.
+ /// This method processes holdouts, experiments, and rollouts in sequence.
+ ///
+ /// The feature flag to get a decision for.
+ /// The user context.
+ /// The project config.
+ /// The user's filtered attributes.
+ /// Decision options.
+ /// User profile tracker for sticky bucketing.
+ /// Decision reasons to merge.
+ /// A decision result for the feature flag.
+ public virtual Result GetDecisionForFlag(
+ FeatureFlag featureFlag,
+ OptimizelyUserContext user,
+ ProjectConfig projectConfig,
+ UserAttributes filteredAttributes,
+ OptimizelyDecideOption[] options,
+ UserProfileTracker userProfileTracker = null,
+ DecisionReasons decideReasons = null
+ )
+ {
+ var reasons = new DecisionReasons();
+ if (decideReasons != null)
+ {
+ reasons += decideReasons;
+ }
+
+ var userId = user.GetUserId();
+
+ // Check holdouts first (highest priority)
+ var holdouts = projectConfig.GetHoldoutsForFlag(featureFlag.Key);
+ foreach (var holdout in holdouts)
+ {
+ var holdoutDecision = GetVariationForHoldout(holdout, user, projectConfig);
+ reasons += holdoutDecision.DecisionReasons;
+
+ if (holdoutDecision.ResultObject != null)
+ {
+ Logger.Log(LogLevel.INFO,
+ reasons.AddInfo(
+ $"The user \"{userId}\" is bucketed into holdout \"{holdout.Key}\" for feature flag \"{featureFlag.Key}\"."));
+ return Result.NewResult(holdoutDecision.ResultObject, reasons);
+ }
+ }
+
+ // Check if the feature flag has an experiment and the user is bucketed into that experiment.
+ var experimentDecision = GetVariationForFeatureExperiment(featureFlag, user,
+ filteredAttributes, projectConfig, options, userProfileTracker);
+ reasons += experimentDecision.DecisionReasons;
+
+ if (experimentDecision.ResultObject != null)
+ {
+ return Result.NewResult(experimentDecision.ResultObject, reasons);
+ }
+
+ // Check if the feature flag has rollout and the user is bucketed into one of its rules.
+ var rolloutDecision = GetVariationForFeatureRollout(featureFlag, user, projectConfig);
+ reasons += rolloutDecision.DecisionReasons;
+
+ if (rolloutDecision.ResultObject != null)
+ {
+ Logger.Log(LogLevel.INFO,
+ reasons.AddInfo(
+ $"The user \"{userId}\" is bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
+ return Result.NewResult(rolloutDecision.ResultObject, reasons);
+ }
+ else
+ {
+ 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);
+ }
+ }
+
public virtual List> GetVariationsForFeatureList(
List featureFlags,
OptimizelyUserContext user,
@@ -747,47 +826,13 @@ OptimizelyDecideOption[] options
userProfileTracker.LoadUserProfile(upsReasons);
}
- var userId = user.GetUserId();
var decisions = new List>();
foreach (var featureFlag in featureFlags)
{
- var reasons = new DecisionReasons();
- reasons += upsReasons;
-
- // Check if the feature flag has an experiment and the user is bucketed into that experiment.
- var decisionResult = GetVariationForFeatureExperiment(featureFlag, user,
- filteredAttributes, projectConfig, options, 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));
- }
+ var decision = GetDecisionForFlag(featureFlag, user, projectConfig, filteredAttributes,
+ options, userProfileTracker, upsReasons);
+ decisions.Add(decision);
}
if (UserProfileService != null && !ignoreUps &&
@@ -856,6 +901,66 @@ private Result GetBucketingId(string userId, UserAttributes filteredAttr
return Result.NewResult(bucketingId, reasons);
}
+ private Result GetVariationForHoldout(
+ Holdout holdout,
+ OptimizelyUserContext user,
+ ProjectConfig config
+ )
+ {
+ var userId = user.GetUserId();
+ var reasons = new DecisionReasons();
+
+ if (!holdout.isRunning)
+ {
+ var infoMessage = $"Holdout \"{holdout.Key}\" is not running.";
+ Logger.Log(LogLevel.INFO, infoMessage);
+ reasons.AddInfo(infoMessage);
+ return Result.NewResult(
+ new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_HOLDOUT),
+ reasons
+ );
+ }
+
+ var audienceResult = ExperimentUtils.DoesUserMeetAudienceConditions(
+ config,
+ holdout,
+ user,
+ LOGGING_KEY_TYPE_EXPERIMENT,
+ holdout.Key,
+ Logger
+ );
+ reasons += audienceResult.DecisionReasons;
+
+ if (!audienceResult.ResultObject)
+ {
+ reasons.AddInfo($"User \"{userId}\" does not meet conditions for holdout ({holdout.Key}).");
+ return Result.NewResult(
+ new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_HOLDOUT),
+ reasons
+ );
+ }
+
+ var attributes = user.GetAttributes();
+ var bucketingIdResult = GetBucketingId(userId, attributes);
+ var bucketedVariation = Bucketer.Bucket(config, holdout, bucketingIdResult.ResultObject, userId);
+ reasons += bucketedVariation.DecisionReasons;
+
+ if (bucketedVariation.ResultObject != null)
+ {
+ reasons.AddInfo($"User \"{userId}\" is bucketed into holdout variation \"{bucketedVariation.ResultObject.Key}\".");
+ return Result.NewResult(
+ new FeatureDecision(holdout, bucketedVariation.ResultObject, FeatureDecision.DECISION_SOURCE_HOLDOUT),
+ reasons
+ );
+ }
+
+ reasons.AddInfo($"User \"{userId}\" is not bucketed into holdout variation \"{holdout.Key}\".");
+
+ return Result.NewResult(
+ new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_HOLDOUT),
+ reasons
+ );
+ }
///
/// Finds a validated forced decision.
///
diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs
index f940ffe6..6721832e 100644
--- a/OptimizelySDK/Config/DatafileProjectConfig.cs
+++ b/OptimizelySDK/Config/DatafileProjectConfig.cs
@@ -413,6 +413,29 @@ private void Initialize()
}
}
+ // Adding Holdout variations in variation id and key maps.
+ if (Holdouts != null)
+ {
+ foreach (var holdout in Holdouts)
+ {
+ _VariationKeyMap[holdout.Key] = new Dictionary();
+ _VariationIdMap[holdout.Key] = new Dictionary();
+ _VariationIdMapByExperimentId[holdout.Id] = new Dictionary();
+ _VariationKeyMapByExperimentId[holdout.Id] = new Dictionary();
+
+ if (holdout.Variations != null)
+ {
+ foreach (var variation in holdout.Variations)
+ {
+ _VariationKeyMap[holdout.Key][variation.Key] = variation;
+ _VariationIdMap[holdout.Key][variation.Id] = variation;
+ _VariationKeyMapByExperimentId[holdout.Id][variation.Key] = variation;
+ _VariationIdMapByExperimentId[holdout.Id][variation.Id] = variation;
+ }
+ }
+ }
+ }
+
var integration = Integrations.FirstOrDefault(i => i.Key.ToLower() == "odp");
HostForOdp = integration?.Host;
PublicKeyForOdp = integration?.PublicKey;
diff --git a/OptimizelySDK/Entity/Experiment.cs b/OptimizelySDK/Entity/Experiment.cs
index dd25f68c..8a7b4036 100644
--- a/OptimizelySDK/Entity/Experiment.cs
+++ b/OptimizelySDK/Entity/Experiment.cs
@@ -27,11 +27,6 @@ public class Experiment : ExperimentCore
///
public string GroupId { get; set; }
- ///
- /// Layer ID for the experiment
- ///
- public string LayerId { get; set; }
-
///
/// ForcedVariations for the experiment
///
@@ -53,12 +48,6 @@ public class Experiment : ExperimentCore
public bool IsInMutexGroup =>
!string.IsNullOrEmpty(GroupPolicy) && GroupPolicy == MUTEX_GROUP_POLICY;
- ///
- /// Determine if experiment is running or not
- ///
- public bool IsExperimentRunning =>
- !string.IsNullOrEmpty(Status) && Status == STATUS_RUNNING;
-
///
/// Determin if user is forced variation of experiment
///
@@ -68,10 +57,5 @@ public bool IsUserInForcedVariation(string userId)
{
return ForcedVariations != null && ForcedVariations.ContainsKey(userId);
}
-
- ///
- /// Determine if experiment is currently activated/running (implementation of abstract property)
- ///
- public override bool IsActivated => IsExperimentRunning;
}
}
diff --git a/OptimizelySDK/Entity/ExperimentCore.cs b/OptimizelySDK/Entity/ExperimentCore.cs
index 61dba9d8..0f81e2d0 100644
--- a/OptimizelySDK/Entity/ExperimentCore.cs
+++ b/OptimizelySDK/Entity/ExperimentCore.cs
@@ -35,6 +35,11 @@ public abstract class ExperimentCore : IdKeyEntity
///
public string Status { get; set; }
+ ///
+ /// Layer ID for the experiment
+ ///
+ public virtual string LayerId { get; set; }
+
///
/// Variations for the experiment/holdout
///
@@ -269,8 +274,8 @@ public virtual Variation GetVariationByKey(string key)
#endregion
///
- /// Determine if experiment/holdout is currently activated/running
+ /// Determine if experiment is currently activated/running
///
- public abstract bool IsActivated { get; }
+ public bool isRunning => !string.IsNullOrEmpty(Status) && Status == STATUS_RUNNING;
}
}
diff --git a/OptimizelySDK/Entity/FeatureDecision.cs b/OptimizelySDK/Entity/FeatureDecision.cs
index e768cc5a..6bdd8f4c 100644
--- a/OptimizelySDK/Entity/FeatureDecision.cs
+++ b/OptimizelySDK/Entity/FeatureDecision.cs
@@ -20,12 +20,12 @@ public class FeatureDecision
{
public const string DECISION_SOURCE_FEATURE_TEST = "feature-test";
public const string DECISION_SOURCE_ROLLOUT = "rollout";
-
- public Experiment Experiment { get; }
+ public const string DECISION_SOURCE_HOLDOUT = "holdout";
+ public ExperimentCore Experiment { get; }
public Variation Variation { get; }
public string Source { get; }
- public FeatureDecision(Experiment experiment, Variation variation, string source)
+ public FeatureDecision(ExperimentCore experiment, Variation variation, string source)
{
Experiment = experiment;
Variation = variation;
diff --git a/OptimizelySDK/Entity/Holdout.cs b/OptimizelySDK/Entity/Holdout.cs
index 17f4c2bd..834b5229 100644
--- a/OptimizelySDK/Entity/Holdout.cs
+++ b/OptimizelySDK/Entity/Holdout.cs
@@ -45,10 +45,15 @@ public enum HoldoutStatus
public string[] ExcludedFlags { get; set; } = new string[0];
///
- /// Determine if holdout is currently activated/running
+ /// Layer ID is always empty for holdouts as they don't belong to any layer
///
- public override bool IsActivated =>
- !string.IsNullOrEmpty(Status) && Status == STATUS_RUNNING;
-
+ public override string LayerId
+ {
+ get => string.Empty;
+ set
+ {
+ /* Holdouts don't have layer IDs, ignore any assignment */
+ }
+ }
}
}
diff --git a/OptimizelySDK/Event/Entity/ImpressionEvent.cs b/OptimizelySDK/Event/Entity/ImpressionEvent.cs
index 12949ea6..0e5d0152 100644
--- a/OptimizelySDK/Event/Entity/ImpressionEvent.cs
+++ b/OptimizelySDK/Event/Entity/ImpressionEvent.cs
@@ -28,7 +28,7 @@ public class ImpressionEvent : UserEvent
public string UserId { get; private set; }
public VisitorAttribute[] VisitorAttributes { get; private set; }
- public Experiment Experiment { get; set; }
+ public ExperimentCore Experiment { get; set; }
public DecisionMetadata Metadata { get; set; }
public Variation Variation { get; set; }
public bool? BotFiltering { get; set; }
@@ -42,7 +42,7 @@ public class Builder
private EventContext EventContext;
public VisitorAttribute[] VisitorAttributes;
- private Experiment Experiment;
+ private ExperimentCore Experiment;
private Variation Variation;
private DecisionMetadata Metadata;
private bool? BotFiltering;
@@ -61,7 +61,7 @@ public Builder WithEventContext(EventContext eventContext)
return this;
}
- public Builder WithExperiment(Experiment experiment)
+ public Builder WithExperiment(ExperimentCore experiment)
{
Experiment = experiment;
diff --git a/OptimizelySDK/Event/UserEventFactory.cs b/OptimizelySDK/Event/UserEventFactory.cs
index 28d6fb87..adb9c87b 100644
--- a/OptimizelySDK/Event/UserEventFactory.cs
+++ b/OptimizelySDK/Event/UserEventFactory.cs
@@ -61,7 +61,7 @@ public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig,
/// experiment or featureDecision source
/// ImpressionEvent instance
public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig,
- Experiment activatedExperiment,
+ ExperimentCore activatedExperiment,
Variation variation,
string userId,
UserAttributes userAttributes,
diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs
index 99cbdaaf..23483360 100644
--- a/OptimizelySDK/Optimizely.cs
+++ b/OptimizelySDK/Optimizely.cs
@@ -1154,12 +1154,12 @@ private void SendImpressionEvent(Experiment experiment, Variation variation, str
/// The user's attributes
/// It can either be experiment key in case if ruleType is experiment or it's feature key in case ruleType is feature-test or rollout
/// It can either be experiment in case impression event is sent from activate or it's feature-test or rollout
- private bool SendImpressionEvent(Experiment experiment, Variation variation, string userId,
+ private bool SendImpressionEvent(ExperimentCore experiment, Variation variation, string userId,
UserAttributes userAttributes, ProjectConfig config,
string flagKey, string ruleType, bool enabled
)
{
- if (experiment != null && !experiment.IsExperimentRunning)
+ if (experiment != null && !experiment.isRunning)
{
Logger.Log(LogLevel.ERROR,
@"Experiment has ""Launched"" status so not dispatching event during activation.");
diff --git a/OptimizelySDK/Utils/ExperimentUtils.cs b/OptimizelySDK/Utils/ExperimentUtils.cs
index c87cebbf..8ee5fab5 100644
--- a/OptimizelySDK/Utils/ExperimentUtils.cs
+++ b/OptimizelySDK/Utils/ExperimentUtils.cs
@@ -25,7 +25,7 @@ public class ExperimentUtils
{
public static bool IsExperimentActive(Experiment experiment, ILogger logger)
{
- if (!experiment.IsExperimentRunning)
+ if (!experiment.isRunning)
{
logger.Log(LogLevel.INFO, $"Experiment \"{experiment.Key}\" is not running.");
@@ -46,7 +46,7 @@ public static bool IsExperimentActive(Experiment experiment, ILogger logger)
/// Custom logger implementation to record log outputs
/// true if the user meets audience conditions to be in experiment, false otherwise.
public static Result DoesUserMeetAudienceConditions(ProjectConfig config,
- Experiment experiment,
+ ExperimentCore experiment,
OptimizelyUserContext user,
string loggingKeyType,
string loggingKey,
@@ -64,15 +64,13 @@ ILogger logger
{
expConditions = experiment.AudienceConditionsList;
logger.Log(LogLevel.DEBUG,
- $@"Evaluating audiences for {loggingKeyType} ""{loggingKey}"": {
- experiment.AudienceConditionsString}.");
+ $@"Evaluating audiences for {loggingKeyType} ""{loggingKey}"": {experiment.AudienceConditionsString}.");
}
else
{
expConditions = experiment.AudienceIdsList;
logger.Log(LogLevel.DEBUG,
- $@"Evaluating audiences for {loggingKeyType} ""{loggingKey}"": {
- experiment.AudienceIdsString}.");
+ $@"Evaluating audiences for {loggingKeyType} ""{loggingKey}"": {experiment.AudienceIdsString}.");
}
// If there are no audiences, return true because that means ALL users are included in the experiment.
@@ -84,8 +82,7 @@ ILogger logger
var result = expConditions.Evaluate(config, user, logger).GetValueOrDefault();
var resultText = result.ToString().ToUpper();
logger.Log(LogLevel.INFO,
- reasons.AddInfo($@"Audiences for {loggingKeyType} ""{loggingKey
- }"" collectively evaluated to {resultText}"));
+ reasons.AddInfo($@"Audiences for {loggingKeyType} ""{loggingKey}"" collectively evaluated to {resultText}"));
return Result.NewResult(result, reasons);
}
}
From 7ebbb27a685153a3776c619f9361e70cbc65d637 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Fri, 29 Aug 2025 21:20:50 +0600
Subject: [PATCH 10/18] [FSSDK-11547] Impression event + notification
adjustment (#387)
---
.../DecisionServiceHoldoutTest.cs | 89 +++++++++++++++++++
.../OptimizelyUserContextHoldoutTest.cs | 88 ++++++++++++++++++
OptimizelySDK/Bucketing/DecisionService.cs | 20 ++---
OptimizelySDK/Config/DatafileProjectConfig.cs | 8 +-
OptimizelySDK/Optimizely.cs | 3 +-
OptimizelySDK/ProjectConfig.cs | 6 +-
6 files changed, 191 insertions(+), 23 deletions(-)
diff --git a/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs b/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs
index a9457cdb..3d34e151 100644
--- a/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs
+++ b/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs
@@ -25,6 +25,8 @@
using OptimizelySDK.Config;
using OptimizelySDK.Entity;
using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Event;
+using OptimizelySDK.Event.Entity;
using OptimizelySDK.Logger;
using OptimizelySDK.OptimizelyDecisions;
@@ -34,6 +36,7 @@ namespace OptimizelySDK.Tests
public class DecisionServiceHoldoutTest
{
private Mock LoggerMock;
+ private Mock EventProcessorMock;
private DecisionService DecisionService;
private DatafileProjectConfig Config;
private JObject TestData;
@@ -46,6 +49,7 @@ public class DecisionServiceHoldoutTest
public void Initialize()
{
LoggerMock = new Mock();
+ EventProcessorMock = new Mock();
// Load test data
var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
@@ -242,5 +246,90 @@ public void TestGetVariationsForFeatureList_Holdout_DecisionReasons()
Assert.IsTrue(decisionWithReasons.DecisionReasons.ToReport().Count > 0, "Should have decision reasons");
}
}
+
+ [Test]
+ public void TestImpressionEventForHoldout()
+ {
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"];
+ var userAttributes = new UserAttributes();
+
+ var eventDispatcher = new Event.Dispatcher.DefaultEventDispatcher(LoggerMock.Object);
+ var optimizelyWithMockedEvents = new Optimizely(
+ TestData["datafileWithHoldouts"].ToString(),
+ eventDispatcher,
+ LoggerMock.Object,
+ new ErrorHandler.NoOpErrorHandler(),
+ null, // userProfileService
+ false, // skipJsonValidation
+ EventProcessorMock.Object
+ );
+
+ EventProcessorMock.Setup(ep => ep.Process(It.IsAny()));
+
+ var userContext = optimizelyWithMockedEvents.CreateUserContext(TestUserId, userAttributes);
+ var decision = userContext.Decide(featureFlag.Key);
+
+ Assert.IsNotNull(decision, "Decision should not be null");
+ Assert.IsNotNull(decision.RuleKey, "RuleKey should not be null");
+
+ var actualHoldout = Config.Holdouts?.FirstOrDefault(h => h.Key == decision.RuleKey);
+
+ Assert.IsNotNull(actualHoldout,
+ $"RuleKey '{decision.RuleKey}' should correspond to a holdout experiment");
+ Assert.AreEqual(featureFlag.Key, decision.FlagKey, "Flag key should match");
+
+ var holdoutVariation = actualHoldout.Variations.FirstOrDefault(v => v.Key == decision.VariationKey);
+
+ Assert.IsNotNull(holdoutVariation,
+ $"Variation '{decision.VariationKey}' should be from the chosen holdout '{actualHoldout.Key}'");
+
+ Assert.AreEqual(holdoutVariation.FeatureEnabled, decision.Enabled,
+ "Enabled flag should match holdout variation's featureEnabled value");
+
+ EventProcessorMock.Verify(ep => ep.Process(It.IsAny()), Times.Once,
+ "Impression event should be processed exactly once for holdout decision");
+
+ EventProcessorMock.Verify(ep => ep.Process(It.Is(ie =>
+ ie.Experiment.Key == actualHoldout.Key &&
+ ie.Experiment.Id == actualHoldout.Id &&
+ ie.Timestamp > 0 &&
+ ie.UserId == TestUserId
+ )), Times.Once, "Impression event should contain correct holdout experiment details");
+ }
+
+ [Test]
+ public void TestImpressionEventForHoldout_DisableDecisionEvent()
+ {
+ var featureFlag = Config.FeatureKeyMap["test_flag_1"];
+ var userAttributes = new UserAttributes();
+
+ var eventDispatcher = new Event.Dispatcher.DefaultEventDispatcher(LoggerMock.Object);
+ var optimizelyWithMockedEvents = new Optimizely(
+ TestData["datafileWithHoldouts"].ToString(),
+ eventDispatcher,
+ LoggerMock.Object,
+ new ErrorHandler.NoOpErrorHandler(),
+ null, // userProfileService
+ false, // skipJsonValidation
+ EventProcessorMock.Object
+ );
+
+ EventProcessorMock.Setup(ep => ep.Process(It.IsAny()));
+
+ var userContext = optimizelyWithMockedEvents.CreateUserContext(TestUserId, userAttributes);
+ var decision = userContext.Decide(featureFlag.Key, new[] { OptimizelyDecideOption.DISABLE_DECISION_EVENT });
+
+ Assert.IsNotNull(decision, "Decision should not be null");
+ Assert.IsNotNull(decision.RuleKey, "User should be bucketed into a holdout");
+
+ var chosenHoldout = Config.Holdouts?.FirstOrDefault(h => h.Key == decision.RuleKey);
+
+ Assert.IsNotNull(chosenHoldout, $"Holdout '{decision.RuleKey}' should exist in config");
+
+ Assert.AreEqual(featureFlag.Key, decision.FlagKey, "Flag key should match");
+
+ EventProcessorMock.Verify(ep => ep.Process(It.IsAny()), Times.Never,
+ "No impression event should be processed when DISABLE_DECISION_EVENT option is used");
+ }
}
}
diff --git a/OptimizelySDK.Tests/OptimizelyUserContextHoldoutTest.cs b/OptimizelySDK.Tests/OptimizelyUserContextHoldoutTest.cs
index 369dabb9..978d207a 100644
--- a/OptimizelySDK.Tests/OptimizelyUserContextHoldoutTest.cs
+++ b/OptimizelySDK.Tests/OptimizelyUserContextHoldoutTest.cs
@@ -28,7 +28,9 @@
using OptimizelySDK.Event;
using OptimizelySDK.Event.Dispatcher;
using OptimizelySDK.Logger;
+using OptimizelySDK.Notifications;
using OptimizelySDK.OptimizelyDecisions;
+using OptimizelySDK.Utils;
namespace OptimizelySDK.Tests
{
@@ -572,6 +574,92 @@ public void TestDecideReasons_HoldoutDecisionContainsRelevantReasons()
"Should contain holdout-related decision reasoning");
}
+
+ #endregion
+
+ #region Notification test
+
+ [Test]
+ public void TestDecide_HoldoutNotificationContent()
+ {
+ var capturedNotifications = new List>();
+
+ NotificationCenter.DecisionCallback notificationCallback =
+ (decisionType, userId, userAttributes, decisionInfo) =>
+ {
+ capturedNotifications.Add(new Dictionary(decisionInfo));
+ };
+
+ OptimizelyInstance.NotificationCenter.AddNotification(
+ NotificationCenter.NotificationType.Decision,
+ notificationCallback);
+
+ var userContext = OptimizelyInstance.CreateUserContext(TestUserId,
+ new UserAttributes { { "country", "us" } });
+ var decision = userContext.Decide("test_flag_1");
+
+ Assert.AreEqual(1, capturedNotifications.Count,
+ "Should have captured exactly one decision notification");
+
+ var notification = capturedNotifications.First();
+
+ Assert.IsTrue(notification.ContainsKey("ruleKey"),
+ "Notification should contain ruleKey");
+
+ var ruleKey = notification["ruleKey"]?.ToString();
+
+ Assert.IsNotNull(ruleKey, "RuleKey should not be null");
+
+ var holdoutExperiment = Config.Holdouts?.FirstOrDefault(h => h.Key == ruleKey);
+
+ Assert.IsNotNull(holdoutExperiment,
+ $"RuleKey '{ruleKey}' should correspond to a holdout experiment");
+ Assert.IsTrue(notification.ContainsKey("flagKey"),
+ "Holdout notification should contain flagKey");
+ Assert.IsTrue(notification.ContainsKey("enabled"),
+ "Holdout notification should contain enabled flag");
+ Assert.IsTrue(notification.ContainsKey("variationKey"),
+ "Holdout notification should contain variationKey");
+ Assert.IsTrue(notification.ContainsKey("experimentId"),
+ "Holdout notification should contain experimentId");
+ Assert.IsTrue(notification.ContainsKey("variationId"),
+ "Holdout notification should contain variationId");
+
+ var flagKey = notification["flagKey"]?.ToString();
+
+ Assert.AreEqual("test_flag_1", flagKey, "FlagKey should match the requested flag");
+
+ var experimentId = notification["experimentId"]?.ToString();
+ Assert.AreEqual(holdoutExperiment.Id, experimentId,
+ "ExperimentId in notification should match holdout experiment ID");
+
+ var variationId = notification["variationId"]?.ToString();
+ var holdoutVariation = holdoutExperiment.Variations?.FirstOrDefault(v => v.Id == variationId);
+
+ Assert.IsNotNull(holdoutVariation,
+ $"VariationId '{variationId}' should correspond to a holdout variation");
+
+ var variationKey = notification["variationKey"]?.ToString();
+
+ Assert.AreEqual(holdoutVariation.Key, variationKey,
+ "VariationKey in notification should match holdout variation key");
+
+ var enabled = notification["enabled"];
+
+ Assert.IsNotNull(enabled, "Enabled flag should be present in notification");
+ Assert.AreEqual(holdoutVariation.FeatureEnabled, (bool)enabled,
+ "Enabled flag should match holdout variation's featureEnabled value");
+
+ Assert.IsTrue(Config.FeatureKeyMap.ContainsKey(flagKey),
+ $"FlagKey '{flagKey}' should exist in config");
+
+ Assert.IsTrue(notification.ContainsKey("variables"),
+ "Notification should contain variables");
+ Assert.IsTrue(notification.ContainsKey("reasons"),
+ "Notification should contain reasons");
+ Assert.IsTrue(notification.ContainsKey("decisionEventDispatched"),
+ "Notification should contain decisionEventDispatched");
+ }
#endregion
}
}
diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs
index ad5487a2..7bc8054b 100644
--- a/OptimizelySDK/Bucketing/DecisionService.cs
+++ b/OptimizelySDK/Bucketing/DecisionService.cs
@@ -759,7 +759,7 @@ public virtual Result GetDecisionForFlag(
var userId = user.GetUserId();
// Check holdouts first (highest priority)
- var holdouts = projectConfig.GetHoldoutsForFlag(featureFlag.Key);
+ var holdouts = projectConfig.GetHoldoutsForFlag(featureFlag.Id);
foreach (var holdout in holdouts)
{
var holdoutDecision = GetVariationForHoldout(holdout, user, projectConfig);
@@ -915,10 +915,7 @@ ProjectConfig config
var infoMessage = $"Holdout \"{holdout.Key}\" is not running.";
Logger.Log(LogLevel.INFO, infoMessage);
reasons.AddInfo(infoMessage);
- return Result.NewResult(
- new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_HOLDOUT),
- reasons
- );
+ return Result.NullResult(reasons);
}
var audienceResult = ExperimentUtils.DoesUserMeetAudienceConditions(
@@ -934,10 +931,7 @@ ProjectConfig config
if (!audienceResult.ResultObject)
{
reasons.AddInfo($"User \"{userId}\" does not meet conditions for holdout ({holdout.Key}).");
- return Result.NewResult(
- new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_HOLDOUT),
- reasons
- );
+ return Result.NullResult(reasons);
}
var attributes = user.GetAttributes();
@@ -945,7 +939,7 @@ ProjectConfig config
var bucketedVariation = Bucketer.Bucket(config, holdout, bucketingIdResult.ResultObject, userId);
reasons += bucketedVariation.DecisionReasons;
- if (bucketedVariation.ResultObject != null)
+ if (bucketedVariation.ResultObject != null && !string.IsNullOrEmpty(bucketedVariation.ResultObject.Key))
{
reasons.AddInfo($"User \"{userId}\" is bucketed into holdout variation \"{bucketedVariation.ResultObject.Key}\".");
return Result.NewResult(
@@ -955,11 +949,7 @@ ProjectConfig config
}
reasons.AddInfo($"User \"{userId}\" is not bucketed into holdout variation \"{holdout.Key}\".");
-
- return Result.NewResult(
- new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_HOLDOUT),
- reasons
- );
+ return Result.NullResult(reasons);
}
///
/// Finds a validated forced decision.
diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs
index 6721832e..e701bc4e 100644
--- a/OptimizelySDK/Config/DatafileProjectConfig.cs
+++ b/OptimizelySDK/Config/DatafileProjectConfig.cs
@@ -875,13 +875,13 @@ public string ToDatafile()
}
///
- /// Get holdout instances associated with the given feature flag key.
+ /// Get holdout instances associated with the given feature flag Id.
///
- /// Feature flag key
+ /// Feature flag Id
/// Array of holdouts associated with the flag, empty array if none
- public Holdout[] GetHoldoutsForFlag(string flagKey)
+ public Holdout[] GetHoldoutsForFlag(string flagId)
{
- var holdouts = _holdoutConfig?.GetHoldoutsForFlag(flagKey);
+ var holdouts = _holdoutConfig?.GetHoldoutsForFlag(flagId);
return holdouts?.ToArray() ?? new Holdout[0];
}
/// Returns the datafile corresponding to ProjectConfig
diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs
index 23483360..9040ea17 100644
--- a/OptimizelySDK/Optimizely.cs
+++ b/OptimizelySDK/Optimizely.cs
@@ -564,7 +564,8 @@ public virtual bool IsFeatureEnabled(string featureKey, string userId,
// This information is only necessary for feature tests.
// For rollouts experiments and variations are an implementation detail only.
- if (decision?.Source == FeatureDecision.DECISION_SOURCE_FEATURE_TEST)
+ if (decision?.Source == FeatureDecision.DECISION_SOURCE_FEATURE_TEST ||
+ decision?.Source == FeatureDecision.DECISION_SOURCE_HOLDOUT)
{
decisionSource = decision.Source;
sourceInfo["experimentKey"] = decision.Experiment.Key;
diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs
index 6a2b5259..992c900b 100644
--- a/OptimizelySDK/ProjectConfig.cs
+++ b/OptimizelySDK/ProjectConfig.cs
@@ -321,11 +321,11 @@ public interface ProjectConfig
Holdout GetHoldout(string holdoutId);
///
- /// Get holdout instances associated with the given feature flag key.
+ /// Get holdout instances associated with the given feature flag Id.
///
- /// Feature flag key
+ /// Feature flag Id
/// Array of holdouts associated with the flag, empty array if none
- Holdout[] GetHoldoutsForFlag(string flagKey);
+ Holdout[] GetHoldoutsForFlag(string flagId);
///
/// Returns the datafile corresponding to ProjectConfig
From 2d23fe1cc8fd9a5b261a972fe31fd8c985a0b569 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Thu, 4 Sep 2025 21:17:44 +0600
Subject: [PATCH 11/18] [FSSDK-11837] legacy api adjustment with key null
support in decide (#388)
---
.../OptimizelyUserContextTest.cs | 17 +++++++++++++++++
OptimizelySDK/Optimizely.cs | 7 +++++++
2 files changed, 24 insertions(+)
diff --git a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs
index 21ae10db..49b3a903 100644
--- a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs
+++ b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs
@@ -405,6 +405,23 @@ public void DecideInvalidFlagKey()
Assert.IsTrue(TestData.CompareObjects(decision, decisionExpected));
}
+ [Test]
+ public void DecideNullFlagKey()
+ {
+ var user = Optimizely.CreateUserContext(UserID);
+ user.SetAttribute("browser_type", "chrome");
+
+ var decisionExpected = OptimizelyDecision.NewErrorDecision(
+ null,
+ user,
+ DecisionMessage.Reason(DecisionMessage.FLAG_KEY_INVALID, "null"),
+ ErrorHandlerMock.Object,
+ LoggerMock.Object);
+ var decision = user.Decide(null);
+
+ Assert.IsTrue(TestData.CompareObjects(decision, decisionExpected));
+ }
+
[Test]
public void DecideWhenConfigIsNull()
{
diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs
index 9040ea17..4e0a0bce 100644
--- a/OptimizelySDK/Optimizely.cs
+++ b/OptimizelySDK/Optimizely.cs
@@ -871,6 +871,13 @@ OptimizelyDecideOption[] options
ErrorHandler, Logger);
}
+ if (key == null)
+ {
+ return OptimizelyDecision.NewErrorDecision(key, user,
+ DecisionMessage.Reason(DecisionMessage.FLAG_KEY_INVALID, "null"),
+ ErrorHandler, Logger);
+ }
+
var allOptions = GetAllOptions(options).
Where(opt => opt != OptimizelyDecideOption.ENABLED_FLAGS_ONLY).
ToArray();
From e99b75ad9b232e3b2028e3503cd95a863e6a3f07 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Tue, 9 Sep 2025 19:23:04 +0600
Subject: [PATCH 12/18] [FSSDK-11342] deps inclusion (#389)
---
OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj | 2 ++
1 file changed, 2 insertions(+)
diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
index 44240b2a..ab55bbac 100644
--- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
+++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
@@ -163,5 +163,7 @@
+
+
From dd69bea5cc67629a0286549691307124edf4679c Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Mon, 22 Sep 2025 21:35:47 +0600
Subject: [PATCH 13/18] [FSSDK-11138] update project config (#390)
---
.../OptimizelySDK.Net35.csproj | 3 +
.../OptimizelySDK.Net40.csproj | 3 +
.../OptimizelySDK.NetStandard16.csproj | 1 +
.../OptimizelySDK.NetStandard20.csproj | 3 +
OptimizelySDK.Tests/ProjectConfigTest.cs | 35 +++++++++++
OptimizelySDK/Config/DatafileProjectConfig.cs | 28 +++++++++
OptimizelySDK/Entity/Cmab.cs | 63 +++++++++++++++++++
OptimizelySDK/Entity/Experiment.cs | 7 +++
OptimizelySDK/OptimizelySDK.csproj | 1 +
OptimizelySDK/ProjectConfig.cs | 12 ++++
OptimizelySDK/Utils/ConfigParser.cs | 8 ++-
OptimizelySDK/Utils/schema.json | 16 ++++-
12 files changed, 178 insertions(+), 2 deletions(-)
create mode 100644 OptimizelySDK/Entity/Cmab.cs
diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
index 303a742e..e441df53 100644
--- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
+++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
@@ -88,6 +88,9 @@
Entity\Experiment.cs
+
+ Entity\Cmab.cs
+
Entity\Holdout.cs
diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
index 3e0a9ea5..c1150280 100644
--- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
+++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
@@ -90,6 +90,9 @@
Entity\Experiment.cs
+
+ Entity\Cmab.cs
+
Entity\Holdout.cs
diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
index ab55bbac..1490ba14 100644
--- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
+++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
@@ -26,6 +26,7 @@
+
diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index 12f9cb55..418f606d 100644
--- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
+++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
@@ -178,6 +178,9 @@
Entity\Experiment.cs
+
+ Entity\Cmab.cs
+
Entity\Holdout.cs
diff --git a/OptimizelySDK.Tests/ProjectConfigTest.cs b/OptimizelySDK.Tests/ProjectConfigTest.cs
index 15e95926..52373b2d 100644
--- a/OptimizelySDK.Tests/ProjectConfigTest.cs
+++ b/OptimizelySDK.Tests/ProjectConfigTest.cs
@@ -1455,5 +1455,40 @@ public void TestMissingHoldoutsField_BackwardCompatibility()
}
#endregion
+
+ [Test]
+ public void TestCmabFieldPopulation()
+ {
+
+ var datafileJson = JObject.Parse(TestData.Datafile);
+ var experiments = (JArray)datafileJson["experiments"];
+
+ if (experiments.Count > 0)
+ {
+ var firstExperiment = (JObject)experiments[0];
+
+ firstExperiment["cmab"] = new JObject
+ {
+ ["attributeIds"] = new JArray { "7723280020", "7723348204" },
+ ["trafficAllocation"] = 4000
+ };
+
+ firstExperiment["trafficAllocation"] = new JArray();
+ }
+
+ var modifiedDatafile = datafileJson.ToString();
+ var projectConfig = DatafileProjectConfig.Create(modifiedDatafile, LoggerMock.Object, ErrorHandlerMock.Object);
+ var experimentWithCmab = projectConfig.GetExperimentFromKey("test_experiment");
+
+ Assert.IsNotNull(experimentWithCmab.Cmab);
+ Assert.AreEqual(2, experimentWithCmab.Cmab.AttributeIds.Count);
+ Assert.Contains("7723280020", experimentWithCmab.Cmab.AttributeIds);
+ Assert.Contains("7723348204", experimentWithCmab.Cmab.AttributeIds);
+ Assert.AreEqual(4000, experimentWithCmab.Cmab.TrafficAllocation);
+
+ var experimentWithoutCmab = projectConfig.GetExperimentFromKey("paused_experiment");
+
+ Assert.IsNull(experimentWithoutCmab.Cmab);
+ }
}
}
diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs
index e701bc4e..52593e78 100644
--- a/OptimizelySDK/Config/DatafileProjectConfig.cs
+++ b/OptimizelySDK/Config/DatafileProjectConfig.cs
@@ -195,6 +195,13 @@ private Dictionary> _VariationIdMap
public Dictionary AttributeKeyMap => _AttributeKeyMap;
+ ///
+ /// Associative array of attribute ID to Attribute(s) in the datafile
+ ///
+ private Dictionary _AttributeIdMap;
+
+ public Dictionary AttributeIdMap => _AttributeIdMap;
+
///
/// Associative array of audience ID to Audience(s) in the datafile
///
@@ -332,6 +339,8 @@ private void Initialize()
true);
_AttributeKeyMap = ConfigParser.GenerateMap(Attributes,
a => a.Key, true);
+ _AttributeIdMap = ConfigParser.GenerateMap(Attributes,
+ a => a.Id, true);
_AudienceIdMap = ConfigParser.GenerateMap(Audiences,
a => a.Id.ToString(), true);
_FeatureKeyMap = ConfigParser.GenerateMap(FeatureFlags,
@@ -653,6 +662,25 @@ public Attribute GetAttribute(string attributeKey)
return new Attribute();
}
+ ///
+ /// Get the Attribute from the ID
+ ///
+ /// ID of the Attribute
+ /// Attribute Entity corresponding to the ID or a dummy entity if ID is invalid
+ public Attribute GetAttributeById(string attributeId)
+ {
+ if (_AttributeIdMap.ContainsKey(attributeId))
+ {
+ return _AttributeIdMap[attributeId];
+ }
+
+ var message = $@"Attribute ID ""{attributeId}"" is not in datafile.";
+ Logger.Log(LogLevel.ERROR, message);
+ ErrorHandler.HandleError(
+ new InvalidAttributeException("Provided attribute is not in datafile."));
+ return new Attribute();
+ }
+
///
/// Get the Variation from the keys
///
diff --git a/OptimizelySDK/Entity/Cmab.cs b/OptimizelySDK/Entity/Cmab.cs
new file mode 100644
index 00000000..f8caec87
--- /dev/null
+++ b/OptimizelySDK/Entity/Cmab.cs
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2025, Optimizely
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace OptimizelySDK.Entity
+{
+ ///
+ /// Class representing CMAB (Contextual Multi-Armed Bandit) configuration for experiments.
+ ///
+ public class Cmab
+ {
+ ///
+ /// List of attribute IDs that are relevant for CMAB decision making.
+ /// These attributes will be used to filter user attributes when making CMAB requests.
+ ///
+ [JsonProperty("attributeIds")]
+ public List AttributeIds { get; set; }
+
+ ///
+ /// Traffic allocation value for CMAB experiments.
+ /// Determines what portion of traffic should be allocated to CMAB decision making.
+ ///
+ [JsonProperty("trafficAllocation")]
+ public int? TrafficAllocation { get; set; }
+
+ ///
+ /// Initializes a new instance of the Cmab class with specified values.
+ ///
+ /// List of attribute IDs for CMAB
+ /// Traffic allocation value
+ public Cmab(List attributeIds, int? trafficAllocation = null)
+ {
+ AttributeIds = attributeIds ?? new List();
+ TrafficAllocation = trafficAllocation;
+ }
+
+ ///
+ /// Returns a string representation of the CMAB configuration.
+ ///
+ /// String representation
+ public override string ToString()
+ {
+ var attributeList = AttributeIds ?? new List();
+ return string.Format("Cmab{{AttributeIds=[{0}], TrafficAllocation={1}}}",
+ string.Join(", ", attributeList.ToArray()), TrafficAllocation);
+ }
+ }
+}
diff --git a/OptimizelySDK/Entity/Experiment.cs b/OptimizelySDK/Entity/Experiment.cs
index 8a7b4036..52e99015 100644
--- a/OptimizelySDK/Entity/Experiment.cs
+++ b/OptimizelySDK/Entity/Experiment.cs
@@ -15,6 +15,7 @@
*/
using System.Collections.Generic;
+using Newtonsoft.Json;
namespace OptimizelySDK.Entity
{
@@ -48,6 +49,12 @@ public class Experiment : ExperimentCore
public bool IsInMutexGroup =>
!string.IsNullOrEmpty(GroupPolicy) && GroupPolicy == MUTEX_GROUP_POLICY;
+ ///
+ /// CMAB (Contextual Multi-Armed Bandit) configuration for the experiment.
+ ///
+ [JsonProperty("cmab")]
+ public Cmab Cmab { get; set; }
+
///
/// Determin if user is forced variation of experiment
///
diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj
index 14201b4e..b4537b92 100644
--- a/OptimizelySDK/OptimizelySDK.csproj
+++ b/OptimizelySDK/OptimizelySDK.csproj
@@ -84,6 +84,7 @@
+
diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs
index 992c900b..28f63d24 100644
--- a/OptimizelySDK/ProjectConfig.cs
+++ b/OptimizelySDK/ProjectConfig.cs
@@ -113,6 +113,11 @@ public interface ProjectConfig
///
Dictionary AttributeKeyMap { get; }
+ ///
+ /// Associative array of attribute ID to Attribute(s) in the datafile
+ ///
+ Dictionary AttributeIdMap { get; }
+
///
/// Associative array of audience ID to Audience(s) in the datafile
///
@@ -234,6 +239,13 @@ public interface ProjectConfig
/// Attribute Entity corresponding to the key or a dummy entity if key is invalid
Attribute GetAttribute(string attributeKey);
+ ///
+ /// Get the Attribute from the ID
+ ///
+ /// ID of the Attribute
+ /// Attribute Entity corresponding to the ID or a dummy entity if ID is invalid
+ Attribute GetAttributeById(string attributeId);
+
///
/// Get the Variation from the keys
///
diff --git a/OptimizelySDK/Utils/ConfigParser.cs b/OptimizelySDK/Utils/ConfigParser.cs
index 318ff1b6..c6b86916 100644
--- a/OptimizelySDK/Utils/ConfigParser.cs
+++ b/OptimizelySDK/Utils/ConfigParser.cs
@@ -33,7 +33,13 @@ public static Dictionary GenerateMap(IEnumerable entities,
Func getKey, bool clone
)
{
- return entities.ToDictionary(e => getKey(e), e => clone ? (T)e.Clone() : e);
+ var dictionary = new Dictionary();
+ foreach (var entity in entities)
+ {
+ var key = getKey(entity);
+ dictionary[key] = clone ? (T)entity.Clone() : entity;
+ }
+ return dictionary;
}
}
}
diff --git a/OptimizelySDK/Utils/schema.json b/OptimizelySDK/Utils/schema.json
index c434bbac..dd3fedef 100644
--- a/OptimizelySDK/Utils/schema.json
+++ b/OptimizelySDK/Utils/schema.json
@@ -182,6 +182,20 @@
},
"forcedVariations": {
"type": "object"
+ },
+ "cmab": {
+ "type": "object",
+ "properties": {
+ "attributeIds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "trafficAllocation": {
+ "type": "integer"
+ }
+ }
}
},
"required": [
@@ -279,4 +293,4 @@
"version",
"revision"
]
-}
\ No newline at end of file
+}
From 8e90274ec69577c5de3b6518c92987f28666ac0b Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Thu, 25 Sep 2025 22:57:13 +0600
Subject: [PATCH 14/18] [FSSDK-11159] lru cache impl update (#391)
---
OptimizelySDK.Tests/OdpTests/LruCacheTest.cs | 108 +++++++++++++++++++
OptimizelySDK/Odp/LruCache.cs | 12 +++
2 files changed, 120 insertions(+)
diff --git a/OptimizelySDK.Tests/OdpTests/LruCacheTest.cs b/OptimizelySDK.Tests/OdpTests/LruCacheTest.cs
index 426240c5..2ff8071d 100644
--- a/OptimizelySDK.Tests/OdpTests/LruCacheTest.cs
+++ b/OptimizelySDK.Tests/OdpTests/LruCacheTest.cs
@@ -208,5 +208,113 @@ public void ShouldHandleWhenCacheIsReset()
Assert.AreEqual(0, cache.CurrentCacheKeysForTesting().Length);
}
+
+ [Test]
+ public void ShouldHandleRemoveNonExistentKey()
+ {
+ var cache = new LruCache>();
+ cache.Save("user1", _segments1And2);
+ cache.Save("user2", _segments3And4);
+
+ // Remove a key that doesn't exist
+ cache.Remove("user3");
+
+ // Existing keys should still be there
+ Assert.AreEqual(_segments1And2, cache.Lookup("user1"));
+ Assert.AreEqual(_segments3And4, cache.Lookup("user2"));
+ }
+
+ [Test]
+ public void ShouldHandleRemoveExistingKey()
+ {
+ var cache = new LruCache>();
+
+ cache.Save("user1", _segments1And2);
+ cache.Save("user2", _segments3And4);
+ cache.Save("user3", _segments5And6);
+
+ Assert.AreEqual(_segments1And2, cache.Lookup("user1"));
+ Assert.AreEqual(_segments3And4, cache.Lookup("user2"));
+ Assert.AreEqual(_segments5And6, cache.Lookup("user3"));
+
+ cache.Remove("user2");
+
+ Assert.AreEqual(_segments1And2, cache.Lookup("user1"));
+ Assert.IsNull(cache.Lookup("user2"));
+ Assert.AreEqual(_segments5And6, cache.Lookup("user3"));
+ }
+
+ [Test]
+ public void ShouldHandleRemoveFromZeroSizedCache()
+ {
+ var cache = new LruCache>(0);
+
+ cache.Save("user1", _segments1And2);
+ cache.Remove("user1");
+
+ Assert.IsNull(cache.Lookup("user1"));
+ Assert.AreEqual(0, cache.CurrentCacheKeysForTesting().Length);
+ }
+
+ [Test]
+ public void ShouldHandleRemoveAndAddBack()
+ {
+ var cache = new LruCache>();
+
+ cache.Save("user1", _segments1And2);
+ cache.Save("user2", _segments3And4);
+ cache.Save("user3", _segments5And6);
+
+ // Remove user2 and add it back with different data
+ cache.Remove("user2");
+ cache.Save("user2", _segments1And2);
+
+ Assert.AreEqual(_segments1And2, cache.Lookup("user1"));
+ Assert.AreEqual(_segments1And2, cache.Lookup("user2"));
+ Assert.AreEqual(_segments5And6, cache.Lookup("user3"));
+
+ Assert.AreEqual(3, cache.CurrentCacheKeysForTesting().Length);
+ }
+
+ [Test]
+ public void ShouldHandleThreadSafetyWithRemove()
+ {
+ var cache = new LruCache(100);
+
+ for (int i = 1; i <= 100; i++)
+ {
+ cache.Save($"key{i}", $"value{i}");
+ }
+
+ var threads = new List();
+
+ for (int i = 1; i <= 50; i++)
+ {
+ int localI = i; // Capture variable for closure
+ var thread = new Thread(() => cache.Remove($"key{localI}"));
+ threads.Add(thread);
+ thread.Start();
+ }
+
+ // Wait for all threads to complete
+ foreach (var thread in threads)
+ {
+ thread.Join();
+ }
+
+ for (int i = 1; i <= 100; i++)
+ {
+ if (i <= 50)
+ {
+ Assert.IsNull(cache.Lookup($"key{i}"), $"key{i} should be removed");
+ }
+ else
+ {
+ Assert.AreEqual($"value{i}", cache.Lookup($"key{i}"), $"key{i} should still exist");
+ }
+ }
+
+ Assert.AreEqual(50, cache.CurrentCacheKeysForTesting().Length);
+ }
}
}
diff --git a/OptimizelySDK/Odp/LruCache.cs b/OptimizelySDK/Odp/LruCache.cs
index bf1af65a..45b9be5d 100644
--- a/OptimizelySDK/Odp/LruCache.cs
+++ b/OptimizelySDK/Odp/LruCache.cs
@@ -165,6 +165,18 @@ public void Reset()
}
}
+ ///
+ /// Remove the element associated with the provided key from the cache
+ ///
+ /// Key of element to remove from the cache
+ public void Remove(string key)
+ {
+ lock (_mutex)
+ {
+ _cache.Remove(key);
+ }
+ }
+
///
/// Wrapping class around a generic value stored in the cache
///
From 34c736c0ee7d114d02f75261f2ee20aa44113692 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Thu, 25 Sep 2025 23:41:34 +0600
Subject: [PATCH 15/18] [FSSDK-11150] cmab client implementation (#392)
---
.../OptimizelySDK.NetStandard20.csproj | 15 ++
.../CmabTests/DefaultCmabClientTest.cs | 186 +++++++++++++++
.../OptimizelySDK.Tests.csproj | 1 +
OptimizelySDK/Cmab/CmabConstants.cs | 32 +++
OptimizelySDK/Cmab/CmabModels.cs | 41 ++++
OptimizelySDK/Cmab/CmabRetryConfig.cs | 43 ++++
OptimizelySDK/Cmab/DefaultCmabClient.cs | 216 ++++++++++++++++++
OptimizelySDK/Cmab/ICmabClient.cs | 41 ++++
.../Exceptions/OptimizelyException.cs | 27 +++
OptimizelySDK/OptimizelySDK.csproj | 5 +
10 files changed, 607 insertions(+)
create mode 100644 OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs
create mode 100644 OptimizelySDK/Cmab/CmabConstants.cs
create mode 100644 OptimizelySDK/Cmab/CmabModels.cs
create mode 100644 OptimizelySDK/Cmab/CmabRetryConfig.cs
create mode 100644 OptimizelySDK/Cmab/DefaultCmabClient.cs
create mode 100644 OptimizelySDK/Cmab/ICmabClient.cs
diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index 418f606d..2ba52d48 100644
--- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
+++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
@@ -181,6 +181,21 @@
Entity\Cmab.cs
+
+ Cmab\ICmabClient.cs
+
+
+ Cmab\DefaultCmabClient.cs
+
+
+ Cmab\CmabRetryConfig.cs
+
+
+ Cmab\CmabModels.cs
+
+
+ Cmab\CmabConstants.cs
+
Entity\Holdout.cs
diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs
new file mode 100644
index 00000000..87a80e33
--- /dev/null
+++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs
@@ -0,0 +1,186 @@
+/*
+* Copyright 2025, Optimizely
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Moq;
+using Moq.Protected;
+using NUnit.Framework;
+using OptimizelySDK.Cmab;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Exceptions;
+using OptimizelySDK.Logger;
+
+namespace OptimizelySDK.Tests.CmabTests
+{
+ [TestFixture]
+ public class DefaultCmabClientTest
+ {
+ private class ResponseStep
+ {
+ public HttpStatusCode Status { get; private set; }
+ public string Body { get; private set; }
+ public ResponseStep(HttpStatusCode status, string body)
+ {
+ Status = status;
+ Body = body;
+ }
+ }
+
+ private static HttpClient MakeClient(params ResponseStep[] sequence)
+ {
+ var handler = new Mock(MockBehavior.Strict);
+ var queue = new Queue(sequence);
+
+ handler.Protected().Setup>("SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny()).Returns((HttpRequestMessage _, CancellationToken __) =>
+ {
+ if (queue.Count == 0)
+ throw new InvalidOperationException("No more mocked responses available.");
+
+ var step = queue.Dequeue();
+ var response = new HttpResponseMessage(step.Status);
+ if (step.Body != null)
+ {
+ response.Content = new StringContent(step.Body);
+ }
+ return Task.FromResult(response);
+ });
+
+ return new HttpClient(handler.Object);
+ }
+
+ private static HttpClient MakeClientExceptionSequence(params Exception[] sequence)
+ {
+ var handler = new Mock(MockBehavior.Strict);
+ var queue = new Queue(sequence);
+
+ handler.Protected().Setup>("SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny()).Returns((HttpRequestMessage _, CancellationToken __) =>
+ {
+ if (queue.Count == 0)
+ throw new InvalidOperationException("No more mocked exceptions available.");
+
+ var ex = queue.Dequeue();
+ var tcs = new TaskCompletionSource();
+ tcs.SetException(ex);
+ return tcs.Task;
+ });
+
+ return new HttpClient(handler.Object);
+ }
+
+ private static string ValidBody(string variationId = "v1")
+ => $"{{\"predictions\":[{{\"variation_id\":\"{variationId}\"}}]}}";
+
+ [Test]
+ public void FetchDecisionReturnsSuccessNoRetry()
+ {
+ var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v1")));
+ var client = new DefaultCmabClient(http, retryConfig: null, logger: new NoOpLogger(), errorHandler: new NoOpErrorHandler());
+ var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");
+
+ Assert.AreEqual("v1", result);
+ }
+
+ [Test]
+ public void FetchDecisionHttpExceptionNoRetry()
+ {
+ var http = MakeClientExceptionSequence(new HttpRequestException("boom"));
+ var client = new DefaultCmabClient(http, retryConfig: null);
+
+ Assert.Throws(() =>
+ client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
+ }
+
+ [Test]
+ public void FetchDecisionNon2xxNoRetry()
+ {
+ var http = MakeClient(new ResponseStep(HttpStatusCode.InternalServerError, null));
+ var client = new DefaultCmabClient(http, retryConfig: null);
+
+ Assert.Throws(() =>
+ client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
+ }
+
+ [Test]
+ public void FetchDecisionInvalidJsonNoRetry()
+ {
+ var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "not json"));
+ var client = new DefaultCmabClient(http, retryConfig: null);
+
+ Assert.Throws(() =>
+ client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
+ }
+
+ [Test]
+ public void FetchDecisionInvalidStructureNoRetry()
+ {
+ var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "{\"predictions\":[]}"));
+ var client = new DefaultCmabClient(http, retryConfig: null);
+
+ Assert.Throws(() =>
+ client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
+ }
+
+ [Test]
+ public void FetchDecisionSuccessWithRetryFirstTry()
+ {
+ var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v2")));
+ var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
+ var client = new DefaultCmabClient(http, retry);
+ var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");
+
+ Assert.AreEqual("v2", result);
+ }
+
+ [Test]
+ public void FetchDecisionSuccessWithRetryThirdTry()
+ {
+ var http = MakeClient(
+ new ResponseStep(HttpStatusCode.InternalServerError, null),
+ new ResponseStep(HttpStatusCode.InternalServerError, null),
+ new ResponseStep(HttpStatusCode.OK, ValidBody("v3"))
+ );
+ var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
+ var client = new DefaultCmabClient(http, retry);
+ var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");
+
+ Assert.AreEqual("v3", result);
+ }
+
+ [Test]
+ public void FetchDecisionExhaustsAllRetries()
+ {
+ var http = MakeClient(
+ new ResponseStep(HttpStatusCode.InternalServerError, null),
+ new ResponseStep(HttpStatusCode.InternalServerError, null),
+ new ResponseStep(HttpStatusCode.InternalServerError, null)
+ );
+ var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
+ var client = new DefaultCmabClient(http, retry);
+
+ Assert.Throws(() =>
+ client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
+ }
+ }
+}
diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
index 026dd5b8..01469f77 100644
--- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
+++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
@@ -70,6 +70,7 @@
+
diff --git a/OptimizelySDK/Cmab/CmabConstants.cs b/OptimizelySDK/Cmab/CmabConstants.cs
new file mode 100644
index 00000000..8c3659a1
--- /dev/null
+++ b/OptimizelySDK/Cmab/CmabConstants.cs
@@ -0,0 +1,32 @@
+/*
+* Copyright 2025, Optimizely
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+using System;
+
+namespace OptimizelySDK.Cmab
+{
+ internal static class CmabConstants
+ {
+ public const string PredictionUrl = "https://prediction.cmab.optimizely.com/predict";
+ public static readonly TimeSpan MaxTimeout = TimeSpan.FromSeconds(10);
+
+ public const string ContentTypeJson = "application/json";
+
+ public const string ErrorFetchFailedFmt = "CMAB decision fetch failed with status: {0}";
+ public const string ErrorInvalidResponse = "Invalid CMAB fetch response";
+ public const string ExhaustRetryMessage = "Exhausted all retries for CMAB request";
+ }
+}
diff --git a/OptimizelySDK/Cmab/CmabModels.cs b/OptimizelySDK/Cmab/CmabModels.cs
new file mode 100644
index 00000000..3a992458
--- /dev/null
+++ b/OptimizelySDK/Cmab/CmabModels.cs
@@ -0,0 +1,41 @@
+/*
+* Copyright 2025, Optimizely
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace OptimizelySDK.Cmab
+{
+ internal class CmabAttribute
+ {
+ [JsonProperty("id")] public string Id { get; set; }
+ [JsonProperty("value")] public object Value { get; set; }
+ [JsonProperty("type")] public string Type { get; set; } = "custom_attribute";
+ }
+
+ internal class CmabInstance
+ {
+ [JsonProperty("visitorId")] public string VisitorId { get; set; }
+ [JsonProperty("experimentId")] public string ExperimentId { get; set; }
+ [JsonProperty("attributes")] public List Attributes { get; set; }
+ [JsonProperty("cmabUUID")] public string CmabUUID { get; set; }
+ }
+
+ internal class CmabRequest
+ {
+ [JsonProperty("instances")] public List Instances { get; set; }
+ }
+}
diff --git a/OptimizelySDK/Cmab/CmabRetryConfig.cs b/OptimizelySDK/Cmab/CmabRetryConfig.cs
new file mode 100644
index 00000000..d89d78c6
--- /dev/null
+++ b/OptimizelySDK/Cmab/CmabRetryConfig.cs
@@ -0,0 +1,43 @@
+/*
+* Copyright 2025, Optimizely
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+using System;
+
+namespace OptimizelySDK.Cmab
+{
+ ///
+ /// Configuration for retrying CMAB requests (exponential backoff).
+ ///
+ public class CmabRetryConfig
+ {
+ public int MaxRetries { get; }
+ public TimeSpan InitialBackoff { get; }
+ public TimeSpan MaxBackoff { get; }
+ public double BackoffMultiplier { get; }
+
+ public CmabRetryConfig(
+ int maxRetries = 3,
+ TimeSpan? initialBackoff = null,
+ TimeSpan? maxBackoff = null,
+ double backoffMultiplier = 2.0)
+ {
+ MaxRetries = maxRetries;
+ InitialBackoff = initialBackoff ?? TimeSpan.FromMilliseconds(100);
+ MaxBackoff = maxBackoff ?? TimeSpan.FromSeconds(10);
+ BackoffMultiplier = backoffMultiplier;
+ }
+ }
+}
diff --git a/OptimizelySDK/Cmab/DefaultCmabClient.cs b/OptimizelySDK/Cmab/DefaultCmabClient.cs
new file mode 100644
index 00000000..3faaec75
--- /dev/null
+++ b/OptimizelySDK/Cmab/DefaultCmabClient.cs
@@ -0,0 +1,216 @@
+/*
+* Copyright 2025, Optimizely
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Exceptions;
+using OptimizelySDK.Logger;
+
+namespace OptimizelySDK.Cmab
+{
+ ///
+ /// Default client for interacting with the CMAB service via HttpClient.
+ ///
+ public class DefaultCmabClient : ICmabClient
+ {
+ private readonly HttpClient _httpClient;
+ private readonly CmabRetryConfig _retryConfig;
+ private readonly ILogger _logger;
+ private readonly IErrorHandler _errorHandler;
+
+ public DefaultCmabClient(
+ HttpClient httpClient = null,
+ CmabRetryConfig retryConfig = null,
+ ILogger logger = null,
+ IErrorHandler errorHandler = null)
+ {
+ _httpClient = httpClient ?? new HttpClient();
+ _retryConfig = retryConfig;
+ _logger = logger ?? new NoOpLogger();
+ _errorHandler = errorHandler ?? new NoOpErrorHandler();
+ }
+
+ private async Task FetchDecisionAsync(
+ string ruleId,
+ string userId,
+ IDictionary attributes,
+ string cmabUuid,
+ TimeSpan? timeout = null)
+ {
+ var url = $"{CmabConstants.PredictionUrl}/{ruleId}";
+ var body = BuildRequestBody(ruleId, userId, attributes, cmabUuid);
+ var perAttemptTimeout = timeout ?? CmabConstants.MaxTimeout;
+
+ if (_retryConfig == null)
+ {
+ return await DoFetchOnceAsync(url, body, perAttemptTimeout).ConfigureAwait(false);
+ }
+ return await DoFetchWithRetryAsync(url, body, perAttemptTimeout).ConfigureAwait(false);
+ }
+
+ public string FetchDecision(
+ string ruleId,
+ string userId,
+ IDictionary attributes,
+ string cmabUuid,
+ TimeSpan? timeout = null)
+ {
+ try
+ {
+ return FetchDecisionAsync(ruleId, userId, attributes, cmabUuid, timeout).ConfigureAwait(false).GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ _errorHandler.HandleError(ex);
+ throw;
+ }
+ }
+
+ private static StringContent BuildContent(object payload)
+ {
+ var json = JsonConvert.SerializeObject(payload);
+ return new StringContent(json, Encoding.UTF8, CmabConstants.ContentTypeJson);
+ }
+
+ private static CmabRequest BuildRequestBody(string ruleId, string userId, IDictionary attributes, string cmabUuid)
+ {
+ var attrList = new List();
+
+ if (attributes != null)
+ {
+ attrList = attributes.Select(kv => new CmabAttribute { Id = kv.Key, Value = kv.Value }).ToList();
+ }
+
+ return new CmabRequest
+ {
+ Instances = new List
+ {
+ new CmabInstance
+ {
+ VisitorId = userId,
+ ExperimentId = ruleId,
+ Attributes = attrList,
+ CmabUUID = cmabUuid,
+ }
+ }
+ };
+ }
+
+ private async Task DoFetchOnceAsync(string url, CmabRequest request, TimeSpan timeout)
+ {
+ using (var cts = new CancellationTokenSource(timeout))
+ {
+ try
+ {
+ var httpRequest = new HttpRequestMessage
+ {
+ RequestUri = new Uri(url),
+ Method = HttpMethod.Post,
+ Content = BuildContent(request),
+ };
+
+ var response = await _httpClient.SendAsync(httpRequest, cts.Token).ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var status = (int)response.StatusCode;
+ _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, status));
+ throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, status));
+ }
+
+ var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ var j = JObject.Parse(responseText);
+ if (!ValidateResponse(j))
+ {
+ _logger.Log(LogLevel.ERROR, CmabConstants.ErrorInvalidResponse);
+ throw new CmabInvalidResponseException(CmabConstants.ErrorInvalidResponse);
+ }
+
+ var variationIdToken = j["predictions"][0]["variation_id"];
+ return variationIdToken?.ToString();
+ }
+ catch (JsonException ex)
+ {
+ _logger.Log(LogLevel.ERROR, CmabConstants.ErrorInvalidResponse);
+ throw new CmabInvalidResponseException(ex.Message);
+ }
+ catch (CmabInvalidResponseException)
+ {
+ throw;
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message));
+ throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message));
+ }
+ catch (Exception ex)
+ {
+ _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message));
+ throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message));
+ }
+ }
+ }
+
+ private async Task DoFetchWithRetryAsync(string url, CmabRequest request, TimeSpan timeout)
+ {
+ var backoff = _retryConfig.InitialBackoff;
+ var attempt = 0;
+ while (true)
+ {
+ try
+ {
+ return await DoFetchOnceAsync(url, request, timeout).ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ if (attempt >= _retryConfig.MaxRetries)
+ {
+ _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, CmabConstants.ExhaustRetryMessage));
+ throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, CmabConstants.ExhaustRetryMessage));
+ }
+
+ _logger.Log(LogLevel.INFO, $"Retrying CMAB request (attempt: {attempt + 1}) after {backoff.TotalSeconds} seconds...");
+ await Task.Delay(backoff).ConfigureAwait(false);
+ var nextMs = Math.Min(_retryConfig.MaxBackoff.TotalMilliseconds, backoff.TotalMilliseconds * _retryConfig.BackoffMultiplier);
+ backoff = TimeSpan.FromMilliseconds(nextMs);
+ attempt++;
+ }
+ }
+ }
+
+ private static bool ValidateResponse(JObject body)
+ {
+ if (body == null) return false;
+
+ var preds = body["predictions"] as JArray;
+ if (preds == null || preds.Count == 0) return false;
+
+ var first = preds[0] as JObject;
+ if (first == null) return false;
+
+ return first["variation_id"] != null;
+ }
+ }
+}
diff --git a/OptimizelySDK/Cmab/ICmabClient.cs b/OptimizelySDK/Cmab/ICmabClient.cs
new file mode 100644
index 00000000..d80aec61
--- /dev/null
+++ b/OptimizelySDK/Cmab/ICmabClient.cs
@@ -0,0 +1,41 @@
+/*
+* Copyright 2025, Optimizely
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace OptimizelySDK.Cmab
+{
+ ///
+ /// Interface for CMAB client that fetches decisions from the prediction service.
+ ///
+ public interface ICmabClient
+ {
+ ///
+ /// Fetch a decision (variation id) from CMAB prediction service.
+ /// Throws on failure (network/non-2xx/invalid response/exhausted retries).
+ ///
+ /// Variation ID as string.
+ string FetchDecision(
+ string ruleId,
+ string userId,
+ IDictionary attributes,
+ string cmabUuid,
+ TimeSpan? timeout = null);
+ }
+}
diff --git a/OptimizelySDK/Exceptions/OptimizelyException.cs b/OptimizelySDK/Exceptions/OptimizelyException.cs
index ba150b2d..2d6ec0d8 100644
--- a/OptimizelySDK/Exceptions/OptimizelyException.cs
+++ b/OptimizelySDK/Exceptions/OptimizelyException.cs
@@ -85,6 +85,33 @@ public InvalidFeatureException(string message)
: base(message) { }
}
+ ///
+ /// Base exception for CMAB client errors.
+ ///
+ public class CmabException : OptimizelyException
+ {
+ public CmabException(string message)
+ : base(message) { }
+ }
+
+ ///
+ /// Exception thrown when CMAB decision fetch fails (network/non-2xx/exhausted retries).
+ ///
+ public class CmabFetchException : CmabException
+ {
+ public CmabFetchException(string message)
+ : base(message) { }
+ }
+
+ ///
+ /// Exception thrown when CMAB response is invalid or cannot be parsed.
+ ///
+ public class CmabInvalidResponseException : CmabException
+ {
+ public CmabInvalidResponseException(string message)
+ : base(message) { }
+ }
+
public class InvalidRolloutException : OptimizelyException
{
public InvalidRolloutException(string message)
diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj
index b4537b92..ccd53f42 100644
--- a/OptimizelySDK/OptimizelySDK.csproj
+++ b/OptimizelySDK/OptimizelySDK.csproj
@@ -204,6 +204,11 @@
+
+
+
+
+
From 375e6a26eaf16e678465274f39d9a4ed741d39d0 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Fri, 10 Oct 2025 21:14:03 +0600
Subject: [PATCH 16/18] [FSSDK-11168] feat: add cmab service (#393)
---
.../OptimizelySDK.NetStandard20.csproj | 6 +
.../CmabTests/DefaultCmabServiceTest.cs | 388 ++++++++++++++++++
.../OptimizelySDK.Tests.csproj | 1 +
OptimizelySDK/Cmab/DefaultCmabService.cs | 261 ++++++++++++
OptimizelySDK/Cmab/ICmabService.cs | 31 ++
.../OptimizelyDecideOption.cs | 3 +
OptimizelySDK/OptimizelySDK.csproj | 2 +
7 files changed, 692 insertions(+)
create mode 100644 OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs
create mode 100644 OptimizelySDK/Cmab/DefaultCmabService.cs
create mode 100644 OptimizelySDK/Cmab/ICmabService.cs
diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index 2ba52d48..f73e809c 100644
--- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
+++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
@@ -187,6 +187,12 @@
Cmab\DefaultCmabClient.cs
+
+ Cmab\ICmabService.cs
+
+
+ Cmab\DefaultCmabService.cs
+
Cmab\CmabRetryConfig.cs
diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs
new file mode 100644
index 00000000..9dac9699
--- /dev/null
+++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs
@@ -0,0 +1,388 @@
+/*
+* Copyright 2025, Optimizely
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+using System;
+using System.Collections.Generic;
+using Moq;
+using NUnit.Framework;
+using OptimizelySDK.Cmab;
+using OptimizelySDK.Config;
+using OptimizelySDK.Entity;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Logger;
+using OptimizelySDK.Odp;
+using OptimizelySDK.OptimizelyDecisions;
+using AttributeEntity = OptimizelySDK.Entity.Attribute;
+
+namespace OptimizelySDK.Tests.CmabTests
+{
+ [TestFixture]
+ public class DefaultCmabServiceTest
+ {
+ private Mock _mockCmabClient;
+ private LruCache _cmabCache;
+ private DefaultCmabService _cmabService;
+ private ILogger _logger;
+ private ProjectConfig _config;
+ private Optimizely _optimizely;
+
+ private const string TEST_RULE_ID = "exp1";
+ private const string TEST_USER_ID = "user123";
+ private const string AGE_ATTRIBUTE_ID = "66";
+ private const string LOCATION_ATTRIBUTE_ID = "77";
+
+ [SetUp]
+ public void SetUp()
+ {
+ _mockCmabClient = new Mock(MockBehavior.Strict);
+ _logger = new NoOpLogger();
+ _cmabCache = new LruCache(maxSize: 10, itemTimeout: TimeSpan.FromMinutes(5), logger: _logger);
+ _cmabService = new DefaultCmabService(_cmabCache, _mockCmabClient.Object, _logger);
+
+ _config = DatafileProjectConfig.Create(TestData.Datafile, _logger, new NoOpErrorHandler());
+ _optimizely = new Optimizely(TestData.Datafile, null, _logger, new NoOpErrorHandler());
+ }
+
+ [Test]
+ public void ReturnsDecisionFromCacheWhenHashMatches()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+ var filteredAttributes = new UserAttributes(new Dictionary { { "age", 25 } });
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+
+ _cmabCache.Save(cacheKey, new CmabCacheEntry
+ {
+ AttributesHash = DefaultCmabService.HashAttributes(filteredAttributes),
+ CmabUuid = "uuid-cached",
+ VariationId = "varA"
+ });
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null);
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varA", decision.VariationId);
+ Assert.AreEqual("uuid-cached", decision.CmabUuid);
+ _mockCmabClient.Verify(c => c.FetchDecision(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public void IgnoresCacheWhenOptionSpecified()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs != null && attrs.Count == 1 && attrs.ContainsKey("age") && (int)attrs["age"] == 25),
+ It.IsAny(),
+ It.IsAny())).Returns("varB");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID,
+ new[] { OptimizelyDecideOption.IGNORE_CMAB_CACHE });
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varB", decision.VariationId);
+ Assert.IsNull(_cmabCache.Lookup(cacheKey));
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void ResetsCacheWhenOptionSpecified()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+
+ _cmabCache.Save(cacheKey, new CmabCacheEntry
+ {
+ AttributesHash = "stale",
+ CmabUuid = "uuid-old",
+ VariationId = "varOld"
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25),
+ It.IsAny(),
+ It.IsAny())).Returns("varNew");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID,
+ new[] { OptimizelyDecideOption.RESET_CMAB_CACHE });
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varNew", decision.VariationId);
+ var cachedEntry = _cmabCache.Lookup(cacheKey);
+ Assert.IsNotNull(cachedEntry);
+ Assert.AreEqual("varNew", cachedEntry.VariationId);
+ Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void InvalidatesUserEntryWhenOptionSpecified()
+ {
+ var otherUserId = "other";
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+
+ var targetKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+ var otherKey = DefaultCmabService.GetCacheKey(otherUserId, TEST_RULE_ID);
+
+ _cmabCache.Save(targetKey, new CmabCacheEntry
+ {
+ AttributesHash = "old_hash",
+ CmabUuid = "uuid-old",
+ VariationId = "varOld"
+ });
+ _cmabCache.Save(otherKey, new CmabCacheEntry
+ {
+ AttributesHash = "other_hash",
+ CmabUuid = "uuid-other",
+ VariationId = "varOther"
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25),
+ It.IsAny(),
+ It.IsAny())).Returns("varNew");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID,
+ new[] { OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE });
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varNew", decision.VariationId);
+ var updatedEntry = _cmabCache.Lookup(targetKey);
+ Assert.IsNotNull(updatedEntry);
+ Assert.AreEqual(decision.CmabUuid, updatedEntry.CmabUuid);
+ Assert.AreEqual("varNew", updatedEntry.VariationId);
+
+ var otherEntry = _cmabCache.Lookup(otherKey);
+ Assert.IsNotNull(otherEntry);
+ Assert.AreEqual("varOther", otherEntry.VariationId);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void FetchesNewDecisionWhenHashDiffers()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+ _cmabCache.Save(cacheKey, new CmabCacheEntry
+ {
+ AttributesHash = "different_hash",
+ CmabUuid = "uuid-old",
+ VariationId = "varOld"
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25),
+ It.IsAny(),
+ It.IsAny())).Returns("varUpdated");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null);
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varUpdated", decision.VariationId);
+ var cachedEntry = _cmabCache.Lookup(cacheKey);
+ Assert.IsNotNull(cachedEntry);
+ Assert.AreEqual("varUpdated", cachedEntry.VariationId);
+ Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void FiltersAttributesBeforeCallingClient()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } },
+ { LOCATION_ATTRIBUTE_ID, new AttributeEntity { Id = LOCATION_ATTRIBUTE_ID, Key = "location" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary
+ {
+ { "age", 25 },
+ { "location", "USA" },
+ { "extra", "value" }
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs.Count == 2 &&
+ (int)attrs["age"] == 25 &&
+ (string)attrs["location"] == "USA" &&
+ !attrs.ContainsKey("extra")),
+ It.IsAny(),
+ It.IsAny())).Returns("varFiltered");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null);
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varFiltered", decision.VariationId);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void HandlesMissingCmabConfiguration()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, null);
+ var attributeMap = new Dictionary();
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs.Count == 0),
+ It.IsAny(),
+ It.IsAny())).Returns("varDefault");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null);
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varDefault", decision.VariationId);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void AttributeHashIsStableRegardlessOfOrder()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "a" } },
+ { LOCATION_ATTRIBUTE_ID, new AttributeEntity { Id = LOCATION_ATTRIBUTE_ID, Key = "b" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+
+ var firstContext = CreateUserContext(TEST_USER_ID, new Dictionary
+ {
+ { "b", 2 },
+ { "a", 1 }
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).Returns("varStable");
+
+ var firstDecision = _cmabService.GetDecision(projectConfig, firstContext, TEST_RULE_ID, null);
+ Assert.IsNotNull(firstDecision);
+ Assert.AreEqual("varStable", firstDecision.VariationId);
+
+ var secondContext = CreateUserContext(TEST_USER_ID, new Dictionary
+ {
+ { "a", 1 },
+ { "b", 2 }
+ });
+
+ var secondDecision = _cmabService.GetDecision(projectConfig, secondContext, TEST_RULE_ID, null);
+
+ Assert.IsNotNull(secondDecision);
+ Assert.AreEqual("varStable", secondDecision.VariationId);
+ _mockCmabClient.Verify(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public void UsesExpectedCacheKeyFormat()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).Returns("varKey");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null);
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varKey", decision.VariationId);
+
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+ var cachedEntry = _cmabCache.Lookup(cacheKey);
+ Assert.IsNotNull(cachedEntry);
+ Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid);
+ }
+
+ private OptimizelyUserContext CreateUserContext(string userId, IDictionary attributes)
+ {
+ var userContext = _optimizely.CreateUserContext(userId);
+
+ foreach (var attr in attributes)
+ {
+ userContext.SetAttribute(attr.Key, attr.Value);
+ }
+
+ return userContext;
+ }
+
+ private static ProjectConfig CreateProjectConfig(string ruleId, Experiment experiment,
+ Dictionary attributeMap)
+ {
+ var mockConfig = new Mock();
+ var experimentMap = new Dictionary();
+ if (experiment != null)
+ {
+ experimentMap[ruleId] = experiment;
+ }
+
+ mockConfig.SetupGet(c => c.ExperimentIdMap).Returns(experimentMap);
+ mockConfig.SetupGet(c => c.AttributeIdMap).Returns(attributeMap ?? new Dictionary());
+ return mockConfig.Object;
+ }
+
+ private static Experiment CreateExperiment(string ruleId, List attributeIds)
+ {
+ return new Experiment
+ {
+ Id = ruleId,
+ Cmab = attributeIds == null ? null : new Entity.Cmab(attributeIds)
+ };
+ }
+
+ }
+}
diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
index 01469f77..1b0b882e 100644
--- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
+++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
@@ -71,6 +71,7 @@
+
diff --git a/OptimizelySDK/Cmab/DefaultCmabService.cs b/OptimizelySDK/Cmab/DefaultCmabService.cs
new file mode 100644
index 00000000..2cdf18c3
--- /dev/null
+++ b/OptimizelySDK/Cmab/DefaultCmabService.cs
@@ -0,0 +1,261 @@
+/*
+* Copyright 2025, Optimizely
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using Newtonsoft.Json;
+using OptimizelySDK;
+using OptimizelySDK.Entity;
+using OptimizelySDK.Logger;
+using OptimizelySDK.Odp;
+using OptimizelySDK.OptimizelyDecisions;
+using AttributeEntity = OptimizelySDK.Entity.Attribute;
+
+namespace OptimizelySDK.Cmab
+{
+ ///
+ /// Represents a CMAB decision response returned by the service.
+ ///
+ public class CmabDecision
+ {
+ ///
+ /// Initializes a new instance of the CmabDecision class.
+ ///
+ /// The variation ID assigned by the CMAB service.
+ /// The unique identifier for this CMAB decision.
+ public CmabDecision(string variationId, string cmabUuid)
+ {
+ VariationId = variationId;
+ CmabUuid = cmabUuid;
+ }
+
+ ///
+ /// Gets the variation ID assigned by the CMAB service.
+ ///
+ public string VariationId { get; }
+
+ ///
+ /// Gets the unique identifier for this CMAB decision.
+ ///
+ public string CmabUuid { get; }
+ }
+
+ ///
+ /// Represents a cached CMAB decision entry.
+ ///
+ public class CmabCacheEntry
+ {
+ ///
+ /// Gets or sets the hash of the filtered attributes used for this decision.
+ ///
+ public string AttributesHash { get; set; }
+
+ ///
+ /// Gets or sets the variation ID from the cached decision.
+ ///
+ public string VariationId { get; set; }
+
+ ///
+ /// Gets or sets the CMAB UUID from the cached decision.
+ ///
+ public string CmabUuid { get; set; }
+ }
+
+ ///
+ /// Default implementation of the CMAB decision service that handles caching and filtering.
+ /// Provides methods for retrieving CMAB decisions with intelligent caching based on user attributes.
+ ///
+ public class DefaultCmabService : ICmabService
+ {
+ private readonly LruCache _cmabCache;
+ private readonly ICmabClient _cmabClient;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the DefaultCmabService class.
+ ///
+ /// LRU cache for storing CMAB decisions.
+ /// Client for fetching decisions from the CMAB prediction service.
+ /// Optional logger for recording service operations.
+ public DefaultCmabService(LruCache cmabCache,
+ ICmabClient cmabClient,
+ ILogger logger = null)
+ {
+ _cmabCache = cmabCache;
+ _cmabClient = cmabClient;
+ _logger = logger ?? new NoOpLogger();
+ }
+
+ public CmabDecision GetDecision(ProjectConfig projectConfig,
+ OptimizelyUserContext userContext,
+ string ruleId,
+ OptimizelyDecideOption[] options = null)
+ {
+ var optionSet = options ?? new OptimizelyDecideOption[0];
+ var filteredAttributes = FilterAttributes(projectConfig, userContext, ruleId);
+
+ if (optionSet.Contains(OptimizelyDecideOption.IGNORE_CMAB_CACHE))
+ {
+ _logger.Log(LogLevel.DEBUG, "Ignoring CMAB cache.");
+ return FetchDecision(ruleId, userContext.GetUserId(), filteredAttributes);
+ }
+
+ if (optionSet.Contains(OptimizelyDecideOption.RESET_CMAB_CACHE))
+ {
+ _logger.Log(LogLevel.DEBUG, "Resetting CMAB cache.");
+ _cmabCache.Reset();
+ }
+
+ var cacheKey = GetCacheKey(userContext.GetUserId(), ruleId);
+
+ if (optionSet.Contains(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE))
+ {
+ _logger.Log(LogLevel.DEBUG, "Invalidating user CMAB cache.");
+ _cmabCache.Remove(cacheKey);
+ }
+
+ var cachedValue = _cmabCache.Lookup(cacheKey);
+ var attributesHash = HashAttributes(filteredAttributes);
+
+ if (cachedValue != null)
+ {
+ if (string.Equals(cachedValue.AttributesHash, attributesHash, StringComparison.Ordinal))
+ {
+ return new CmabDecision(cachedValue.VariationId, cachedValue.CmabUuid);
+ }
+ else
+ {
+ _cmabCache.Remove(cacheKey);
+ }
+
+ }
+
+ var cmabDecision = FetchDecision(ruleId, userContext.GetUserId(), filteredAttributes);
+
+ _cmabCache.Save(cacheKey, new CmabCacheEntry
+ {
+ AttributesHash = attributesHash,
+ VariationId = cmabDecision.VariationId,
+ CmabUuid = cmabDecision.CmabUuid,
+ });
+
+ return cmabDecision;
+ }
+
+ ///
+ /// Fetches a new decision from the CMAB client and generates a unique UUID for tracking.
+ ///
+ /// The experiment/rule ID.
+ /// The user ID.
+ /// The filtered user attributes to send to the CMAB service.
+ /// A new CmabDecision with the assigned variation and generated UUID.
+ private CmabDecision FetchDecision(string ruleId,
+ string userId,
+ UserAttributes attributes)
+ {
+ var cmabUuid = Guid.NewGuid().ToString();
+ var variationId = _cmabClient.FetchDecision(ruleId, userId, attributes, cmabUuid);
+ return new CmabDecision(variationId, cmabUuid);
+ }
+
+ ///
+ /// Filters user attributes to include only those configured for the CMAB experiment.
+ ///
+ /// The project configuration containing attribute mappings.
+ /// The user context with all user attributes.
+ /// The experiment/rule ID to get CMAB attribute configuration for.
+ /// A UserAttributes object containing only the filtered attributes, or empty if no CMAB config exists.
+ ///
+ /// Only attributes specified in the experiment's CMAB configuration are included.
+ /// This ensures that cache invalidation is based only on relevant attributes.
+ ///
+ private UserAttributes FilterAttributes(ProjectConfig projectConfig,
+ OptimizelyUserContext userContext,
+ string ruleId)
+ {
+ var filtered = new UserAttributes();
+
+ if (projectConfig.ExperimentIdMap == null ||
+ !projectConfig.ExperimentIdMap.TryGetValue(ruleId, out var experiment) ||
+ experiment?.Cmab?.AttributeIds == null ||
+ experiment.Cmab.AttributeIds.Count == 0)
+ {
+ return filtered;
+ }
+
+ var userAttributes = userContext.GetAttributes() ?? new UserAttributes();
+ var attributeIdMap = projectConfig.AttributeIdMap ?? new Dictionary();
+
+ foreach (var attributeId in experiment.Cmab.AttributeIds)
+ {
+ if (attributeIdMap.TryGetValue(attributeId, out var attribute) &&
+ userAttributes.TryGetValue(attribute.Key, out var value))
+ {
+ filtered[attribute.Key] = value;
+ }
+ }
+
+ return filtered;
+ }
+
+ ///
+ /// Generates a cache key for storing and retrieving CMAB decisions.
+ ///
+ /// The user ID.
+ /// The experiment/rule ID.
+ /// A cache key string in the format: {userId.Length}-{userId}-{ruleId}
+ ///
+ /// The length prefix prevents key collisions between different user IDs that might appear
+ /// similar when concatenated (e.g., "12-abc-exp" vs "1-2abc-exp").
+ ///
+ internal static string GetCacheKey(string userId, string ruleId)
+ {
+ var normalizedUserId = userId ?? string.Empty;
+ return $"{normalizedUserId.Length}-{normalizedUserId}-{ruleId}";
+ }
+
+ ///
+ /// Computes an MD5 hash of the user attributes for cache validation.
+ ///
+ /// The user attributes to hash.
+ /// A hexadecimal MD5 hash string of the serialized attributes.
+ ///
+ /// Attributes are sorted by key before hashing to ensure consistent hashes regardless of
+ /// the order in which attributes are provided. This allows cache hits when the same attributes
+ /// are present in different orders.
+ ///
+ internal static string HashAttributes(UserAttributes attributes)
+ {
+ var ordered = attributes.OrderBy(kvp => kvp.Key).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+ var serialized = JsonConvert.SerializeObject(ordered);
+
+ using (var md5 = MD5.Create())
+ {
+ var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(serialized));
+ var builder = new StringBuilder(hashBytes.Length * 2);
+ foreach (var b in hashBytes)
+ {
+ builder.Append(b.ToString("x2"));
+ }
+
+ return builder.ToString();
+ }
+ }
+ }
+}
diff --git a/OptimizelySDK/Cmab/ICmabService.cs b/OptimizelySDK/Cmab/ICmabService.cs
new file mode 100644
index 00000000..3b909295
--- /dev/null
+++ b/OptimizelySDK/Cmab/ICmabService.cs
@@ -0,0 +1,31 @@
+/*
+* Copyright 2025, Optimizely
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+using OptimizelySDK.OptimizelyDecisions;
+
+namespace OptimizelySDK.Cmab
+{
+ ///
+ /// Contract for CMAB decision services.
+ ///
+ public interface ICmabService
+ {
+ CmabDecision GetDecision(ProjectConfig projectConfig,
+ OptimizelyUserContext userContext,
+ string ruleId,
+ OptimizelyDecideOption[] options);
+ }
+}
diff --git a/OptimizelySDK/OptimizelyDecisions/OptimizelyDecideOption.cs b/OptimizelySDK/OptimizelyDecisions/OptimizelyDecideOption.cs
index b0ec5307..1b7379ff 100644
--- a/OptimizelySDK/OptimizelyDecisions/OptimizelyDecideOption.cs
+++ b/OptimizelySDK/OptimizelyDecisions/OptimizelyDecideOption.cs
@@ -23,5 +23,8 @@ public enum OptimizelyDecideOption
IGNORE_USER_PROFILE_SERVICE,
INCLUDE_REASONS,
EXCLUDE_VARIABLES,
+ IGNORE_CMAB_CACHE,
+ RESET_CMAB_CACHE,
+ INVALIDATE_USER_CMAB_CACHE,
}
}
diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj
index ccd53f42..7091cf01 100644
--- a/OptimizelySDK/OptimizelySDK.csproj
+++ b/OptimizelySDK/OptimizelySDK.csproj
@@ -205,7 +205,9 @@
+
+
From 1f405cd02f1fb0de1ced56bc7dfd01fe33fa5016 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Wed, 5 Nov 2025 00:59:24 +0600
Subject: [PATCH 17/18] [FSSDK-11177] Decision Service CMAB + Optimizely Client
+ Impression event adjustment (#394)
---
.../OptimizelySDK.Net35.csproj | 3 +
.../OptimizelySDK.Net40.csproj | 3 +
.../OptimizelySDK.NetStandard16.csproj | 1 +
.../OptimizelySDK.NetStandard20.csproj | 9 +
.../BucketerBucketToEntityIdTest.cs | 286 ++++++++
.../CmabTests/DecisionServiceCmabTest.cs | 655 ++++++++++++++++++
.../CmabTests/DefaultCmabServiceTest.cs | 410 +++++++++--
.../CmabTests/ImpressionEventCmabTest.cs | 215 ++++++
.../OptimizelyUserContextCmabTest.cs | 578 ++++++++++++++++
.../DecisionServiceHoldoutTest.cs | 2 +-
OptimizelySDK.Tests/DecisionServiceTest.cs | 81 +--
OptimizelySDK.Tests/OptimizelyFactoryTest.cs | 92 +++
.../OptimizelySDK.Tests.csproj | 8 +-
OptimizelySDK.Tests/OptimizelyTest.cs | 68 +-
OptimizelySDK/Bucketing/Bucketer.cs | 77 +-
OptimizelySDK/Bucketing/DecisionService.cs | 178 ++++-
.../Bucketing/VariationDecisionResult.cs | 51 ++
OptimizelySDK/Cmab/CmabConfig.cs | 80 +++
OptimizelySDK/Cmab/CmabConstants.cs | 53 +-
OptimizelySDK/Cmab/DefaultCmabClient.cs | 28 +-
OptimizelySDK/Cmab/DefaultCmabService.cs | 55 +-
OptimizelySDK/Entity/Cmab.cs | 4 +-
OptimizelySDK/Entity/FeatureDecision.cs | 6 +-
.../Event/Entity/DecisionMetadata.cs | 6 +-
OptimizelySDK/Event/UserEventFactory.cs | 6 +-
OptimizelySDK/Odp/LruCache.cs | 2 +-
OptimizelySDK/Optimizely.cs | 106 ++-
.../OptimizelyDecisions/OptimizelyDecision.cs | 23 +
OptimizelySDK/OptimizelyFactory.cs | 35 +-
OptimizelySDK/OptimizelySDK.csproj | 3 +
OptimizelySDK/Utils/ICacheWithRemove.cs | 36 +
31 files changed, 2916 insertions(+), 244 deletions(-)
create mode 100644 OptimizelySDK.Tests/BucketerBucketToEntityIdTest.cs
create mode 100644 OptimizelySDK.Tests/CmabTests/DecisionServiceCmabTest.cs
create mode 100644 OptimizelySDK.Tests/CmabTests/ImpressionEventCmabTest.cs
create mode 100644 OptimizelySDK.Tests/CmabTests/OptimizelyUserContextCmabTest.cs
create mode 100644 OptimizelySDK/Bucketing/VariationDecisionResult.cs
create mode 100644 OptimizelySDK/Cmab/CmabConfig.cs
create mode 100644 OptimizelySDK/Utils/ICacheWithRemove.cs
diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
index e441df53..4c3145c2 100644
--- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
+++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
@@ -230,6 +230,9 @@
Bucketing\UserProfileUtil
+
+ Bucketing\VariationDecisionResult.cs
+
Entity\FeatureVariableUsage
diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
index c1150280..6f2b3f23 100644
--- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
+++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
@@ -229,6 +229,9 @@
Bucketing\UserProfileUtil
+
+ Bucketing\VariationDecisionResult.cs
+
Entity\FeatureVariableUsage
diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
index 1490ba14..c1ba6d73 100644
--- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
+++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
@@ -79,6 +79,7 @@
+
diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index f73e809c..e41c7fd7 100644
--- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
+++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
@@ -142,6 +142,9 @@
Bucketing\UserProfileUtil.cs
+
+ Bucketing\VariationDecisionResult.cs
+
Config\DatafileProjectConfig.cs
@@ -196,6 +199,9 @@
Cmab\CmabRetryConfig.cs
+
+ Cmab\CmabConfig.cs
+
Cmab\CmabModels.cs
@@ -369,6 +375,9 @@
Utils\Validator.cs
+
+ Utils\ICacheWithRemove.cs
+
Event\BatchEventProcessor.cs
diff --git a/OptimizelySDK.Tests/BucketerBucketToEntityIdTest.cs b/OptimizelySDK.Tests/BucketerBucketToEntityIdTest.cs
new file mode 100644
index 00000000..837b4dcb
--- /dev/null
+++ b/OptimizelySDK.Tests/BucketerBucketToEntityIdTest.cs
@@ -0,0 +1,286 @@
+/*
+ * Copyright 2025, Optimizely
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System.Collections.Generic;
+using Moq;
+using Newtonsoft.Json;
+using NUnit.Framework;
+using OptimizelySDK.Bucketing;
+using OptimizelySDK.Config;
+using OptimizelySDK.Entity;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Logger;
+
+namespace OptimizelySDK.Tests
+{
+ [TestFixture]
+ public class BucketerBucketToEntityIdTest
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ _loggerMock = new Mock();
+ }
+
+ private const string ExperimentId = "bucket_entity_exp";
+ private const string ExperimentKey = "bucket_entity_experiment";
+ private const string GroupId = "group_1";
+
+ private Mock _loggerMock;
+
+ [Test]
+ public void BucketToEntityIdAllowsBucketingWhenNoGroup()
+ {
+ var config = CreateConfig(new ConfigSetup { IncludeGroup = false });
+ var experiment = config.GetExperimentFromKey(ExperimentKey);
+ var bucketer = new Bucketer(_loggerMock.Object);
+
+ var fullAllocation = CreateTrafficAllocations(new TrafficAllocation
+ {
+ EntityId = "entity_123",
+ EndOfRange = 10000,
+ });
+ var fullResult = bucketer.BucketToEntityId(config, experiment, "bucketing_id", "user",
+ fullAllocation);
+ Assert.IsNotNull(fullResult.ResultObject);
+ Assert.AreEqual("entity_123", fullResult.ResultObject);
+
+ var zeroAllocation = CreateTrafficAllocations(new TrafficAllocation
+ {
+ EntityId = "entity_123",
+ EndOfRange = 0,
+ });
+ var zeroResult = bucketer.BucketToEntityId(config, experiment, "bucketing_id", "user",
+ zeroAllocation);
+ Assert.IsNull(zeroResult.ResultObject);
+ }
+
+ [Test]
+ public void BucketToEntityIdReturnsEntityIdWhenGroupAllowsUser()
+ {
+ var config = CreateConfig(new ConfigSetup
+ {
+ IncludeGroup = true,
+ GroupPolicy = "random",
+ GroupEndOfRange = 10000,
+ });
+
+ var experiment = config.GetExperimentFromKey(ExperimentKey);
+ var bucketer = new Bucketer(_loggerMock.Object);
+
+ var testCases = new[]
+ {
+ new { BucketingId = "ppid1", EntityId = "entity1" },
+ new { BucketingId = "ppid2", EntityId = "entity2" },
+ new { BucketingId = "ppid3", EntityId = "entity3" },
+ new
+ {
+ BucketingId =
+ "a very very very very very very very very very very very very very very very long ppd string",
+ EntityId = "entity4",
+ },
+ };
+
+ foreach (var testCase in testCases)
+ {
+ var allocation = CreateTrafficAllocations(new TrafficAllocation
+ {
+ EntityId = testCase.EntityId,
+ EndOfRange = 10000,
+ });
+ var result = bucketer.BucketToEntityId(config, experiment, testCase.BucketingId,
+ testCase.BucketingId, allocation);
+ Assert.AreEqual(testCase.EntityId, result.ResultObject,
+ $"Failed for {testCase.BucketingId}");
+ }
+ }
+
+ [Test]
+ public void BucketToEntityIdReturnsNullWhenGroupRejectsUser()
+ {
+ var config = CreateConfig(new ConfigSetup
+ {
+ IncludeGroup = true,
+ GroupPolicy = "random",
+ GroupEndOfRange = 0,
+ });
+
+ var experiment = config.GetExperimentFromKey(ExperimentKey);
+ var bucketer = new Bucketer(_loggerMock.Object);
+
+ var allocation = CreateTrafficAllocations(new TrafficAllocation
+ {
+ EntityId = "entity1",
+ EndOfRange = 10000,
+ });
+ var testCases = new[]
+ {
+ "ppid1",
+ "ppid2",
+ "ppid3",
+ "a very very very very very very very very very very very very very very very long ppd string",
+ };
+
+ foreach (var bucketingId in testCases)
+ {
+ var result = bucketer.BucketToEntityId(config, experiment, bucketingId, bucketingId,
+ allocation);
+ Assert.IsNull(result.ResultObject, $"Expected null for {bucketingId}");
+ }
+ }
+
+ [Test]
+ public void BucketToEntityIdAllowsBucketingWhenGroupOverlapping()
+ {
+ var config = CreateConfig(new ConfigSetup
+ {
+ IncludeGroup = true,
+ GroupPolicy = "overlapping",
+ GroupEndOfRange = 10000,
+ });
+
+ var experiment = config.GetExperimentFromKey(ExperimentKey);
+ var bucketer = new Bucketer(_loggerMock.Object);
+
+ var allocation = CreateTrafficAllocations(new TrafficAllocation
+ {
+ EntityId = "entity_overlapping",
+ EndOfRange = 10000,
+ });
+ var result =
+ bucketer.BucketToEntityId(config, experiment, "bucketing_id", "user", allocation);
+ Assert.AreEqual("entity_overlapping", result.ResultObject);
+ }
+
+ private static IList CreateTrafficAllocations(
+ params TrafficAllocation[] allocations
+ )
+ {
+ return new List(allocations);
+ }
+
+ private ProjectConfig CreateConfig(ConfigSetup setup)
+ {
+ if (setup == null)
+ {
+ setup = new ConfigSetup();
+ }
+
+ var datafile = BuildDatafile(setup);
+ return DatafileProjectConfig.Create(datafile, _loggerMock.Object,
+ new NoOpErrorHandler());
+ }
+
+ private static string BuildDatafile(ConfigSetup setup)
+ {
+ var variations = new object[]
+ {
+ new Dictionary
+ {
+ { "id", "var_1" },
+ { "key", "variation_1" },
+ { "variables", new object[0] },
+ },
+ };
+
+ var experiment = new Dictionary
+ {
+ { "status", "Running" },
+ { "key", ExperimentKey },
+ { "layerId", "layer_1" },
+ { "id", ExperimentId },
+ { "audienceIds", new string[0] },
+ { "audienceConditions", "[]" },
+ { "forcedVariations", new Dictionary() },
+ { "variations", variations },
+ {
+ "trafficAllocation", new object[]
+ {
+ new Dictionary
+ {
+ { "entityId", "var_1" },
+ { "endOfRange", 10000 },
+ },
+ }
+ },
+ };
+
+ object[] groups;
+ if (setup.IncludeGroup)
+ {
+ var groupExperiment = new Dictionary(experiment);
+ groupExperiment["trafficAllocation"] = new object[0];
+
+ groups = new object[]
+ {
+ new Dictionary
+ {
+ { "id", GroupId },
+ { "policy", setup.GroupPolicy },
+ {
+ "trafficAllocation", new object[]
+ {
+ new Dictionary
+ {
+ { "entityId", ExperimentId },
+ { "endOfRange", setup.GroupEndOfRange },
+ },
+ }
+ },
+ { "experiments", new object[] { groupExperiment } },
+ },
+ };
+ }
+ else
+ {
+ groups = new object[0];
+ }
+
+ var datafile = new Dictionary
+ {
+ { "version", "4" },
+ { "projectId", "project_1" },
+ { "accountId", "account_1" },
+ { "revision", "1" },
+ { "environmentKey", string.Empty },
+ { "sdkKey", string.Empty },
+ { "sendFlagDecisions", false },
+ { "anonymizeIP", false },
+ { "botFiltering", false },
+ { "attributes", new object[0] },
+ { "audiences", new object[0] },
+ { "typedAudiences", new object[0] },
+ { "events", new object[0] },
+ { "featureFlags", new object[0] },
+ { "rollouts", new object[0] },
+ { "integrations", new object[0] },
+ { "holdouts", new object[0] },
+ { "groups", groups },
+ { "experiments", new object[] { experiment } },
+ { "segments", new object[0] },
+ };
+
+ return JsonConvert.SerializeObject(datafile);
+ }
+
+ private class ConfigSetup
+ {
+ public bool IncludeGroup { get; set; }
+ public string GroupPolicy { get; set; }
+ public int GroupEndOfRange { get; set; }
+ }
+ }
+}
diff --git a/OptimizelySDK.Tests/CmabTests/DecisionServiceCmabTest.cs b/OptimizelySDK.Tests/CmabTests/DecisionServiceCmabTest.cs
new file mode 100644
index 00000000..a08af152
--- /dev/null
+++ b/OptimizelySDK.Tests/CmabTests/DecisionServiceCmabTest.cs
@@ -0,0 +1,655 @@
+/*
+* Copyright 2025, Optimizely
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+using System;
+using System.Collections.Generic;
+using Moq;
+using NUnit.Framework;
+using OptimizelySDK.Bucketing;
+using OptimizelySDK.Cmab;
+using OptimizelySDK.Config;
+using OptimizelySDK.Entity;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Logger;
+using OptimizelySDK.OptimizelyDecisions;
+using OptimizelySDK.Odp;
+using AttributeEntity = OptimizelySDK.Entity.Attribute;
+
+namespace OptimizelySDK.Tests.CmabTests
+{
+ [TestFixture]
+ public class DecisionServiceCmabTest
+ {
+ private Mock _loggerMock;
+ private Mock _errorHandlerMock;
+ private Mock _bucketerMock;
+ private Mock _cmabServiceMock;
+ private DecisionService _decisionService;
+ private ProjectConfig _config;
+ private Optimizely _optimizely;
+
+ private const string TEST_USER_ID = "test_user_cmab";
+ private const string TEST_EXPERIMENT_KEY = "test_experiment";
+ private const string TEST_EXPERIMENT_ID = "111127";
+ private const string VARIATION_A_ID = "111128";
+ private const string VARIATION_A_KEY = "control";
+ private const string TEST_CMAB_UUID = "uuid-123-456";
+ private const string AGE_ATTRIBUTE_KEY = "age";
+
+ [SetUp]
+ public void SetUp()
+ {
+ _loggerMock = new Mock();
+ _errorHandlerMock = new Mock();
+ _bucketerMock = new Mock(_loggerMock.Object);
+ _cmabServiceMock = new Mock();
+
+ _config = DatafileProjectConfig.Create(TestData.Datafile, _loggerMock.Object,
+ _errorHandlerMock.Object);
+
+ _decisionService = new DecisionService(_bucketerMock.Object, _errorHandlerMock.Object,
+ null, _loggerMock.Object, _cmabServiceMock.Object);
+
+ _optimizely = new Optimizely(TestData.Datafile, null, _loggerMock.Object,
+ _errorHandlerMock.Object);
+ }
+
+ ///
+ /// Verifies that GetVariation returns correct variation with CMAB UUID
+ ///
+ [Test]
+ public void TestGetVariationWithCmabExperimentReturnsVariation()
+ {
+ var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000);
+ var userContext = _optimizely.CreateUserContext(TEST_USER_ID);
+ var variation = new Variation { Id = VARIATION_A_ID, Key = VARIATION_A_KEY };
+
+ _bucketerMock.Setup(b => b.BucketToEntityId(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )).Returns(Result.NewResult("$", new DecisionReasons()));
+
+ _cmabServiceMock.Setup(c => c.GetDecision(
+ It.IsAny(),
+ It.IsAny(),
+ TEST_EXPERIMENT_ID,
+ It.IsAny()
+ )).Returns(new CmabDecision(VARIATION_A_ID, TEST_CMAB_UUID));
+
+ var mockConfig = CreateMockConfig(experiment, variation);
+
+ var result = _decisionService.GetVariation(experiment, userContext, mockConfig.Object);
+
+ Assert.IsNotNull(result);
+ Assert.IsNotNull(result.ResultObject, "VariationDecisionResult should be returned");
+ Assert.IsNotNull(result.ResultObject.Variation, "Variation should be returned");
+ Assert.AreEqual(VARIATION_A_KEY, result.ResultObject.Variation.Key);
+ Assert.AreEqual(VARIATION_A_ID, result.ResultObject.Variation.Id);
+ Assert.AreEqual(TEST_CMAB_UUID, result.ResultObject.CmabUuid);
+
+ _cmabServiceMock.Verify(c => c.GetDecision(
+ It.IsAny(),
+ It.IsAny(),
+ TEST_EXPERIMENT_ID,
+ It.IsAny()
+ ), Times.Once);
+ }
+
+ ///
+ /// Verifies that with 0 traffic allocation, CMAB service is not called
+ ///
+ [Test]
+ public void TestGetVariationWithCmabExperimentZeroTrafficAllocation()
+ {
+ var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 0);
+ var userContext = _optimizely.CreateUserContext(TEST_USER_ID);
+
+ _bucketerMock.Setup(b => b.BucketToEntityId(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )).Returns(Result.NullResult(new DecisionReasons()));
+
+ var mockConfig = CreateMockConfig(experiment, null);
+
+ var result = _decisionService.GetVariation(experiment, userContext, mockConfig.Object);
+
+ Assert.IsNotNull(result);
+ Assert.IsNull(result.ResultObject.Variation, "No variation should be returned with 0 traffic");
+ Assert.IsNull(result.ResultObject.CmabUuid);
+
+ var reasons = result.DecisionReasons.ToReport(true);
+ var expectedMessage =
+ $"User [{TEST_USER_ID}] not in CMAB experiment [{TEST_EXPERIMENT_KEY}] due to traffic allocation.";
+ Assert.Contains(expectedMessage, reasons);
+
+ _cmabServiceMock.Verify(c => c.GetDecision(
+ It.IsAny(),
+ It.IsAny