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

- Build Status Stable Release Size bitHound Overall Score Istanbul Code Coverage @@ -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 ![Semantic](https://img.shields.io/badge/sem-ver-lightgrey.svg?style=plastic) -[![Build Status](https://travis-ci.org/optimizely/csharp-sdk.svg?branch=master)](https://travis-ci.org/optimizely/csharp-sdk) +![CI](https://github.com/optimizely/csharp-sdk/actions/workflows/csharp.yml/badge.svg?branch=master) [![NuGet](https://img.shields.io/nuget/v/Optimizely.SDK.svg?style=plastic)](https://www.nuget.org/packages/Optimizely.SDK/) [![Apache 2.0](https://img.shields.io/github/license/nebula-plugins/gradle-extra-configurations-plugin.svg)](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(), + It.IsAny(), + It.IsAny() + ), Times.Never); + } + + /// + /// Verifies error handling when CMAB service throws exception + /// + [Test] + public void TestGetVariationWithCmabExperimentServiceError() + { + var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000); + var userContext = _optimizely.CreateUserContext(TEST_USER_ID); + + _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(), + It.IsAny(), + It.IsAny() + )).Throws(new Exception("CMAB service error")); + + var mockConfig = CreateMockConfig(experiment, null); + + var result = _decisionService.GetVariation(experiment, userContext, mockConfig.Object); + + Assert.IsNotNull(result); + Assert.IsNull(result.ResultObject.Variation, "Should return null on error"); + Assert.IsTrue(result.ResultObject.Error); + + var reasonsList = result.DecisionReasons.ToReport(true); + + Assert.IsTrue(reasonsList.Exists(reason => + reason.Contains( + $"Failed to fetch CMAB data for experiment {TEST_EXPERIMENT_KEY}.")), + $"Decision reasons should include CMAB fetch failure. Actual reasons: {string.Join(", ", reasonsList)}"); + + _cmabServiceMock.Verify(c => c.GetDecision( + It.IsAny(), + It.IsAny(), + TEST_EXPERIMENT_ID, + It.IsAny() + ), Times.Once); + } + + /// + /// Verifies behavior when CMAB service returns an unknown variation ID + /// + [Test] + public void TestGetVariationWithCmabExperimentUnknownVariationId() + { + var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000); + var userContext = _optimizely.CreateUserContext(TEST_USER_ID); + + _bucketerMock.Setup(b => b.BucketToEntityId( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>() + )).Returns(Result.NewResult("$", new DecisionReasons())); + + const string unknownVariationId = "unknown_var"; + _cmabServiceMock.Setup(c => c.GetDecision( + It.IsAny(), + It.IsAny(), + TEST_EXPERIMENT_ID, + It.IsAny() + )).Returns(new CmabDecision(unknownVariationId, TEST_CMAB_UUID)); + + var mockConfig = CreateMockConfig(experiment, null); + + var result = _decisionService.GetVariation(experiment, userContext, mockConfig.Object); + + Assert.IsNotNull(result); + Assert.IsNull(result.ResultObject.Variation, "Should return null on error"); + + var reasons = result.DecisionReasons.ToReport(true); + var expectedMessage = + $"User [{TEST_USER_ID}] bucketed into invalid variation [{unknownVariationId}] for CMAB experiment [{TEST_EXPERIMENT_KEY}]."; + Assert.Contains(expectedMessage, reasons); + + _cmabServiceMock.Verify(c => c.GetDecision( + It.IsAny(), + It.IsAny(), + TEST_EXPERIMENT_ID, + It.IsAny() + ), Times.Once); + } + + /// + /// Verifies that cached decisions skip CMAB service call + /// + [Test] + public void TestGetVariationWithCmabExperimentCacheHit() + { + var attributeIds = new List { "age_attr_id" }; + var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000, + attributeIds); + 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())); + + var attributeMap = new Dictionary + { + { "age_attr_id", new AttributeEntity { Id = "age_attr_id", Key = AGE_ATTRIBUTE_KEY } } + }; + var mockConfig = CreateMockConfig(experiment, variation, attributeMap); + + var cmabClientMock = new Mock(MockBehavior.Strict); + cmabClientMock.Setup(c => c.FetchDecision( + TEST_EXPERIMENT_ID, + TEST_USER_ID, + It.Is>(attrs => + attrs.Count == 1 && attrs.ContainsKey(AGE_ATTRIBUTE_KEY) && + (int)attrs[AGE_ATTRIBUTE_KEY] == 25), + It.IsAny(), + It.IsAny())) + .Returns(VARIATION_A_ID); + + var cache = new LruCache(maxSize: 10, + itemTimeout: TimeSpan.FromMinutes(5), + logger: new NoOpLogger()); + var cmabService = new DefaultCmabService(cache, cmabClientMock.Object, new NoOpLogger()); + var decisionService = new DecisionService(_bucketerMock.Object, _errorHandlerMock.Object, + null, _loggerMock.Object, cmabService); + + var userContext = _optimizely.CreateUserContext(TEST_USER_ID); + userContext.SetAttribute(AGE_ATTRIBUTE_KEY, 25); + + var result1 = decisionService.GetVariation(experiment, userContext, mockConfig.Object); + var result2 = decisionService.GetVariation(experiment, userContext, mockConfig.Object); + + Assert.IsNotNull(result1.ResultObject); + Assert.IsNotNull(result2.ResultObject); + Assert.AreEqual(result1.ResultObject.Variation.Key, result2.ResultObject.Variation.Key); + Assert.IsNotNull(result1.ResultObject.CmabUuid); + Assert.AreEqual(result1.ResultObject.CmabUuid, result2.ResultObject.CmabUuid); + + cmabClientMock.Verify(c => c.FetchDecision( + TEST_EXPERIMENT_ID, + TEST_USER_ID, + It.Is>(attrs => + attrs.Count == 1 && (int)attrs[AGE_ATTRIBUTE_KEY] == 25), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that changing attributes invalidates cache + /// + [Test] + public void TestGetVariationWithCmabExperimentCacheMissAttributesChanged() + { + var attributeIds = new List { "age_attr_id" }; + var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000, + attributeIds); + var variation = new Variation { Id = VARIATION_A_ID, Key = VARIATION_A_KEY }; + var attributeMap = new Dictionary + { + { "age_attr_id", new AttributeEntity { Id = "age_attr_id", Key = AGE_ATTRIBUTE_KEY } } + }; + var mockConfig = CreateMockConfig(experiment, variation, attributeMap); + + _bucketerMock.Setup(b => b.BucketToEntityId( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>() + )).Returns(Result.NewResult("$", new DecisionReasons())); + + var cmabClientMock = new Mock(MockBehavior.Strict); + cmabClientMock.Setup(c => c.FetchDecision( + TEST_EXPERIMENT_ID, + TEST_USER_ID, + It.Is>(attrs => attrs.ContainsKey(AGE_ATTRIBUTE_KEY)), + It.IsAny(), + It.IsAny())) + .Returns(VARIATION_A_ID); + + var cache = new LruCache(maxSize: 10, + itemTimeout: TimeSpan.FromMinutes(5), + logger: new NoOpLogger()); + var cmabService = new DefaultCmabService(cache, cmabClientMock.Object, new NoOpLogger()); + var decisionService = new DecisionService(_bucketerMock.Object, _errorHandlerMock.Object, + null, _loggerMock.Object, cmabService); + + var userContext = _optimizely.CreateUserContext(TEST_USER_ID); + + userContext.SetAttribute(AGE_ATTRIBUTE_KEY, 25); + var result1 = decisionService.GetVariation(experiment, userContext, mockConfig.Object); + + userContext.SetAttribute(AGE_ATTRIBUTE_KEY, 30); + var result2 = decisionService.GetVariation(experiment, userContext, mockConfig.Object); + + Assert.IsNotNull(result1.ResultObject); + Assert.IsNotNull(result2.ResultObject); + Assert.IsNotNull(result1.ResultObject.CmabUuid); + Assert.IsNotNull(result2.ResultObject.CmabUuid); + Assert.AreNotEqual(result1.ResultObject.CmabUuid, result2.ResultObject.CmabUuid); + + cmabClientMock.Verify(c => c.FetchDecision( + TEST_EXPERIMENT_ID, + TEST_USER_ID, + It.Is>(attrs => + attrs.ContainsKey(AGE_ATTRIBUTE_KEY) && (int)attrs[AGE_ATTRIBUTE_KEY] == 25), + It.IsAny(), + It.IsAny()), + Times.Once); + cmabClientMock.Verify(c => c.FetchDecision( + TEST_EXPERIMENT_ID, + TEST_USER_ID, + It.Is>(attrs => + attrs.ContainsKey(AGE_ATTRIBUTE_KEY) && (int)attrs[AGE_ATTRIBUTE_KEY] == 30), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies GetVariationForFeatureExperiment works with CMAB + /// + [Test] + public void TestGetVariationForFeatureExperimentWithCmab() + { + 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); + + // GetVariationForFeatureExperiment requires a FeatureFlag, not just an Experiment + // For this test, we'll use GetVariation instead since we're testing CMAB decision flow + var result = _decisionService.GetVariation(experiment, userContext, mockConfig.Object, + new OptimizelyDecideOption[] { }); + + // Assert + Assert.IsNotNull(result); + Assert.IsNotNull(result.ResultObject); + Assert.AreEqual(VARIATION_A_KEY, result.ResultObject.Variation.Key); + Assert.AreEqual(TEST_CMAB_UUID, result.ResultObject.CmabUuid); + } + + /// + /// Verifies GetVariationForFeature works with CMAB experiments in feature flags + /// + [Test] + public void TestGetVariationForFeatureWithCmabExperiment() + { + // Arrange + 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, + FeatureEnabled = true + }; + + _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 + Assert.IsNotNull(result); + Assert.IsNotNull(result.ResultObject); + Assert.IsTrue(result.ResultObject.Variation.FeatureEnabled == true); + Assert.AreEqual(TEST_CMAB_UUID, result.ResultObject.CmabUuid); + } + + /// + /// Verifies only relevant attributes are sent to CMAB service + /// + [Test] + public void TestGetDecisionForCmabExperimentAttributeFiltering() + { + var attributeIds = new List { "age_attr_id", "location_attr_id" }; + var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000, + attributeIds); + var variation = new Variation { Id = VARIATION_A_ID, Key = VARIATION_A_KEY }; + var attributeMap = new Dictionary + { + { "age_attr_id", new AttributeEntity { Id = "age_attr_id", Key = "age" } }, + { "location_attr_id", new AttributeEntity { Id = "location_attr_id", Key = "location" } } + }; + var mockConfig = CreateMockConfig(experiment, variation, attributeMap); + + _bucketerMock.Setup(b => b.BucketToEntityId( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>() + )).Returns(Result.NewResult("$", new DecisionReasons())); + + var cmabClientMock = new Mock(MockBehavior.Strict); + cmabClientMock.Setup(c => c.FetchDecision( + TEST_EXPERIMENT_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(VARIATION_A_ID); + + var cache = new LruCache(maxSize: 10, + itemTimeout: TimeSpan.FromMinutes(5), + logger: new NoOpLogger()); + var cmabService = new DefaultCmabService(cache, cmabClientMock.Object, new NoOpLogger()); + var decisionService = new DecisionService(_bucketerMock.Object, _errorHandlerMock.Object, + null, _loggerMock.Object, cmabService); + + var userContext = _optimizely.CreateUserContext(TEST_USER_ID); + userContext.SetAttribute("age", 25); + userContext.SetAttribute("location", "USA"); + userContext.SetAttribute("extra", "value"); + + var result = decisionService.GetVariation(experiment, userContext, mockConfig.Object); + + Assert.IsNotNull(result); + Assert.IsNotNull(result.ResultObject); + Assert.IsNotNull(result.ResultObject.CmabUuid); + + cmabClientMock.VerifyAll(); + } + + /// + /// Verifies CMAB service receives an empty attribute payload when no CMAB attribute IDs are + /// configured + /// + [Test] + public void TestGetDecisionForCmabExperimentNoAttributeIds() + { + var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000, + null); + var variation = new Variation { Id = VARIATION_A_ID, Key = VARIATION_A_KEY }; + var mockConfig = CreateMockConfig(experiment, variation, new Dictionary()); + + _bucketerMock.Setup(b => b.BucketToEntityId( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>() + )).Returns(Result.NewResult("$", new DecisionReasons())); + + var cmabClientMock = new Mock(MockBehavior.Strict); + cmabClientMock.Setup(c => c.FetchDecision( + TEST_EXPERIMENT_ID, + TEST_USER_ID, + It.Is>(attrs => attrs.Count == 0), + It.IsAny(), + It.IsAny())) + .Returns(VARIATION_A_ID); + + var cache = new LruCache(maxSize: 10, + itemTimeout: TimeSpan.FromMinutes(5), + logger: new NoOpLogger()); + var cmabService = new DefaultCmabService(cache, cmabClientMock.Object, new NoOpLogger()); + var decisionService = new DecisionService(_bucketerMock.Object, _errorHandlerMock.Object, + null, _loggerMock.Object, cmabService); + + var userContext = _optimizely.CreateUserContext(TEST_USER_ID); + userContext.SetAttribute("age", 25); + userContext.SetAttribute("location", "USA"); + + var result = decisionService.GetVariation(experiment, userContext, mockConfig.Object); + + Assert.IsNotNull(result); + Assert.IsNotNull(result.ResultObject); + Assert.IsNotNull(result.ResultObject.CmabUuid); + + cmabClientMock.VerifyAll(); + } + + /// + /// Verifies regular experiments are not affected by CMAB logic + /// + [Test] + public void TestGetVariationNonCmabExperimentNotAffected() + { + var experiment = _config.GetExperimentFromKey(TEST_EXPERIMENT_KEY); + Assert.IsNotNull(experiment); + Assert.IsNull(experiment.Cmab, "Should be a non-CMAB experiment"); + + var userContext = _optimizely.CreateUserContext(TEST_USER_ID); + var variation = _config.GetVariationFromKey(TEST_EXPERIMENT_KEY, VARIATION_A_KEY); + + // Create decision service WITHOUT CMAB service + var decisionServiceWithoutCmab = new DecisionService( + new Bucketer(_loggerMock.Object), + _errorHandlerMock.Object, + null, + _loggerMock.Object, + null // No CMAB service + ); + + var result = decisionServiceWithoutCmab.GetVariation(experiment, userContext, _config); + + Assert.IsNotNull(result); + // Standard bucketing should work normally + // Verify CMAB service was never called + _cmabServiceMock.Verify(c => c.GetDecision( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Never); + } + + #region Helper Methods + + /// + /// Creates a CMAB experiment for testing + /// + private Experiment CreateCmabExperiment(string id, string key, int trafficAllocation, + List attributeIds = null) + { + return new Experiment + { + Id = id, + Key = key, + LayerId = "layer_1", + Status = "Running", + TrafficAllocation = new TrafficAllocation[0], + ForcedVariations = new Dictionary(), // UserIdToKeyVariations is an alias for this + Cmab = new Entity.Cmab(attributeIds ?? new List(), trafficAllocation) + }; + } + + /// + /// Creates a mock ProjectConfig with the experiment and variation + /// + private Mock CreateMockConfig(Experiment experiment, Variation variation, + Dictionary attributeMap = null) + { + var mockConfig = new Mock(); + + var experimentMap = new Dictionary + { + { experiment.Id, experiment } + }; + + mockConfig.Setup(c => c.ExperimentIdMap).Returns(experimentMap); + mockConfig.Setup(c => c.GetExperimentFromKey(experiment.Key)).Returns(experiment); + mockConfig.Setup(c => c.GetExperimentFromId(experiment.Id)).Returns(experiment); + + if (variation != null) + { + mockConfig.Setup(c => c.GetVariationFromIdByExperimentId(experiment.Id, + variation.Id)).Returns(variation); + } + + mockConfig.Setup(c => c.AttributeIdMap) + .Returns(attributeMap ?? new Dictionary()); + + return mockConfig; + } + + #endregion + } +} diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs index 9dac9699..5c891a9d 100644 --- a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs +++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs @@ -1,18 +1,18 @@ -/* -* 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. -*/ +/* + * 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; @@ -25,6 +25,8 @@ using OptimizelySDK.Logger; using OptimizelySDK.Odp; using OptimizelySDK.OptimizelyDecisions; +using OptimizelySDK.Tests.Utils; +using OptimizelySDK.Utils; using AttributeEntity = OptimizelySDK.Entity.Attribute; namespace OptimizelySDK.Tests.CmabTests @@ -32,6 +34,19 @@ namespace OptimizelySDK.Tests.CmabTests [TestFixture] public class DefaultCmabServiceTest { + [SetUp] + public void SetUp() + { + _mockCmabClient = new Mock(MockBehavior.Strict); + _logger = new NoOpLogger(); + _cmabCache = new LruCache(10, TimeSpan.FromMinutes(5), _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()); + } + private Mock _mockCmabClient; private LruCache _cmabCache; private DefaultCmabService _cmabService; @@ -44,44 +59,37 @@ public class DefaultCmabServiceTest 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" } } + { 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 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" + VariationId = "varA", }); - var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null); + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID); 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); + _mockCmabClient.Verify( + c => c.FetchDecision(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny(), + It.IsAny()), Times.Never); } [Test] @@ -90,14 +98,17 @@ 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" } } + { 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 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.Is>(attrs => + attrs != null && attrs.Count == 1 && attrs.ContainsKey("age") && + (int)attrs["age"] == 25), It.IsAny(), It.IsAny())).Returns("varB"); @@ -116,21 +127,23 @@ 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" } } + { 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 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" + VariationId = "varOld", }); _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, - It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25), + It.Is>(attrs => + attrs.Count == 1 && (int)attrs["age"] == 25), It.IsAny(), It.IsAny())).Returns("varNew"); @@ -153,10 +166,11 @@ public void InvalidatesUserEntryWhenOptionSpecified() 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" } } + { 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 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); @@ -165,17 +179,18 @@ public void InvalidatesUserEntryWhenOptionSpecified() { AttributesHash = "old_hash", CmabUuid = "uuid-old", - VariationId = "varOld" + VariationId = "varOld", }); _cmabCache.Save(otherKey, new CmabCacheEntry { AttributesHash = "other_hash", CmabUuid = "uuid-other", - VariationId = "varOther" + VariationId = "varOther", }); _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, - It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25), + It.Is>(attrs => + attrs.Count == 1 && (int)attrs["age"] == 25), It.IsAny(), It.IsAny())).Returns("varNew"); @@ -201,25 +216,27 @@ 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" } } + { 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 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" + VariationId = "varOld", }); _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, - It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25), + 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); + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID); Assert.IsNotNull(decision); Assert.AreEqual("varUpdated", decision.VariationId); @@ -233,18 +250,22 @@ public void FetchesNewDecisionWhenHashDiffers() [Test] public void FiltersAttributesBeforeCallingClient() { - var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID }); + 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" } } + { + 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" } + { "extra", "value" }, }); _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, @@ -255,7 +276,7 @@ public void FiltersAttributesBeforeCallingClient() It.IsAny(), It.IsAny())).Returns("varFiltered"); - var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null); + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID); Assert.IsNotNull(decision); Assert.AreEqual("varFiltered", decision.VariationId); @@ -268,14 +289,15 @@ 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 } }); + 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); + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID); Assert.IsNotNull(decision); Assert.AreEqual("varDefault", decision.VariationId); @@ -285,18 +307,22 @@ public void HandlesMissingCmabConfiguration() [Test] public void AttributeHashIsStableRegardlessOfOrder() { - var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID }); + 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" } } + { + 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 } + { "a", 1 }, }); _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, @@ -304,22 +330,25 @@ public void AttributeHashIsStableRegardlessOfOrder() It.IsAny(), It.IsAny())).Returns("varStable"); - var firstDecision = _cmabService.GetDecision(projectConfig, firstContext, TEST_RULE_ID, null); + var firstDecision = _cmabService.GetDecision(projectConfig, firstContext, TEST_RULE_ID); Assert.IsNotNull(firstDecision); Assert.AreEqual("varStable", firstDecision.VariationId); var secondContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "a", 1 }, - { "b", 2 } + { "b", 2 }, }); - var secondDecision = _cmabService.GetDecision(projectConfig, secondContext, TEST_RULE_ID, null); + var secondDecision = + _cmabService.GetDecision(projectConfig, secondContext, TEST_RULE_ID); 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); + It.IsAny>(), It.IsAny(), + It.IsAny()), + Times.Once); } [Test] @@ -328,17 +357,18 @@ 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" } } + { 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 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); + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID); Assert.IsNotNull(decision); Assert.AreEqual("varKey", decision.VariationId); @@ -348,7 +378,244 @@ public void UsesExpectedCacheKeyFormat() Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid); } - private OptimizelyUserContext CreateUserContext(string userId, IDictionary attributes) + [Test] + public void ConstructorWithoutConfigUsesDefaultCacheSettings() + { + var cache = new LruCache(CmabConstants.DEFAULT_CACHE_SIZE, + CmabConstants.DEFAULT_CACHE_TTL, _logger); + var client = new DefaultCmabClient(null, + new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); + var service = new DefaultCmabService(cache, client, _logger); + var internalCache = GetInternalCache(service) as LruCache; + + Assert.IsNotNull(internalCache); + Assert.AreEqual(CmabConstants.DEFAULT_CACHE_SIZE, internalCache.MaxSizeForTesting); + Assert.AreEqual(CmabConstants.DEFAULT_CACHE_TTL, internalCache.TimeoutForTesting); + } + + [Test] + public void ConstructorAppliesCustomCacheSize() + { + var cache = new LruCache(42, CmabConstants.DEFAULT_CACHE_TTL, _logger); + var client = new DefaultCmabClient(null, + new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); + var service = new DefaultCmabService(cache, client, _logger); + var internalCache = GetInternalCache(service) as LruCache; + + Assert.IsNotNull(internalCache); + Assert.AreEqual(42, internalCache.MaxSizeForTesting); + Assert.AreEqual(CmabConstants.DEFAULT_CACHE_TTL, internalCache.TimeoutForTesting); + } + + [Test] + public void ConstructorAppliesCustomCacheTtl() + { + var expectedTtl = TimeSpan.FromMinutes(3); + var cache = new LruCache(CmabConstants.DEFAULT_CACHE_SIZE, expectedTtl, + _logger); + var client = new DefaultCmabClient(null, + new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); + var service = new DefaultCmabService(cache, client, _logger); + var internalCache = GetInternalCache(service) as LruCache; + + Assert.IsNotNull(internalCache); + Assert.AreEqual(CmabConstants.DEFAULT_CACHE_SIZE, internalCache.MaxSizeForTesting); + Assert.AreEqual(expectedTtl, internalCache.TimeoutForTesting); + } + + [Test] + public void ConstructorAppliesCustomCacheSizeAndTtl() + { + var expectedTtl = TimeSpan.FromSeconds(90); + var cache = new LruCache(5, expectedTtl, _logger); + var client = new DefaultCmabClient(null, + new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); + var service = new DefaultCmabService(cache, client, _logger); + var internalCache = GetInternalCache(service) as LruCache; + + Assert.IsNotNull(internalCache); + Assert.AreEqual(5, internalCache.MaxSizeForTesting); + Assert.AreEqual(expectedTtl, internalCache.TimeoutForTesting); + } + + [Test] + public void ConstructorUsesProvidedCustomCacheInstance() + { + var customCache = new LruCache(3, TimeSpan.FromSeconds(5), _logger); + var client = new DefaultCmabClient(null, + new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); + var service = new DefaultCmabService(customCache, client, _logger); + var cache = GetInternalCache(service); + + Assert.IsNotNull(cache); + Assert.AreSame(customCache, cache); + } + + [Test] + public void ConstructorAcceptsAnyICacheImplementation() + { + var fakeCache = new FakeCache(); + var client = new DefaultCmabClient(null, + new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); + var service = new DefaultCmabService(fakeCache, client, _logger); + var cache = GetInternalCache(service); + + Assert.IsNotNull(cache); + Assert.AreSame(fakeCache, cache); + Assert.IsInstanceOf>(cache); + } + + [Test] + public void ConstructorCreatesDefaultClientWhenNoneProvided() + { + var cache = new LruCache(CmabConstants.DEFAULT_CACHE_SIZE, + CmabConstants.DEFAULT_CACHE_TTL, _logger); + var client = new DefaultCmabClient(null, + new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); + var service = new DefaultCmabService(cache, client, _logger); + var internalClient = GetInternalClient(service); + + Assert.IsInstanceOf(internalClient); + } + + [Test] + public void ConstructorUsesProvidedClientInstance() + { + var mockClient = new Mock().Object; + var cache = new LruCache(CmabConstants.DEFAULT_CACHE_SIZE, + CmabConstants.DEFAULT_CACHE_TTL, _logger); + var service = new DefaultCmabService(cache, mockClient, _logger); + var client = GetInternalClient(service); + + Assert.AreSame(mockClient, client); + } + + [Test] + public void ConcurrentRequestsForSameUserUseCacheAfterFirstNetworkCall() + { + 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 clientCallCount = 0; + var clientCallLock = new object(); + + _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(() => + { + lock (clientCallLock) + { + clientCallCount++; + } + System.Threading.Thread.Sleep(100); + + return "varConcurrent"; + }); + + var tasks = new System.Threading.Tasks.Task[10]; + + for (int i = 0; i < tasks.Length; i++) + { + tasks[i] = System.Threading.Tasks.Task.Run(() => + _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID)); + } + + System.Threading.Tasks.Task.WaitAll(tasks); + + foreach (var task in tasks) + { + Assert.IsNotNull(task.Result); + Assert.AreEqual("varConcurrent", task.Result.VariationId); + } + + Assert.AreEqual(1, clientCallCount, + "Client should only be called once - subsequent requests should use cache"); + + _mockCmabClient.VerifyAll(); + } + + [Test] + public void SameUserRuleCombinationUsesConsistentLock() + { + var userId = "test_user"; + var ruleId = "test_rule"; + + var index1 = _cmabService.GetLockIndex(userId, ruleId); + var index2 = _cmabService.GetLockIndex(userId, ruleId); + var index3 = _cmabService.GetLockIndex(userId, ruleId); + + Assert.AreEqual(index1, index2, "Same user/rule should always use same lock"); + Assert.AreEqual(index2, index3, "Same user/rule should always use same lock"); + } + + [Test] + public void LockStripingDistribution() + { + var testCases = new[] + { + new { UserId = "user1", RuleId = "rule1" }, + new { UserId = "user2", RuleId = "rule1" }, + new { UserId = "user1", RuleId = "rule2" }, + new { UserId = "user3", RuleId = "rule3" }, + new { UserId = "user4", RuleId = "rule4" }, + }; + + var lockIndices = new HashSet(); + foreach (var testCase in testCases) + { + var index = _cmabService.GetLockIndex(testCase.UserId, testCase.RuleId); + + Assert.GreaterOrEqual(index, 0, "Lock index should be non-negative"); + Assert.Less(index, 1000, "Lock index should be less than NUM_LOCK_STRIPES (1000)"); + + lockIndices.Add(index); + } + + Assert.Greater(lockIndices.Count, 1, + "Different user/rule combinations should generally use different locks"); + } + + private static ICacheWithRemove GetInternalCache(DefaultCmabService service) + { + return Reflection.GetFieldValue, DefaultCmabService>(service, + "_cmabCache"); + } + + private static ICmabClient GetInternalClient(DefaultCmabService service) + { + return Reflection.GetFieldValue(service, + "_cmabClient"); + } + + private sealed class FakeCache : ICacheWithRemove + { + public void Save(string key, CmabCacheEntry value) { } + + public CmabCacheEntry Lookup(string key) + { + return null; + } + + public void Reset() { } + + public void Remove(string key) { } + } + + private OptimizelyUserContext CreateUserContext(string userId, + IDictionary attributes + ) { var userContext = _optimizely.CreateUserContext(userId); @@ -361,7 +628,8 @@ private OptimizelyUserContext CreateUserContext(string userId, IDictionary attributeMap) + Dictionary attributeMap + ) { var mockConfig = new Mock(); var experimentMap = new Dictionary(); @@ -371,7 +639,8 @@ private static ProjectConfig CreateProjectConfig(string ruleId, Experiment exper } mockConfig.SetupGet(c => c.ExperimentIdMap).Returns(experimentMap); - mockConfig.SetupGet(c => c.AttributeIdMap).Returns(attributeMap ?? new Dictionary()); + mockConfig.SetupGet(c => c.AttributeIdMap). + Returns(attributeMap ?? new Dictionary()); return mockConfig.Object; } @@ -380,9 +649,8 @@ private static Experiment CreateExperiment(string ruleId, List attribute return new Experiment { Id = ruleId, - Cmab = attributeIds == null ? null : new Entity.Cmab(attributeIds) + Cmab = attributeIds == null ? null : new Entity.Cmab(attributeIds, 10000), }; } - } } diff --git a/OptimizelySDK.Tests/CmabTests/ImpressionEventCmabTest.cs b/OptimizelySDK.Tests/CmabTests/ImpressionEventCmabTest.cs new file mode 100644 index 00000000..ccde294f --- /dev/null +++ b/OptimizelySDK.Tests/CmabTests/ImpressionEventCmabTest.cs @@ -0,0 +1,215 @@ +/* +* 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 Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using OptimizelySDK.Config; +using OptimizelySDK.Entity; +using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Event; +using OptimizelySDK.Event.Entity; +using OptimizelySDK.Logger; + +namespace OptimizelySDK.Tests.CmabTests +{ + [TestFixture] + public class ImpressionEventCmabTest + { + private Mock _loggerMock; + private Mock _errorHandlerMock; + private ProjectConfig _config; + + private const string TEST_USER_ID = "test_user"; + private const string TEST_CMAB_UUID = "cmab-uuid-12345"; + private const string TEST_EXPERIMENT_KEY = "test_experiment"; + private const string TEST_VARIATION_ID = "77210100090"; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock(); + _errorHandlerMock = new Mock(); + + _config = DatafileProjectConfig.Create(TestData.Datafile, _loggerMock.Object, + _errorHandlerMock.Object); + } + + /// + /// Verifies that CreateImpressionEvent includes CMAB UUID in metadata + /// + [Test] + public void TestCreateImpressionEventWithCmabUuid() + { + var experiment = _config.GetExperimentFromKey(TEST_EXPERIMENT_KEY); + var variation = _config.GetVariationFromId(experiment.Key, TEST_VARIATION_ID); + + var impressionEvent = UserEventFactory.CreateImpressionEvent( + _config, + experiment, + variation, + TEST_USER_ID, + null, + TEST_EXPERIMENT_KEY, + "experiment", + true, + TEST_CMAB_UUID); + + Assert.IsNotNull(impressionEvent); + Assert.IsNotNull(impressionEvent.Metadata); + Assert.AreEqual(TEST_CMAB_UUID, impressionEvent.Metadata.CmabUuid); + Assert.AreEqual(experiment, impressionEvent.Experiment); + Assert.AreEqual(variation, impressionEvent.Variation); + Assert.AreEqual(TEST_USER_ID, impressionEvent.UserId); + } + + /// + /// Verifies that CreateImpressionEvent without CMAB UUID has null cmab_uuid + /// + [Test] + public void TestCreateImpressionEventWithoutCmabUuid() + { + var experiment = _config.GetExperimentFromKey(TEST_EXPERIMENT_KEY); + var variation = _config.GetVariationFromId(experiment.Key, TEST_VARIATION_ID); + + var impressionEvent = UserEventFactory.CreateImpressionEvent( + _config, + experiment, + variation, + TEST_USER_ID, + null, + TEST_EXPERIMENT_KEY, + "experiment"); + + Assert.IsNotNull(impressionEvent); + Assert.IsNotNull(impressionEvent.Metadata); + Assert.IsNull(impressionEvent.Metadata.CmabUuid); + Assert.AreEqual(experiment, impressionEvent.Experiment); + Assert.AreEqual(variation, impressionEvent.Variation); + } + + /// + /// Verifies that EventFactory includes cmab_uuid in the log event JSON + /// + [Test] + public void TestEventFactoryCreateLogEventWithCmabUuid() + { + // Arrange + var experiment = _config.GetExperimentFromKey(TEST_EXPERIMENT_KEY); + var variation = _config.GetVariationFromId(experiment.Key, TEST_VARIATION_ID); + + var impressionEvent = UserEventFactory.CreateImpressionEvent( + _config, + experiment, + variation, + TEST_USER_ID, + null, + TEST_EXPERIMENT_KEY, + "experiment", + true, + TEST_CMAB_UUID); + + var logEvent = EventFactory.CreateLogEvent(new UserEvent[] { impressionEvent }, _loggerMock.Object); + + Assert.IsNotNull(logEvent); + + var params_dict = logEvent.Params; + + Assert.IsNotNull(params_dict); + Assert.IsTrue(params_dict.ContainsKey("visitors")); + + var visitors = (JArray)params_dict["visitors"]; + + Assert.IsNotNull(visitors); + Assert.AreEqual(1, visitors.Count); + + var visitor = visitors[0] as JObject; + var snapshots = visitor["snapshots"] as JArray; + + Assert.IsNotNull(snapshots); + Assert.Greater(snapshots.Count, 0); + + var snapshot = snapshots[0] as JObject; + var decisions = snapshot["decisions"] as JArray; + + Assert.IsNotNull(decisions); + Assert.Greater(decisions.Count, 0); + + var decision = decisions[0] as JObject; + var metadata = decision["metadata"] as JObject; + + Assert.IsNotNull(metadata); + + Assert.IsTrue(metadata.ContainsKey("cmab_uuid")); + Assert.AreEqual(TEST_CMAB_UUID, metadata["cmab_uuid"].ToString()); + } + + /// + /// Verifies that EventFactory does not include cmab_uuid when not provided + /// + [Test] + public void TestEventFactoryCreateLogEventWithoutCmabUuid() + { + var experiment = _config.GetExperimentFromKey(TEST_EXPERIMENT_KEY); + var variation = _config.GetVariationFromId(experiment.Key, TEST_VARIATION_ID); + + var impressionEvent = UserEventFactory.CreateImpressionEvent( + _config, + experiment, + variation, + TEST_USER_ID, + null, + TEST_EXPERIMENT_KEY, + "experiment"); + + var logEvent = EventFactory.CreateLogEvent(new UserEvent[] { impressionEvent }, _loggerMock.Object); + + Assert.IsNotNull(logEvent); + + var params_dict = logEvent.Params; + + Assert.IsNotNull(params_dict); + Assert.IsTrue(params_dict.ContainsKey("visitors")); + + var visitors = (JArray)params_dict["visitors"]; + + Assert.IsNotNull(visitors); + Assert.AreEqual(1, visitors.Count); + + var visitor = visitors[0] as JObject; + var snapshots = visitor["snapshots"] as JArray; + + Assert.IsNotNull(snapshots); + Assert.Greater(snapshots.Count, 0); + + var snapshot = snapshots[0] as JObject; + var decisions = snapshot["decisions"] as JArray; + + Assert.IsNotNull(decisions); + Assert.Greater(decisions.Count, 0); + + var decision = decisions[0] as JObject; + var metadata = decision["metadata"] as JObject; + + Assert.IsNotNull(metadata); + + Assert.IsFalse(metadata.ContainsKey("cmab_uuid") && + metadata["cmab_uuid"].Type != JTokenType.Null, + "cmab_uuid should be absent or null when no CMAB UUID is provided."); + } + } +} diff --git a/OptimizelySDK.Tests/CmabTests/OptimizelyUserContextCmabTest.cs b/OptimizelySDK.Tests/CmabTests/OptimizelyUserContextCmabTest.cs new file mode 100644 index 00000000..9156b02e --- /dev/null +++ b/OptimizelySDK.Tests/CmabTests/OptimizelyUserContextCmabTest.cs @@ -0,0 +1,578 @@ +/* +* 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.Reflection; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using OptimizelySDK.Bucketing; +using OptimizelySDK.Cmab; +using OptimizelySDK.Config; +using OptimizelySDK.Entity; +using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Event; +using OptimizelySDK.Event.Dispatcher; +using OptimizelySDK.Logger; +using OptimizelySDK.Notifications; +using OptimizelySDK.OptimizelyDecisions; +using OptimizelySDK.Tests.NotificationTests; +using OptimizelySDK.Utils; + +namespace OptimizelySDK.Tests.CmabTests +{ + [TestFixture] + public class OptimizelyUserContextCmabTest + { + private Mock _loggerMock; + private Mock _errorHandlerMock; + private Mock _eventDispatcherMock; + private TestCmabService _cmabService; + private Mock _notificationCallbackMock; + private Optimizely _optimizely; + private ProjectConfig _config; + + private const string TEST_USER_ID = "test_user_cmab"; + private const string TEST_FEATURE_KEY = "multi_variate_feature"; + private const string TEST_EXPERIMENT_KEY = "test_experiment_multivariate"; + private const string TEST_EXPERIMENT_ID = "122230"; + private const string VARIATION_A_ID = "122231"; + private const string VARIATION_A_KEY = "Fred"; + private const string TEST_CMAB_UUID = "uuid-cmab-123"; + private const string DEVICE_TYPE_ATTRIBUTE_ID = "7723280020"; + private const string DEVICE_TYPE_ATTRIBUTE_KEY = "device_type"; + private const string BROWSER_TYPE_ATTRIBUTE_KEY = "browser_type"; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock(); + _errorHandlerMock = new Mock(); + _eventDispatcherMock = new Mock(); + _cmabService = new TestCmabService + { + DefaultDecision = new CmabDecision(VARIATION_A_ID, TEST_CMAB_UUID) + }; + _notificationCallbackMock = new Mock(); + + _config = DatafileProjectConfig.Create(TestData.Datafile, _loggerMock.Object, + _errorHandlerMock.Object); + + ConfigureCmabExperiment(_config, TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY); + + // Create Optimizely with mocked CMAB service using ConfigManager + var configManager = new FallbackProjectConfigManager(_config); + _optimizely = new Optimizely(configManager, null, _eventDispatcherMock.Object, + _loggerMock.Object, _errorHandlerMock.Object); + + // Replace decision service with one that has our mock CMAB service + var decisionService = new DecisionService(new Bucketer(_loggerMock.Object), + _errorHandlerMock.Object, null, _loggerMock.Object, _cmabService); + + SetDecisionService(_optimizely, decisionService); + } + + /// + /// Verifies Decide returns decision for CMAB experiment + /// + [Test] + public void TestDecideWithCmabExperimentReturnsDecision() + { + var userContext = CreateCmabUserContext(); + var decision = userContext.Decide(TEST_FEATURE_KEY); + + Assert.IsNotNull(decision); + Assert.AreEqual(VARIATION_A_KEY, decision.VariationKey); + Assert.IsTrue(decision.Enabled, "Feature flag should be enabled for CMAB variation."); + Assert.AreEqual(TEST_FEATURE_KEY, decision.FlagKey); + Assert.AreEqual(TEST_EXPERIMENT_KEY, decision.RuleKey); + Assert.IsTrue(decision.Reasons == null || decision.Reasons.Length == 0); + + Assert.AreEqual(1, _cmabService.CallCount); + Assert.AreEqual(TEST_EXPERIMENT_ID, _cmabService.LastRuleId); + } + + /// + /// Verifies impression event is sent with CMAB UUID in metadata + /// + [Test] + public void TestDecideWithCmabExperimentVerifyImpressionEvent() + { + var userContext = CreateCmabUserContext(); + LogEvent impressionEvent = null; + + _eventDispatcherMock.Setup(d => d.DispatchEvent(It.IsAny())) + .Callback(e => impressionEvent = e); + + var decision = userContext.Decide(TEST_FEATURE_KEY); + + Assert.IsNotNull(decision); + _eventDispatcherMock.Verify(d => d.DispatchEvent(It.IsAny()), Times.Once); + Assert.IsNotNull(impressionEvent, "Impression event should be dispatched."); + + var payload = JObject.Parse(impressionEvent.GetParamsAsJson()); + var cmabUuidToken = + payload.SelectToken("visitors[0].snapshots[0].decisions[0].metadata.cmab_uuid"); + + Assert.IsNotNull(cmabUuidToken, "Metadata should include CMAB UUID."); + Assert.AreEqual(TEST_CMAB_UUID, cmabUuidToken.Value()); + Assert.AreEqual(1, _cmabService.CallCount); + } + + /// + /// Verifies IsFeatureEnabled sends impression event including CMAB UUID in metadata + /// + [Test] + public void TestIsFeatureEnabledDispatchesCmabUuidInImpressionEvent() + { + LogEvent impressionEvent = null; + + _eventDispatcherMock.Setup(d => d.DispatchEvent(It.IsAny())) + .Callback(e => impressionEvent = e); + + var attributes = new UserAttributes + { + { DEVICE_TYPE_ATTRIBUTE_KEY, "mobile" }, + { BROWSER_TYPE_ATTRIBUTE_KEY, "chrome" }, + }; + + var featureEnabled = _optimizely.IsFeatureEnabled(TEST_FEATURE_KEY, TEST_USER_ID, + attributes); + + Assert.IsTrue(featureEnabled, "Feature flag should be enabled for CMAB variation."); + _eventDispatcherMock.Verify(d => d.DispatchEvent(It.IsAny()), Times.Once, + "Impression event should be dispatched for IsFeatureEnabled calls."); + Assert.IsNotNull(impressionEvent, "Impression event should be captured."); + + var payload = JObject.Parse(impressionEvent.GetParamsAsJson()); + var cmabUuidToken = + payload.SelectToken("visitors[0].snapshots[0].decisions[0].metadata.cmab_uuid"); + + Assert.IsNotNull(cmabUuidToken, "Metadata should include CMAB UUID."); + Assert.AreEqual(TEST_CMAB_UUID, cmabUuidToken.Value()); + Assert.AreEqual(1, _cmabService.CallCount); + } + + /// + /// Verifies no impression event sent when DISABLE_DECISION_EVENT option is used + /// + [Test] + public void TestDecideWithCmabExperimentDisableDecisionEvent() + { + var userContext = CreateCmabUserContext(); + + var decision = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.DISABLE_DECISION_EVENT }); + + Assert.IsNotNull(decision); + _eventDispatcherMock.Verify(d => d.DispatchEvent(It.IsAny()), Times.Never, + "No impression event should be sent with DISABLE_DECISION_EVENT"); + Assert.AreEqual(1, _cmabService.CallCount); + Assert.IsTrue(_cmabService.OptionsPerCall[0] + .Contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)); + } + + /// + /// Verifies DecideForKeys works with mix of CMAB and non-CMAB flags + /// + [Test] + public void TestDecideForKeysMixedCmabAndNonCmab() + { + var userContext = CreateCmabUserContext(); + var featureKeys = new[] { TEST_FEATURE_KEY, "boolean_single_variable_feature" }; + var decisions = userContext.DecideForKeys(featureKeys); + + Assert.IsNotNull(decisions); + Assert.AreEqual(2, decisions.Count); + Assert.IsTrue(decisions.ContainsKey(TEST_FEATURE_KEY)); + Assert.IsTrue(decisions.ContainsKey("boolean_single_variable_feature")); + + var cmabDecision = decisions[TEST_FEATURE_KEY]; + var nonCmabDecision = decisions["boolean_single_variable_feature"]; + + Assert.IsNotNull(cmabDecision); + Assert.AreEqual(VARIATION_A_KEY, cmabDecision.VariationKey); + + Assert.IsNotNull(nonCmabDecision); + Assert.AreEqual(1, _cmabService.CallCount); + } + + /// + /// Verifies DecideAll includes CMAB experiment decisions + /// + [Test] + public void TestDecideAllIncludesCmabExperiments() + { + var userContext = CreateCmabUserContext(); + var decisions = userContext.DecideAll(); + + Assert.IsNotNull(decisions); + Assert.IsTrue(decisions.Count > 0, "Should return decisions for all feature flags"); + Assert.IsTrue(decisions.TryGetValue(TEST_FEATURE_KEY, out var cmabDecision)); + Assert.IsNotNull(cmabDecision); + Assert.AreEqual(VARIATION_A_KEY, cmabDecision.VariationKey); + Assert.GreaterOrEqual(_cmabService.CallCount, 1); + } + + /// + /// Verifies IGNORE_CMAB_CACHE option is passed correctly to decision flow + /// + [Test] + public void TestDecideWithCmabExperimentIgnoreCmabCache() + { + var userContext = CreateCmabUserContext(); + + var decision1 = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.IGNORE_CMAB_CACHE }); + var decision2 = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.IGNORE_CMAB_CACHE }); + + Assert.IsNotNull(decision1); + Assert.IsNotNull(decision2); + Assert.AreEqual(VARIATION_A_KEY, decision1.VariationKey); + Assert.AreEqual(VARIATION_A_KEY, decision2.VariationKey); + Assert.AreEqual(2, _cmabService.CallCount); + Assert.IsTrue(_cmabService.OptionsPerCall.All(options => + options.Contains(OptimizelyDecideOption.IGNORE_CMAB_CACHE))); + } + + /// + /// Verifies RESET_CMAB_CACHE option clears entire cache + /// + [Test] + public void TestDecideWithCmabExperimentResetCmabCache() + { + var userContext = CreateCmabUserContext(); + + var decision1 = userContext.Decide(TEST_FEATURE_KEY); + + var decision2 = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.RESET_CMAB_CACHE }); + + Assert.IsNotNull(decision1); + Assert.IsNotNull(decision2); + Assert.AreEqual(VARIATION_A_KEY, decision1.VariationKey); + Assert.AreEqual(VARIATION_A_KEY, decision2.VariationKey); + Assert.AreEqual(2, _cmabService.CallCount); + Assert.IsFalse(_cmabService.OptionsPerCall[0] + .Contains(OptimizelyDecideOption.RESET_CMAB_CACHE)); + Assert.IsTrue(_cmabService.OptionsPerCall[1] + .Contains(OptimizelyDecideOption.RESET_CMAB_CACHE)); + } + + /// + /// Verifies INVALIDATE_USER_CMAB_CACHE option is passed correctly to decision flow + /// + [Test] + public void TestDecideWithCmabExperimentInvalidateUserCmabCache() + { + // Arrange + var userContext1 = CreateCmabUserContext(); + var userContext2 = CreateCmabUserContext("other_user"); + + var decision1 = userContext1.Decide(TEST_FEATURE_KEY); + + var decision2 = userContext2.Decide(TEST_FEATURE_KEY); + + var decision3 = userContext1.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE }); + + Assert.IsNotNull(decision1); + Assert.IsNotNull(decision2); + Assert.IsNotNull(decision3); + Assert.AreEqual(3, _cmabService.CallCount); + Assert.IsTrue(_cmabService.OptionsPerCall[2] + .Contains(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE)); + } + + /// + /// Verifies User Profile Service integration with CMAB experiments + /// + [Test] + public void TestDecideWithCmabExperimentUserProfileService() + { + var userProfileServiceMock = new Mock(); + userProfileServiceMock.Setup(ups => ups.Save(It.IsAny>())) + .Callback>(_ => { }); + + var configManager = new FallbackProjectConfigManager(_config); + var optimizelyWithUps = new Optimizely(configManager, null, _eventDispatcherMock.Object, + _loggerMock.Object, _errorHandlerMock.Object, userProfileServiceMock.Object); + + var cmabService = new TestCmabService + { + DefaultDecision = new CmabDecision(VARIATION_A_ID, TEST_CMAB_UUID) + }; + var decisionService = new DecisionService(new Bucketer(_loggerMock.Object), + _errorHandlerMock.Object, userProfileServiceMock.Object, _loggerMock.Object, + cmabService); + SetDecisionService(optimizelyWithUps, decisionService); + + var userContext = CreateCmabUserContext(optimizely: optimizelyWithUps); + + var decision = userContext.Decide(TEST_FEATURE_KEY); + + Assert.IsNotNull(decision); + Assert.AreEqual(VARIATION_A_KEY, decision.VariationKey); + userProfileServiceMock.Verify(ups => ups.Save(It.IsAny>()), + Times.Never); + Assert.AreEqual(1, cmabService.CallCount); + } + + /// + /// Verifies IGNORE_USER_PROFILE_SERVICE option skips UPS lookup + /// + [Test] + public void TestDecideWithCmabExperimentIgnoreUserProfileService() + { + var userProfileServiceMock = new Mock(); + + var configManager = new FallbackProjectConfigManager(_config); + var optimizelyWithUps = new Optimizely(configManager, null, _eventDispatcherMock.Object, + _loggerMock.Object, _errorHandlerMock.Object, userProfileServiceMock.Object); + + var cmabService = new TestCmabService + { + DefaultDecision = new CmabDecision(VARIATION_A_ID, TEST_CMAB_UUID) + }; + var decisionService = new DecisionService(new Bucketer(_loggerMock.Object), + _errorHandlerMock.Object, userProfileServiceMock.Object, _loggerMock.Object, + cmabService); + SetDecisionService(optimizelyWithUps, decisionService); + + var userContext = CreateCmabUserContext(optimizely: optimizelyWithUps); + + var decision = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE }); + + Assert.IsNotNull(decision); + Assert.AreEqual(VARIATION_A_KEY, decision.VariationKey); + + userProfileServiceMock.Verify(ups => ups.Lookup(It.IsAny()), Times.Never); + Assert.AreEqual(1, cmabService.CallCount); + Assert.IsTrue(cmabService.OptionsPerCall[0] + .Contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE)); + } + + /// + /// Verifies INCLUDE_REASONS option includes CMAB decision info + /// + [Test] + public void TestDecideWithCmabExperimentIncludeReasons() + { + var userContext = CreateCmabUserContext(); + + var decision = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.INCLUDE_REASONS }); + + Assert.IsNotNull(decision); + Assert.IsNotNull(decision.Reasons); + Assert.AreEqual(1, _cmabService.CallCount); + } + + /// + /// Verifies error handling when CMAB service fails + /// + [Test] + public void TestDecideWithCmabErrorReturnsErrorDecision() + { + var userContext = CreateCmabUserContext(); + + _cmabService.ReturnNullNext = true; + + var decision = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.INCLUDE_REASONS }); + + Assert.IsNotNull(decision); + Assert.IsNull(decision.VariationKey); + Assert.IsTrue(decision.Reasons.Any(r => r.Contains( + string.Format(CmabConstants.CMAB_FETCH_FAILED, TEST_EXPERIMENT_KEY)))); + Assert.AreEqual(1, _cmabService.CallCount); + } + + /// + /// Verifies decision notification is called for CMAB experiments + /// + [Test] + public void TestDecideWithCmabExperimentDecisionNotification() + { + var notificationCenter = new NotificationCenter(_loggerMock.Object); + Dictionary capturedDecisionInfo = null; + + _notificationCallbackMock.Setup(nc => nc.TestDecisionCallback( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Callback((string type, string userId, UserAttributes attrs, + Dictionary decisionInfo) => capturedDecisionInfo = decisionInfo); + + notificationCenter.AddNotification( + NotificationCenter.NotificationType.Decision, + _notificationCallbackMock.Object.TestDecisionCallback); + + var configManager = new FallbackProjectConfigManager(_config); + var optimizelyWithNotifications = new Optimizely(configManager, notificationCenter, + _eventDispatcherMock.Object, _loggerMock.Object, _errorHandlerMock.Object); + + var cmabService = new TestCmabService + { + DefaultDecision = new CmabDecision(VARIATION_A_ID, TEST_CMAB_UUID) + }; + var decisionService = new DecisionService(new Bucketer(_loggerMock.Object), + _errorHandlerMock.Object, null, _loggerMock.Object, cmabService); + SetDecisionService(optimizelyWithNotifications, decisionService); + + var userContext = CreateCmabUserContext(optimizely: optimizelyWithNotifications); + + var decision = userContext.Decide(TEST_FEATURE_KEY); + + Assert.IsNotNull(decision); + Assert.AreEqual(TEST_FEATURE_KEY, decision.FlagKey); + Assert.AreEqual(VARIATION_A_KEY, decision.VariationKey); + _notificationCallbackMock.Verify(nc => nc.TestDecisionCallback( + DecisionNotificationTypes.FLAG, + TEST_USER_ID, + It.IsAny(), + It.IsAny>()), + Times.Once); + Assert.IsNotNull(capturedDecisionInfo); + Assert.AreEqual(TEST_FEATURE_KEY, capturedDecisionInfo["flagKey"]); + Assert.AreEqual(VARIATION_A_KEY, capturedDecisionInfo["variationKey"]); + Assert.AreEqual(1, cmabService.CallCount); + } + + private OptimizelyUserContext CreateCmabUserContext(string userId = TEST_USER_ID, + Optimizely optimizely = null, + IDictionary additionalAttributes = null) + { + var client = optimizely ?? _optimizely; + var userContext = client.CreateUserContext(userId); + + userContext.SetAttribute(BROWSER_TYPE_ATTRIBUTE_KEY, "chrome"); + userContext.SetAttribute(DEVICE_TYPE_ATTRIBUTE_KEY, "mobile"); + + if (additionalAttributes != null) + { + foreach (var kvp in additionalAttributes) + { + userContext.SetAttribute(kvp.Key, kvp.Value); + } + } + + return userContext; + } + + private static void SetDecisionService(Optimizely optimizely, DecisionService decisionService) + { + var decisionServiceField = typeof(Optimizely).GetField("DecisionService", + BindingFlags.NonPublic | BindingFlags.Instance); + decisionServiceField?.SetValue(optimizely, decisionService); + } + + private void ConfigureCmabExperiment(ProjectConfig config, + string experimentId, + string experimentKey, + int trafficAllocation = 10000, + IEnumerable attributeIds = null) + { + Assert.IsNotNull(config, "Project config should be available for CMAB tests."); + + var attributeList = attributeIds?.ToList() ?? + new List { DEVICE_TYPE_ATTRIBUTE_ID }; + + var experiment = config.ExperimentIdMap.TryGetValue(experimentId, out var existing) + ? existing + : config.GetExperimentFromKey(experimentKey); + + Assert.IsNotNull(experiment, $"Experiment {experimentKey} should exist for CMAB tests."); + + experiment.Cmab = new Entity.Cmab(attributeList, trafficAllocation); + config.ExperimentIdMap[experiment.Id] = experiment; + + if (config.ExperimentKeyMap.ContainsKey(experiment.Key)) + { + config.ExperimentKeyMap[experiment.Key] = experiment; + } + } + + private class TestCmabService : ICmabService + { + public int CallCount { get; private set; } + + public string LastRuleId { get; private set; } + + public OptimizelyUserContext LastUserContext { get; private set; } + + public List OptionsPerCall { get; } = + new List(); + + public Queue DecisionsQueue { get; } = new Queue(); + + public CmabDecision DefaultDecision { get; set; } + + public Exception ExceptionToThrow { get; set; } + + public bool ReturnNullNext { get; set; } + + public Func Handler { get; set; } + + public void EnqueueDecision(CmabDecision decision) + { + DecisionsQueue.Enqueue(decision); + } + + public CmabDecision GetDecision(ProjectConfig projectConfig, + OptimizelyUserContext userContext, + string ruleId, + OptimizelyDecideOption[] options = null) + { + CallCount++; + LastRuleId = ruleId; + LastUserContext = userContext; + var copiedOptions = options?.ToArray() ?? new OptimizelyDecideOption[0]; + OptionsPerCall.Add(copiedOptions); + + if (ExceptionToThrow != null) + { + var ex = ExceptionToThrow; + ExceptionToThrow = null; + throw ex; + } + + if (Handler != null) + { + return Handler(projectConfig, userContext, ruleId, copiedOptions); + } + + if (ReturnNullNext) + { + ReturnNullNext = false; + return null; + } + + if (DecisionsQueue.Count > 0) + { + return DecisionsQueue.Dequeue(); + } + + return DefaultDecision; + } + } + } +} diff --git a/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs b/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs index 3d34e151..5d8be677 100644 --- a/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs +++ b/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs @@ -65,7 +65,7 @@ public void Initialize() // Use real Bucketer instead of mock var realBucketer = new Bucketer(LoggerMock.Object); DecisionService = new DecisionService(realBucketer, - new ErrorHandler.NoOpErrorHandler(), null, LoggerMock.Object); + new ErrorHandler.NoOpErrorHandler(), null, LoggerMock.Object, null); // Create an Optimizely instance for creating user contexts var eventDispatcher = new Event.Dispatcher.DefaultEventDispatcher(LoggerMock.Object); diff --git a/OptimizelySDK.Tests/DecisionServiceTest.cs b/OptimizelySDK.Tests/DecisionServiceTest.cs index 8fbedf23..0378b93d 100644 --- a/OptimizelySDK.Tests/DecisionServiceTest.cs +++ b/OptimizelySDK.Tests/DecisionServiceTest.cs @@ -63,9 +63,9 @@ public void SetUp() WhitelistedVariation = WhitelistedExperiment.VariationKeyToVariationMap["vtag5"]; DecisionService = new DecisionService(new Bucketer(LoggerMock.Object), - ErrorHandlerMock.Object, null, LoggerMock.Object); + ErrorHandlerMock.Object, null, LoggerMock.Object, null); DecisionServiceMock = new Mock(BucketerMock.Object, - ErrorHandlerMock.Object, null, LoggerMock.Object) + ErrorHandlerMock.Object, null, LoggerMock.Object, null) { CallBase = true }; DecisionReasons = new DecisionReasons(); @@ -82,7 +82,7 @@ public void SetUp() public void TestFindValidatedForcedDecisionReturnsCorrectDecisionWithNullVariation() { var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -104,7 +104,7 @@ public void TestFindValidatedForcedDecisionReturnsCorrectDecisionWithNullVariati public void TestGetVariationForcedVariationPrecedesAudienceEval() { var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var experiment = ProjectConfig.Experiments[8]; var expectedVariation = experiment.Variations[0]; @@ -129,7 +129,7 @@ public void TestGetVariationForcedVariationPrecedesAudienceEval() WhitelistedUserId)), Times.Once); // no attributes provided for a experiment that has an audience - Assertions.AreEqual(expectedVariation, actualVariation.ResultObject); + Assertions.AreEqual(expectedVariation, actualVariation.ResultObject.Variation); BucketerMock.Verify( _ => _.Bucket(It.IsAny(), It.IsAny(), It.IsAny(), @@ -148,7 +148,7 @@ public void TestGetVariationLogsErrorWhenUserProfileMapItsNull() UserProfileServiceMock.Setup(up => up.Lookup(WhitelistedUserId)).Returns(userProfile); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); var options = new OptimizelyDecideOption[] { OptimizelyDecideOption.INCLUDE_REASONS }; var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -192,7 +192,7 @@ public void TestGetVariationEvaluatesUserProfileBeforeAudienceTargeting() Returns(userProfile.ToMap()); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); decisionService.GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig); @@ -216,7 +216,7 @@ public void TestGetVariationEvaluatesUserProfileBeforeAudienceTargeting() public void TestGetForcedVariationReturnsForcedVariation() { var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var expectedVariation = decisionService. GetWhitelistedVariation(WhitelistedExperiment, WhitelistedUserId). ResultObject; @@ -241,7 +241,7 @@ public void TestGetForcedVariationWithInvalidVariation() var invalidVariationKey = "invalidVarKey"; var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var variations = new Variation[] { @@ -286,7 +286,7 @@ public void TestGetForcedVariationReturnsNullWhenUserIsNotWhitelisted() { var bucketer = new Bucketer(LoggerMock.Object); var decisionService = new DecisionService(bucketer, ErrorHandlerMock.Object, null, - LoggerMock.Object); + LoggerMock.Object, null); Assert.IsNull(decisionService. GetWhitelistedVariation(WhitelistedExperiment, GenericUserId). @@ -323,11 +323,11 @@ public void TestBucketReturnsVariationStoredInUserProfile() OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns(UserProfileId); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); var actualVariation = decisionService.GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig); - Assertions.AreEqual(variation, actualVariation.ResultObject); + Assertions.AreEqual(variation, actualVariation.ResultObject.Variation); Assert.AreEqual(actualVariation.DecisionReasons.ToReport(true).Count, 1); Assert.AreEqual(actualVariation.DecisionReasons.ToReport(true)[0], @@ -352,7 +352,7 @@ public void TestGetStoredVariationLogsWhenLookupReturnsNull() UserProfileServiceMock.Setup(_ => _.Lookup(UserProfileId)).Returns(userProfile.ToMap()); var decisionService = new DecisionService(bucketer, - ErrorHandlerMock.Object, userProfileService, LoggerMock.Object); + ErrorHandlerMock.Object, userProfileService, LoggerMock.Object, null); Assert.IsNull(decisionService. GetStoredVariation(experiment, userProfile, ProjectConfig). @@ -382,7 +382,7 @@ public void TestGetStoredVariationReturnsNullWhenVariationIsNoLongerInConfig() Returns(storedUserProfile.ToMap()); var decisionService = new DecisionService(bucketer, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); Assert.IsNull(decisionService. GetStoredVariation(experiment, storedUserProfile, ProjectConfig). ResultObject); @@ -416,14 +416,14 @@ public void TestGetVariationSavesBucketedVariationIntoUserProfile() Returns(variation); var decisionService = new DecisionService(mockBucketer.Object, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns(UserProfileId); Assert.IsTrue(TestData.CompareObjects(variation.ResultObject, decisionService. GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig). - ResultObject)); + ResultObject.Variation)); LoggerMock.Verify(l => l.Log(LogLevel.INFO, string.Format( "Saved variation \"{0}\" of experiment \"{1}\" for user \"{2}\".", @@ -453,7 +453,7 @@ public void TestBucketLogsCorrectlyWhenUserProfileFailsToSave() new UserProfile(UserProfileId, new Dictionary()); var decisionService = new DecisionService(bucketer, - ErrorHandlerMock.Object, UserProfileServiceMock.Object, LoggerMock.Object); + ErrorHandlerMock.Object, UserProfileServiceMock.Object, LoggerMock.Object, null); decisionService.SaveVariation(experiment, variation, saveUserProfile); @@ -489,13 +489,12 @@ public void TestGetVariationSavesANewUserProfile() UserProfileServiceMock.Setup(up => up.Lookup(UserProfileId)).Returns(userProfile); var decisionService = new DecisionService(mockBucketer.Object, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns(UserProfileId); var actualVariation = decisionService.GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig); - - Assertions.AreEqual(variation.ResultObject, actualVariation.ResultObject); + Assertions.AreEqual(variation.ResultObject, actualVariation.ResultObject.Variation); UserProfileServiceMock.Verify(_ => _.Save(It.IsAny>()), Times.Once); @@ -650,7 +649,7 @@ public void TestGetVariationWithBucketingId() UserProfileServiceMock.Setup(up => up.Lookup(userId)). Returns(storedUserProfile.ToMap()); var decisionService = new DecisionService(bucketerMock.Object, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); actualVariation = optlyObject.GetVariation(experimentKey, userId, userAttributesWithBucketingId); @@ -732,10 +731,11 @@ public void TestGetVariationForFeatureExperimentGivenNonMutexGroupAndUserNotBuck public void TestGetVariationForFeatureExperimentGivenNonMutexGroupAndUserIsBucketed() { var experiment = ProjectConfig.GetExperimentFromKey("test_experiment_multivariate"); - var variation = Result.NewResult( - ProjectConfig.GetVariationFromId("test_experiment_multivariate", "122231"), + var variationObj = ProjectConfig.GetVariationFromId("test_experiment_multivariate", "122231"); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), DecisionReasons); - var expectedDecision = new FeatureDecision(experiment, variation.ResultObject, + var expectedDecision = new FeatureDecision(experiment, variationObj, FeatureDecision.DECISION_SOURCE_FEATURE_TEST); var userAttributes = new UserAttributes(); @@ -770,10 +770,12 @@ public void TestGetVariationForFeatureExperimentGivenNonMutexGroupAndUserIsBucke public void TestGetVariationForFeatureExperimentGivenMutexGroupAndUserIsBucketed() { var mutexExperiment = ProjectConfig.GetExperimentFromKey("group_experiment_1"); - var variation = - Result.NewResult(mutexExperiment.Variations[0], DecisionReasons); + var variationObj = mutexExperiment.Variations[0]; + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var userAttributes = new UserAttributes(); - var expectedDecision = new FeatureDecision(mutexExperiment, variation.ResultObject, + var expectedDecision = new FeatureDecision(mutexExperiment, variationObj, FeatureDecision.DECISION_SOURCE_FEATURE_TEST); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), @@ -816,7 +818,7 @@ public void TestGetVariationForFeatureExperimentGivenMutexGroupAndUserNotBuckete It.IsAny(), ProjectConfig, It.IsAny(), It.IsAny(), It.IsAny())). - Returns(Result.NullResult(null)); + Returns(Result.NullResult(null)); var featureFlag = ProjectConfig.GetFeatureFlagFromKey("boolean_feature"); var actualDecision = DecisionServiceMock.Object.GetVariationForFeatureExperiment( @@ -850,7 +852,7 @@ public void TestGetVariationForFeatureRolloutWhenNoRuleInRollouts() var optimizelyUserContext = new OptimizelyUserContext(optlyObject, "userId1", null, ErrorHandlerMock.Object, LoggerMock.Object); var decisionService = new DecisionService(new Bucketer(new NoOpLogger()), - new NoOpErrorHandler(), null, new NoOpLogger()); + new NoOpErrorHandler(), null, new NoOpLogger(), null); var variation = decisionService.GetVariationForFeatureRollout(featureFlag, optimizelyUserContext, @@ -914,7 +916,7 @@ public void TestGetVariationForFeatureRolloutWhenUserIsBucketedInTheTargetingRul It.IsAny())). Returns(variation); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -957,7 +959,7 @@ public void It.IsAny(), It.IsAny())). Returns(variation); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -989,7 +991,7 @@ public void It.IsAny(), It.IsAny())). Returns(Result.NullResult(null)); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -1027,7 +1029,7 @@ public void TestGetVariationForFeatureRolloutWhenUserDoesNotQualifyForAnyTargeti It.IsAny(), It.IsAny())). Returns(variation); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); // Provide null attributes so that user does not qualify for audience. var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), @@ -1063,7 +1065,7 @@ public void TestGetVariationForFeatureRolloutAudienceAndTrafficeAllocationCheck( var mockBucketer = new Mock(LoggerMock.Object) { CallBase = true }; mockBucketer.Setup(bm => bm.GenerateBucketValue(It.IsAny())).Returns(980); var decisionService = new DecisionService(mockBucketer.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); // Calling with audience iPhone users in San Francisco. var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), @@ -1148,7 +1150,7 @@ public void TestGetVariationForFeatureRolloutCheckAudienceInEveryoneElseRule() Returns(Result.NullResult(DecisionReasons)); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -1312,10 +1314,11 @@ public void TestGetVariationForFeatureWhenTheUserIsBuckedtedInBothExperimentAndR var featureFlag = ProjectConfig.GetFeatureFlagFromKey("string_single_variable_feature"); var experiment = ProjectConfig.GetExperimentFromKey("test_experiment_with_feature_rollout"); - var variation = Result.NewResult( - ProjectConfig.GetVariationFromId("test_experiment_with_feature_rollout", "122236"), + var variationObj = ProjectConfig.GetVariationFromId("test_experiment_with_feature_rollout", "122236"); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), DecisionReasons); - var expectedDecision = new FeatureDecision(experiment, variation.ResultObject, + var expectedDecision = new FeatureDecision(experiment, variationObj, FeatureDecision.DECISION_SOURCE_FEATURE_TEST); var userAttributes = new UserAttributes { @@ -1332,7 +1335,7 @@ public void TestGetVariationForFeatureWhenTheUserIsBuckedtedInBothExperimentAndR BucketerMock. Setup(bm => bm.Bucket(ProjectConfig, experiment, It.IsAny(), It.IsAny())). - Returns(variation); + Returns(Result.NewResult(variationObj, DecisionReasons)); DecisionServiceMock.Setup(ds => ds.GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig, diff --git a/OptimizelySDK.Tests/OptimizelyFactoryTest.cs b/OptimizelySDK.Tests/OptimizelyFactoryTest.cs index e1cc7ebf..b52ad568 100644 --- a/OptimizelySDK.Tests/OptimizelyFactoryTest.cs +++ b/OptimizelySDK.Tests/OptimizelyFactoryTest.cs @@ -16,14 +16,20 @@ */ using System; +using System.Reflection; using Moq; using NUnit.Framework; +using OptimizelySDK.Bucketing; +using OptimizelySDK.Cmab; using OptimizelySDK.Config; using OptimizelySDK.Event; using OptimizelySDK.Event.Dispatcher; +using OptimizelySDK.ErrorHandler; using OptimizelySDK.Logger; using OptimizelySDK.Notifications; +using OptimizelySDK.Odp; using OptimizelySDK.Tests.ConfigTest; +using OptimizelySDK.Utils; using OptimizelySDK.Tests.EventTest; using OptimizelySDK.Tests.Utils; @@ -39,6 +45,13 @@ public void Initialize() { LoggerMock = new Mock(); LoggerMock.Setup(i => i.Log(It.IsAny(), It.IsAny())); + ResetCmabConfiguration(); + } + + [TearDown] + public void Cleanup() + { + ResetCmabConfiguration(); } [Test] @@ -244,5 +257,84 @@ public void TestGetFeatureVariableJSONEmptyDatafileTest() "userId")); optimizely.Dispose(); } + + [Test] + public void SetCmabConfigStoresCacheSizeAndTtl() + { + const int cacheSize = 1234; + var cacheTtl = TimeSpan.FromSeconds(45); + + var cmabConfig = new CmabConfig() + .SetCacheSize(cacheSize) + .SetCacheTtl(cacheTtl); + OptimizelyFactory.SetCmabConfig(cmabConfig); + + var config = GetCurrentCmabConfiguration(); + + Assert.IsNotNull(config); + Assert.AreEqual(cacheSize, config.CacheSize); + Assert.AreEqual(cacheTtl, config.CacheTtl); + Assert.IsNull(config.Cache); + } + + [Test] + public void SetCmabConfigStoresCustomCacheInstance() + { + var customCache = new LruCache(maxSize: 10, itemTimeout: TimeSpan.FromMinutes(2)); + + var cmabConfig = new CmabConfig() + .SetCache(customCache); + OptimizelyFactory.SetCmabConfig(cmabConfig); + + var config = GetCurrentCmabConfiguration(); + + Assert.IsNotNull(config); + Assert.AreSame(customCache, config.Cache); + Assert.IsNull(config.CacheSize); + Assert.IsNull(config.CacheTtl); + } + + [Test] + public void NewDefaultInstanceUsesConfiguredCmabCache() + { + const int cacheSize = 7; + var cacheTtl = TimeSpan.FromSeconds(30); + var cmabConfig = new CmabConfig() + .SetCacheSize(cacheSize) + .SetCacheTtl(cacheTtl); + OptimizelyFactory.SetCmabConfig(cmabConfig); + + var logger = new NoOpLogger(); + var errorHandler = new NoOpErrorHandler(); + var projectConfig = DatafileProjectConfig.Create(TestData.Datafile, logger, errorHandler); + var configManager = new FallbackProjectConfigManager(projectConfig); + + var optimizely = OptimizelyFactory.NewDefaultInstance(configManager, logger: logger, errorHandler: errorHandler); + + var decisionService = Reflection.GetFieldValue(optimizely, "DecisionService"); + Assert.IsNotNull(decisionService); + + var cmabService = Reflection.GetFieldValue(decisionService, "CmabService"); + Assert.IsInstanceOf(cmabService); + + var cache = Reflection.GetFieldValue, DefaultCmabService>((DefaultCmabService)cmabService, "_cmabCache") as LruCache; + Assert.IsNotNull(cache); + Assert.AreEqual(cacheSize, cache.MaxSizeForTesting); + Assert.AreEqual(cacheTtl, cache.TimeoutForTesting); + + optimizely.Dispose(); + } + + private static void ResetCmabConfiguration() + { + var field = typeof(OptimizelyFactory).GetField("CmabConfiguration", BindingFlags.NonPublic | BindingFlags.Static); + field?.SetValue(null, null); + } + + private static CmabConfig GetCurrentCmabConfiguration() + { + var field = typeof(OptimizelyFactory).GetField("CmabConfiguration", BindingFlags.NonPublic | BindingFlags.Static); + return field?.GetValue(null) as CmabConfig; + } } } diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj index 1b0b882e..c250d0e1 100644 --- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj +++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj @@ -24,7 +24,7 @@ full false bin\Debug\ - DEBUG;TRACE + DEBUG;TRACE;USE_CMAB prompt 4 @@ -32,7 +32,7 @@ pdbonly true bin\Release\ - TRACE + TRACE;USE_CMAB prompt 4 @@ -70,8 +70,12 @@ + + + + diff --git a/OptimizelySDK.Tests/OptimizelyTest.cs b/OptimizelySDK.Tests/OptimizelyTest.cs index 034b4bc0..3025dc89 100644 --- a/OptimizelySDK.Tests/OptimizelyTest.cs +++ b/OptimizelySDK.Tests/OptimizelyTest.cs @@ -115,7 +115,7 @@ public void Initialize() DecisionServiceMock = new Mock(new Bucketer(LoggerMock.Object), ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); NotificationCenter = new NotificationCenter(LoggerMock.Object); NotificationCallbackMock = new Mock(); @@ -3371,12 +3371,13 @@ public void TestActivateListener(UserAttributes userAttributes) var variationKey = "group_exp_1_var_1"; var featureKey = "boolean_feature"; var experiment = Config.GetExperimentFromKey(experimentKey); - var variation = - Result.NewResult(Config.GetVariationFromKey(experimentKey, variationKey), - DecisionReasons); + var variationObj = Config.GetVariationFromKey(experimentKey, variationKey); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var featureFlag = Config.GetFeatureFlagFromKey(featureKey); var decision = Result.NewResult( - new FeatureDecision(experiment, variation.ResultObject, + new FeatureDecision(experiment, variationObj, FeatureDecision.DECISION_SOURCE_FEATURE_TEST), DecisionReasons); // Mocking objects. @@ -3424,10 +3425,10 @@ public void TestActivateListener(UserAttributes userAttributes) NotificationCallbackMock.Verify( nc => nc.TestActivateCallback(experiment, TestUserId, userAttributes, - variation.ResultObject, It.IsAny()), Times.Exactly(2)); + variation.ResultObject.Variation, It.IsAny()), Times.Exactly(2)); NotificationCallbackMock.Verify( nc => nc.TestAnotherActivateCallback(experiment, TestUserId, userAttributes, - variation.ResultObject, It.IsAny()), Times.Exactly(2)); + variation.ResultObject.Variation, It.IsAny()), Times.Exactly(2)); } [Test] @@ -3487,9 +3488,10 @@ public void TestTrackListener(UserAttributes userAttributes, EventTags eventTags var variationKey = "control"; var eventKey = "purchase"; var experiment = Config.GetExperimentFromKey(experimentKey); - var variation = - Result.NewResult(Config.GetVariationFromKey(experimentKey, variationKey), - DecisionReasons); + var variationObj = Config.GetVariationFromKey(experimentKey, variationKey); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var logEvent = new LogEvent(EventFactory.EventEndpoints["US"], OptimizelyHelper.SingleParameter, "POST", new Dictionary()); @@ -3545,9 +3547,10 @@ public void TestActivateSendsDecisionNotificationWithActualVariationKey() var experimentKey = "test_experiment"; var variationKey = "variation"; var experiment = Config.GetExperimentFromKey(experimentKey); - var variation = - Result.NewResult(Config.GetVariationFromKey(experimentKey, variationKey), - DecisionReasons); + var variationObj = Config.GetVariationFromKey(experimentKey, variationKey); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var userAttributes = new UserAttributes { { @@ -3602,9 +3605,10 @@ public void var experimentKey = "group_experiment_1"; var variationKey = "group_exp_1_var_1"; var experiment = Config.GetExperimentFromKey(experimentKey); - var variation = - Result.NewResult(Config.GetVariationFromKey(experimentKey, variationKey), - DecisionReasons); + var variationObj = Config.GetVariationFromKey(experimentKey, variationKey); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var userAttributes = new UserAttributes { { @@ -3668,7 +3672,7 @@ public void TestActivateSendsDecisionNotificationWithNullVariationKey() DecisionServiceMock.Setup(ds => ds.GetVariation(experiment, It.IsAny(), It.IsAny(), null)) - .Returns(Result.NullResult(null)); + .Returns(Result.NullResult(null)); optStronglyTyped.NotificationCenter.AddNotification( NotificationCenter.NotificationType.Decision, @@ -3697,9 +3701,10 @@ public void TestGetVariationSendsDecisionNotificationWithActualVariationKey() var experimentKey = "test_experiment"; var variationKey = "variation"; var experiment = Config.GetExperimentFromKey(experimentKey); - var variation = - Result.NewResult(Config.GetVariationFromKey(experimentKey, variationKey), - DecisionReasons); + var variationObj = Config.GetVariationFromKey(experimentKey, variationKey); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var userAttributes = new UserAttributes { { @@ -3760,9 +3765,10 @@ public void var experimentKey = "group_experiment_1"; var variationKey = "group_exp_1_var_1"; var experiment = Config.GetExperimentFromKey(experimentKey); - var variation = - Result.NewResult(Config.GetVariationFromKey(experimentKey, variationKey), - DecisionReasons); + var variationObj = Config.GetVariationFromKey(experimentKey, variationKey); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var userAttributes = new UserAttributes { { @@ -3830,7 +3836,7 @@ public void TestGetVariationSendsDecisionNotificationWithNullVariationKey() DecisionServiceMock.Setup(ds => ds.GetVariation(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Result.NullResult(null)); + .Returns(Result.NullResult(null)); //DecisionServiceMock.Setup(ds => ds.GetVariation(experiment, TestUserId, Config, null)).Returns(Result.NullResult(null)); optStronglyTyped.NotificationCenter.AddNotification( @@ -3859,12 +3865,13 @@ public void { var featureKey = "double_single_variable_feature"; var experiment = Config.GetExperimentFromKey("test_experiment_double_feature"); - var variation = Result.NewResult( - Config.GetVariationFromKey("test_experiment_double_feature", "control"), + var variationObj = Config.GetVariationFromKey("test_experiment_double_feature", "control"); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), DecisionReasons); var featureFlag = Config.GetFeatureFlagFromKey(featureKey); var decision = Result.NewResult( - new FeatureDecision(experiment, variation.ResultObject, + new FeatureDecision(experiment, variationObj, FeatureDecision.DECISION_SOURCE_FEATURE_TEST), DecisionReasons); DecisionServiceMock.Setup(ds => @@ -3920,12 +3927,13 @@ public void { var featureKey = "double_single_variable_feature"; var experiment = Config.GetExperimentFromKey("test_experiment_double_feature"); - var variation = Result.NewResult( - Config.GetVariationFromKey("test_experiment_double_feature", "variation"), + var variationObj = Config.GetVariationFromKey("test_experiment_double_feature", "variation"); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), DecisionReasons); var featureFlag = Config.GetFeatureFlagFromKey(featureKey); var decision = Result.NewResult( - new FeatureDecision(experiment, variation.ResultObject, + new FeatureDecision(experiment, variationObj, FeatureDecision.DECISION_SOURCE_FEATURE_TEST), DecisionReasons); DecisionServiceMock.Setup(ds => diff --git a/OptimizelySDK/Bucketing/Bucketer.cs b/OptimizelySDK/Bucketing/Bucketer.cs index f891fc76..d354d4be 100644 --- a/OptimizelySDK/Bucketing/Bucketer.cs +++ b/OptimizelySDK/Bucketing/Bucketer.cs @@ -105,34 +105,34 @@ IEnumerable trafficAllocations } /// - /// Determine variation the user should be put in. + /// Bucket user to an entity ID based on traffic allocations. + /// This method is used for CMAB experiments where we need to determine if a user + /// is in the traffic allocation before fetching the CMAB decision. /// /// ProjectConfig Configuration for the project - /// Experiment Experiment in which user is to be bucketed + /// Experiment in which user is to be bucketed /// 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, ExperimentCore experiment, - string bucketingId, string userId + /// Traffic allocations to use for bucketing + /// Entity ID (string) if user is bucketed, null otherwise + public virtual Result BucketToEntityId(ProjectConfig config, ExperimentCore experiment, + string bucketingId, string userId, IEnumerable trafficAllocations ) { string message; - Variation variation; - var reasons = new DecisionReasons(); - if (string.IsNullOrEmpty(experiment.Key)) + if (string.IsNullOrEmpty(experiment?.Key)) { - return Result.NewResult(new Variation(), reasons); + return Result.NullResult(reasons); } - // Determine if experiment is in a mutually exclusive group. if (experiment is Experiment exp && exp.IsInMutexGroup) { var group = config.GetGroup(exp.GroupId); - if (string.IsNullOrEmpty(group.Id)) + if (string.IsNullOrEmpty(group?.Id)) { - return Result.NewResult(new Variation(), reasons); + return Result.NullResult(reasons); } var userExperimentId = @@ -141,7 +141,7 @@ public virtual Result Bucket(ProjectConfig config, ExperimentCore exp { message = $"User [{userId}] is in no experiment."; Logger.Log(LogLevel.INFO, reasons.AddInfo(message)); - return Result.NewResult(new Variation(), reasons); + return Result.NullResult(reasons); } if (userExperimentId != experiment.Id) @@ -149,7 +149,7 @@ public virtual Result Bucket(ProjectConfig config, ExperimentCore exp message = $"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); + return Result.NullResult(reasons); } message = @@ -157,21 +157,58 @@ public virtual Result Bucket(ProjectConfig config, ExperimentCore exp Logger.Log(LogLevel.INFO, reasons.AddInfo(message)); } - // Bucket user if not in whitelist and in group (if any). - var variationId = FindBucket(bucketingId, userId, experiment.Id, + var entityId = FindBucket(bucketingId, userId, experiment.Id, trafficAllocations); + + if (string.IsNullOrEmpty(entityId)) + { + Logger.Log(LogLevel.INFO, reasons.AddInfo($"User [{userId}] is in no variation.")); + return Result.NullResult(reasons); + } + + return Result.NewResult(entityId, reasons); + } + + /// + /// Determine variation the user should be put in. + /// + /// ProjectConfig Configuration for the project + /// Experiment Experiment in which user is to be bucketed + /// 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, ExperimentCore experiment, + string bucketingId, string userId + ) + { + string message; + var reasons = new DecisionReasons(); + + if (string.IsNullOrEmpty(experiment?.Key)) + { + return Result.NewResult(new Variation(), reasons); + } + + var bucketResult = BucketToEntityId(config, experiment, bucketingId, userId, experiment.TrafficAllocation); + + reasons += bucketResult.DecisionReasons; + + var variationId = bucketResult.ResultObject; + if (string.IsNullOrEmpty(variationId)) { - Logger.Log(LogLevel.INFO, reasons.AddInfo($"User [{userId}] is in no variation.")); return Result.NewResult(new Variation(), reasons); } - // success! - variation = config.GetVariationFromIdByExperimentId(experiment.Id, variationId); + var variation = config.GetVariationFromIdByExperimentId(experiment.Id, variationId); message = $"User [{userId}] is in variation [{variation.Key}] of experiment [{experiment.Key}]."; + Logger.Log(LogLevel.INFO, reasons.AddInfo(message)); - return Result.NewResult(variation, reasons); + + var result = Result.NewResult(variation, reasons); + + return result; } } } diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs index 7bc8054b..9b7f8785 100644 --- a/OptimizelySDK/Bucketing/DecisionService.cs +++ b/OptimizelySDK/Bucketing/DecisionService.cs @@ -14,6 +14,11 @@ * limitations under the License. */ +#if !(NET35 || NET40 || NETSTANDARD1_6) +#define USE_CMAB +#endif + + using System; using System.Collections.Generic; using System.Linq; @@ -24,6 +29,11 @@ using OptimizelySDK.Utils; using static OptimizelySDK.Entity.Holdout; +#if USE_CMAB +using OptimizelySDK.Cmab; +#endif + + namespace OptimizelySDK.Bucketing { /// @@ -45,18 +55,20 @@ public class DecisionService private IErrorHandler ErrorHandler; private UserProfileService UserProfileService; private ILogger Logger; +#if USE_CMAB + private ICmabService CmabService; +#endif /// - /// Associative array of user IDs to an associative array - /// of experiments to variations.This contains all the forced variations - /// set by the user by calling setForcedVariation (it is not the same as the - /// whitelisting forcedVariations data structure in the Experiments class). + /// Associative array of user IDs to an associative array + /// of experiments to variations.This contains all the forced variations + /// set by the user by calling setForcedVariation (it is not the same as the + /// whitelisting forcedVariations data structure in the Experiments class). /// #if NET35 private Dictionary> ForcedVariationMap; #else - private System.Collections.Concurrent.ConcurrentDictionary> ForcedVariationMap; + private System.Collections.Concurrent.ConcurrentDictionary> ForcedVariationMap; #endif /// @@ -64,16 +76,23 @@ public class DecisionService /// /// Base bucketer to allocate new users to an experiment. /// The error handler of the Optimizely client. - /// - /// < param name= "logger" > UserProfileService implementation for storing user info. + /// UserProfileService implementation for storing user info. + /// Logger for logging messages. + /// CMAB service for fetching CMAB decisions. Optional. public DecisionService(Bucketer bucketer, IErrorHandler errorHandler, UserProfileService userProfileService, ILogger logger +#if USE_CMAB + , ICmabService cmabService = null +#endif ) { Bucketer = bucketer; ErrorHandler = errorHandler; UserProfileService = userProfileService; Logger = logger; +#if USE_CMAB + CmabService = cmabService; +#endif #if NET35 ForcedVariationMap = new Dictionary>(); #else @@ -89,8 +108,8 @@ public DecisionService(Bucketer bucketer, IErrorHandler errorHandler, /// The Experiment the user will be bucketed into. /// Optimizely user context. /// Project config. - /// The Variation the user is allocated into. - public virtual Result GetVariation(Experiment experiment, + /// The VariationDecisionResult containing variation and CMAB metadata. + public virtual Result GetVariation(Experiment experiment, OptimizelyUserContext user, ProjectConfig config ) @@ -105,8 +124,8 @@ ProjectConfig config /// Optimizely user context. /// Project Config. /// An array of decision options. - /// - public virtual Result GetVariation(Experiment experiment, + /// The VariationDecisionResult containing variation and CMAB metadata. + public virtual Result GetVariation(Experiment experiment, OptimizelyUserContext user, ProjectConfig config, OptimizelyDecideOption[] options @@ -145,8 +164,8 @@ OptimizelyDecideOption[] options /// An array of decision options. /// A UserProfileTracker object. /// Set of reasons for the decision. - /// The Variation the user is allocated into. - public virtual Result GetVariation(Experiment experiment, + /// The VariationDecisionResult containing variation and CMAB metadata. + public virtual Result GetVariation(Experiment experiment, OptimizelyUserContext user, ProjectConfig config, OptimizelyDecideOption[] options, @@ -163,7 +182,7 @@ public virtual Result GetVariation(Experiment experiment, { var message = reasons.AddInfo($"Experiment {experiment.Key} is not running."); Logger.Log(LogLevel.INFO, message); - return Result.NullResult(reasons); + return Result.NullResult(reasons); } var userId = user.GetUserId(); @@ -181,8 +200,8 @@ public virtual Result GetVariation(Experiment experiment, if (variation != null) { - decisionVariation.SetReasons(reasons); - return decisionVariation; + return Result.NewResult( + new VariationDecisionResult(variation), reasons); } if (userProfileTracker != null) @@ -193,7 +212,8 @@ public virtual Result GetVariation(Experiment experiment, variation = decisionVariation.ResultObject; if (variation != null) { - return decisionVariation; + return Result.NewResult( + new VariationDecisionResult(variation), reasons); } } @@ -205,6 +225,19 @@ public virtual Result GetVariation(Experiment experiment, { var bucketingId = GetBucketingId(userId, user.GetAttributes()).ResultObject; +#if USE_CMAB + if (experiment.Cmab != null) + { + var cmabDecisionResult = + GetDecisionForCmabExperiment(experiment, user, config, bucketingId, options); + reasons += cmabDecisionResult.DecisionReasons; + + return Result.NewResult( + cmabDecisionResult.ResultObject, reasons); + } +#endif + + // Standard (non-CMAB) bucketing decisionVariation = Bucketer.Bucket(config, experiment, bucketingId, userId); reasons += decisionVariation.DecisionReasons; variation = decisionVariation.ResultObject; @@ -220,17 +253,97 @@ public virtual Result GetVariation(Experiment experiment, Logger.Log(LogLevel.INFO, "This decision will not be saved since the UserProfileService is null."); } + + return Result.NewResult( + new VariationDecisionResult(variation), reasons); } - return decisionVariation.SetReasons(reasons); + return Result.NullResult(reasons); } Logger.Log(LogLevel.INFO, reasons.AddInfo( $"User \"{user.GetUserId()}\" does not meet conditions to be in experiment \"{experiment.Key}\".")); - return Result.NullResult(reasons); + return Result.NullResult(reasons); + } + + /// + /// Get decision for CMAB (Contextual Multi-Armed Bandit) experiment. + /// This method checks if the user is in the CMAB traffic allocation and fetches + /// the variation from the CMAB service. + /// + /// The CMAB experiment + /// Optimizely user context + /// Project config + /// Bucketing ID for the user + /// Decision options + /// Result containing VariationDecisionResult with variation, CMAB UUID, and error status +#if USE_CMAB + private Result GetDecisionForCmabExperiment( + Experiment experiment, + OptimizelyUserContext user, + ProjectConfig config, + string bucketingId, + OptimizelyDecideOption[] options + ) + { + var reasons = new DecisionReasons(); + var userId = user.GetUserId(); + + // dummy traffic allocation for CMAB + var cmabTrafficAllocation = new List + { + new TrafficAllocation + { + EntityId = "$", + EndOfRange = experiment.Cmab.TrafficAllocation, + }, + }; + + var bucketResult = Bucketer.BucketToEntityId(config, experiment, bucketingId, userId, + cmabTrafficAllocation); + reasons += bucketResult.DecisionReasons; + + var entityId = bucketResult.ResultObject; + if (string.IsNullOrEmpty(entityId)) + { + var message = string.Format(CmabConstants.USER_NOT_IN_CMAB_EXPERIMENT, userId, + experiment.Key); + Logger.Log(LogLevel.INFO, reasons.AddInfo(message)); + return Result.NewResult( + new VariationDecisionResult(null), reasons); + } + + try + { + var cmabDecision = CmabService.GetDecision(config, user, experiment.Id, options); + + var variation = config.GetVariationFromIdByExperimentId(experiment.Id, + cmabDecision.VariationId); + + if (variation == null) + { + var message = + $"User [{userId}] bucketed into invalid variation [{cmabDecision.VariationId}] for CMAB experiment [{experiment.Key}]."; + Logger.Log(LogLevel.INFO, reasons.AddInfo(message)); + return Result.NewResult( + new VariationDecisionResult(null), reasons); + } + + return Result.NewResult( + new VariationDecisionResult(variation, cmabDecision.CmabUuid), reasons); + } + catch (Exception) + { + var message = string.Format(CmabConstants.CMAB_FETCH_FAILED, experiment.Key); + reasons.AddError(message); + Logger.Log(LogLevel.ERROR, message); + return Result.NewResult( + new VariationDecisionResult(null, null, true), reasons); + } } +#endif /// /// Gets the forced variation for the given user and experiment. @@ -669,6 +782,9 @@ public virtual Result GetVariationForFeatureExperiment( { var experiment = config.GetExperimentFromId(experimentId); Variation decisionVariation = null; +#if USE_CMAB + string cmabUuid = null; +#endif if (string.IsNullOrEmpty(experiment.Key)) { @@ -691,7 +807,22 @@ public virtual Result GetVariationForFeatureExperiment( userProfileTracker); reasons += decisionResponse?.DecisionReasons; - decisionVariation = decisionResponse.ResultObject; + var variationResult = decisionResponse.ResultObject; + decisionVariation = variationResult?.Variation; +#if USE_CMAB + cmabUuid = variationResult?.CmabUuid; + + if (variationResult?.Error == true) + { + Logger.Log(LogLevel.ERROR, + reasons.AddInfo( + $"Failed to fetch CMAB decision for user \"{userId}\" in experiment \"{experiment.Key}\" of feature \"{featureFlag.Key}\".")); + + var errorDecision = new FeatureDecision(experiment, null, + FeatureDecision.DECISION_SOURCE_FEATURE_TEST, null, error: true); + return Result.NewResult(errorDecision, reasons); + } +#endif } if (!string.IsNullOrEmpty(decisionVariation?.Id)) @@ -700,8 +831,13 @@ public virtual Result GetVariationForFeatureExperiment( reasons.AddInfo( $"The user \"{userId}\" is bucketed into experiment \"{experiment.Key}\" of feature \"{featureFlag.Key}\".")); +#if USE_CMAB + var featureDecision = new FeatureDecision(experiment, decisionVariation, + FeatureDecision.DECISION_SOURCE_FEATURE_TEST, cmabUuid); +#else var featureDecision = new FeatureDecision(experiment, decisionVariation, FeatureDecision.DECISION_SOURCE_FEATURE_TEST); +#endif return Result.NewResult(featureDecision, reasons); } } diff --git a/OptimizelySDK/Bucketing/VariationDecisionResult.cs b/OptimizelySDK/Bucketing/VariationDecisionResult.cs new file mode 100644 index 00000000..ab3c404c --- /dev/null +++ b/OptimizelySDK/Bucketing/VariationDecisionResult.cs @@ -0,0 +1,51 @@ +/* + * 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.Entity; + +namespace OptimizelySDK.Bucketing +{ + /// + /// Represents the result of a variation decision, including CMAB-specific fields. + /// + public class VariationDecisionResult + { + public VariationDecisionResult(Variation variation, string cmabUuid = null, + bool error = false + ) + { + Variation = variation; + CmabUuid = cmabUuid; + Error = error; + } + + /// + /// The variation selected for the user. Null if no variation was selected. + /// + public Variation Variation { get; set; } + + /// + /// The CMAB UUID associated with this decision. Null for non-CMAB experiments. + /// + public string CmabUuid { get; set; } + + /// + /// Indicates whether an error occurred during the decision process. + /// False for successful decisions or when no error occurred. + /// + public bool Error { get; set; } + } +} diff --git a/OptimizelySDK/Cmab/CmabConfig.cs b/OptimizelySDK/Cmab/CmabConfig.cs new file mode 100644 index 00000000..55b7fc11 --- /dev/null +++ b/OptimizelySDK/Cmab/CmabConfig.cs @@ -0,0 +1,80 @@ +/* + * 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 OptimizelySDK.Cmab; +using OptimizelySDK.Utils; + +namespace OptimizelySDK +{ + /// + /// Configuration options for CMAB (Contextual Multi-Armed Bandit) functionality. + /// + public class CmabConfig + { + /// + /// Gets or sets the maximum number of entries in the CMAB cache. + /// If null, the default value (1000) will be used. + /// + public int? CacheSize { get; private set; } + + /// + /// Gets or sets the time-to-live for CMAB cache entries. + /// If null, the default value (30 minutes) will be used. + /// + public TimeSpan? CacheTtl { get; private set; } + + /// + /// Gets or sets the custom cache implementation for CMAB decisions. + /// If provided, CacheSize and CacheTtl will be ignored. + /// + public ICacheWithRemove Cache { get; private set; } + + /// + /// Sets the maximum number of entries in the CMAB cache. + /// + /// Maximum number of entries in the cache. + /// This CmabConfig instance for method chaining. + public CmabConfig SetCacheSize(int cacheSize) + { + CacheSize = cacheSize; + return this; + } + + /// + /// Sets the time-to-live for CMAB cache entries. + /// + /// Time-to-live for cache entries. + /// This CmabConfig instance for method chaining. + public CmabConfig SetCacheTtl(TimeSpan cacheTtl) + { + CacheTtl = cacheTtl; + return this; + } + + /// + /// Sets a custom cache implementation for CMAB decisions. + /// When set, CacheSize and CacheTtl will be ignored. + /// + /// Custom cache implementation for CMAB decisions. + /// This CmabConfig instance for method chaining. + public CmabConfig SetCache(ICacheWithRemove cache) + { + Cache = cache ?? throw new ArgumentNullException(nameof(cache)); + return this; + } + } +} diff --git a/OptimizelySDK/Cmab/CmabConstants.cs b/OptimizelySDK/Cmab/CmabConstants.cs index 8c3659a1..0b7525a3 100644 --- a/OptimizelySDK/Cmab/CmabConstants.cs +++ b/OptimizelySDK/Cmab/CmabConstants.cs @@ -1,18 +1,18 @@ -/* -* 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. -*/ +/* + * 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; @@ -20,13 +20,24 @@ 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 PREDICTION_URL = "https://prediction.cmab.optimizely.com/predict"; + public const int DEFAULT_CACHE_SIZE = 10_000; + public const string CONTENT_TYPE = "application/json"; - public const string ContentTypeJson = "application/json"; + public const string ERROR_FETCH_FAILED_FMT = "CMAB decision fetch failed with status: {0}"; + public const string ERROR_INVALID_RESPONSE = "Invalid CMAB fetch response"; + public const string EXHAUST_RETRY_MESSAGE = "Exhausted all retries for CMAB request"; - 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"; + public const string USER_NOT_IN_CMAB_EXPERIMENT = + "User [{0}] not in CMAB experiment [{1}] due to traffic allocation."; + + public const string CMAB_FETCH_FAILED = + "Failed to fetch CMAB data for experiment {0}."; + + public static readonly TimeSpan MAX_TIMEOUT = TimeSpan.FromSeconds(10); + public static readonly TimeSpan DEFAULT_CACHE_TTL = TimeSpan.FromMinutes(30); + + public const int CMAB_MAX_RETRIES = 1; + public static readonly TimeSpan CMAB_INITIAL_BACKOFF = TimeSpan.FromMilliseconds(100); } } diff --git a/OptimizelySDK/Cmab/DefaultCmabClient.cs b/OptimizelySDK/Cmab/DefaultCmabClient.cs index 3faaec75..a06f2149 100644 --- a/OptimizelySDK/Cmab/DefaultCmabClient.cs +++ b/OptimizelySDK/Cmab/DefaultCmabClient.cs @@ -58,9 +58,9 @@ private async Task FetchDecisionAsync( string cmabUuid, TimeSpan? timeout = null) { - var url = $"{CmabConstants.PredictionUrl}/{ruleId}"; + var url = $"{CmabConstants.PREDICTION_URL}/{ruleId}"; var body = BuildRequestBody(ruleId, userId, attributes, cmabUuid); - var perAttemptTimeout = timeout ?? CmabConstants.MaxTimeout; + var perAttemptTimeout = timeout ?? CmabConstants.MAX_TIMEOUT; if (_retryConfig == null) { @@ -90,7 +90,7 @@ public string FetchDecision( private static StringContent BuildContent(object payload) { var json = JsonConvert.SerializeObject(payload); - return new StringContent(json, Encoding.UTF8, CmabConstants.ContentTypeJson); + return new StringContent(json, Encoding.UTF8, CmabConstants.CONTENT_TYPE); } private static CmabRequest BuildRequestBody(string ruleId, string userId, IDictionary attributes, string cmabUuid) @@ -135,8 +135,8 @@ private async Task DoFetchOnceAsync(string url, CmabRequest request, Tim 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)); + _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, status)); + throw new CmabFetchException(string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, status)); } var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false); @@ -144,8 +144,8 @@ private async Task DoFetchOnceAsync(string url, CmabRequest request, Tim var j = JObject.Parse(responseText); if (!ValidateResponse(j)) { - _logger.Log(LogLevel.ERROR, CmabConstants.ErrorInvalidResponse); - throw new CmabInvalidResponseException(CmabConstants.ErrorInvalidResponse); + _logger.Log(LogLevel.ERROR, CmabConstants.ERROR_INVALID_RESPONSE); + throw new CmabInvalidResponseException(CmabConstants.ERROR_INVALID_RESPONSE); } var variationIdToken = j["predictions"][0]["variation_id"]; @@ -153,7 +153,7 @@ private async Task DoFetchOnceAsync(string url, CmabRequest request, Tim } catch (JsonException ex) { - _logger.Log(LogLevel.ERROR, CmabConstants.ErrorInvalidResponse); + _logger.Log(LogLevel.ERROR, CmabConstants.ERROR_INVALID_RESPONSE); throw new CmabInvalidResponseException(ex.Message); } catch (CmabInvalidResponseException) @@ -162,13 +162,13 @@ private async Task DoFetchOnceAsync(string url, CmabRequest request, Tim } catch (HttpRequestException ex) { - _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); - throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); + _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, ex.Message)); + throw new CmabFetchException(string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, ex.Message)); } catch (Exception ex) { - _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); - throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); + _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, ex.Message)); + throw new CmabFetchException(string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, ex.Message)); } } } @@ -187,8 +187,8 @@ private async Task DoFetchWithRetryAsync(string url, CmabRequest request { 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.ERROR, string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, CmabConstants.EXHAUST_RETRY_MESSAGE)); + throw new CmabFetchException(string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, CmabConstants.EXHAUST_RETRY_MESSAGE)); } _logger.Log(LogLevel.INFO, $"Retrying CMAB request (attempt: {attempt + 1}) after {backoff.TotalSeconds} seconds..."); diff --git a/OptimizelySDK/Cmab/DefaultCmabService.cs b/OptimizelySDK/Cmab/DefaultCmabService.cs index 2cdf18c3..86b4d649 100644 --- a/OptimizelySDK/Cmab/DefaultCmabService.cs +++ b/OptimizelySDK/Cmab/DefaultCmabService.cs @@ -23,8 +23,8 @@ using OptimizelySDK; using OptimizelySDK.Entity; using OptimizelySDK.Logger; -using OptimizelySDK.Odp; using OptimizelySDK.OptimizelyDecisions; +using OptimizelySDK.Utils; using AttributeEntity = OptimizelySDK.Entity.Attribute; namespace OptimizelySDK.Cmab @@ -83,29 +83,70 @@ public class CmabCacheEntry /// public class DefaultCmabService : ICmabService { - private readonly LruCache _cmabCache; + /// + /// Number of lock stripes to use for concurrency control. + /// Using multiple locks reduces contention while ensuring the same user/rule combination always uses the same lock. + /// + private const int NUM_LOCK_STRIPES = 1000; + + private readonly ICacheWithRemove _cmabCache; private readonly ICmabClient _cmabClient; private readonly ILogger _logger; + private readonly object[] _locks; /// /// Initializes a new instance of the DefaultCmabService class. /// - /// LRU cache for storing CMAB decisions. + /// Cache for storing CMAB decisions. /// Client for fetching decisions from the CMAB prediction service. - /// Optional logger for recording service operations. - public DefaultCmabService(LruCache cmabCache, + /// Logger for recording service operations. + public DefaultCmabService(ICacheWithRemove cmabCache, ICmabClient cmabClient, - ILogger logger = null) + ILogger logger) { _cmabCache = cmabCache; _cmabClient = cmabClient; - _logger = logger ?? new NoOpLogger(); + _logger = logger; + _locks = Enumerable.Range(0, NUM_LOCK_STRIPES).Select(_ => new object()).ToArray(); + } + + /// + /// Calculate the lock index for a given user and rule combination. + /// Uses MurmurHash to ensure consistent lock selection for the same user/rule while distributing different combinations across locks. + /// + /// The user ID. + /// The experiment/rule ID. + /// The lock index in the range [0, NUM_LOCK_STRIPES). + internal int GetLockIndex(string userId, string ruleId) + { + var hashInput = $"{userId}{ruleId}"; + var murmer32 = Murmur.MurmurHash.Create32(0, true); + var data = Encoding.UTF8.GetBytes(hashInput); + var hash = murmer32.ComputeHash(data); + var hashValue = BitConverter.ToUInt32(hash, 0); + return (int)(hashValue % NUM_LOCK_STRIPES); } public CmabDecision GetDecision(ProjectConfig projectConfig, OptimizelyUserContext userContext, string ruleId, OptimizelyDecideOption[] options = null) + { + var lockIndex = GetLockIndex(userContext.GetUserId(), ruleId); + lock (_locks[lockIndex]) + { + return GetDecisionInternal(projectConfig, userContext, ruleId, options); + } + } + + /// + /// Internal implementation of GetDecision that performs the actual decision logic. + /// This method should only be called while holding the appropriate lock. + /// + private CmabDecision GetDecisionInternal(ProjectConfig projectConfig, + OptimizelyUserContext userContext, + string ruleId, + OptimizelyDecideOption[] options = null) { var optionSet = options ?? new OptimizelyDecideOption[0]; var filteredAttributes = FilterAttributes(projectConfig, userContext, ruleId); diff --git a/OptimizelySDK/Entity/Cmab.cs b/OptimizelySDK/Entity/Cmab.cs index f8caec87..ebe846bc 100644 --- a/OptimizelySDK/Entity/Cmab.cs +++ b/OptimizelySDK/Entity/Cmab.cs @@ -36,14 +36,14 @@ public class Cmab /// Determines what portion of traffic should be allocated to CMAB decision making. /// [JsonProperty("trafficAllocation")] - public int? TrafficAllocation { get; set; } + 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) + public Cmab(List attributeIds, int trafficAllocation) { AttributeIds = attributeIds ?? new List(); TrafficAllocation = trafficAllocation; diff --git a/OptimizelySDK/Entity/FeatureDecision.cs b/OptimizelySDK/Entity/FeatureDecision.cs index 6bdd8f4c..a536def6 100644 --- a/OptimizelySDK/Entity/FeatureDecision.cs +++ b/OptimizelySDK/Entity/FeatureDecision.cs @@ -24,12 +24,16 @@ public class FeatureDecision public ExperimentCore Experiment { get; } public Variation Variation { get; } public string Source { get; } + public string CmabUuid { get; } + public bool Error { get; } - public FeatureDecision(ExperimentCore experiment, Variation variation, string source) + public FeatureDecision(ExperimentCore experiment, Variation variation, string source, string cmabUuid = null, bool error = false) { Experiment = experiment; Variation = variation; Source = source; + CmabUuid = cmabUuid; + Error = error; } } } diff --git a/OptimizelySDK/Event/Entity/DecisionMetadata.cs b/OptimizelySDK/Event/Entity/DecisionMetadata.cs index 88b0a27c..0fc8f7d2 100644 --- a/OptimizelySDK/Event/Entity/DecisionMetadata.cs +++ b/OptimizelySDK/Event/Entity/DecisionMetadata.cs @@ -39,8 +39,11 @@ public class DecisionMetadata [JsonProperty("enabled")] public bool Enabled { get; private set; } + [JsonProperty("cmab_uuid", NullValueHandling = NullValueHandling.Ignore)] + public string CmabUuid { get; private set; } + public DecisionMetadata(string flagKey, string ruleKey, string ruleType, - string variationKey = "", bool enabled = false + string variationKey = "", bool enabled = false, string cmabUuid = null ) { FlagKey = flagKey; @@ -48,6 +51,7 @@ public DecisionMetadata(string flagKey, string ruleKey, string ruleType, RuleType = ruleType; VariationKey = variationKey; Enabled = enabled; + CmabUuid = cmabUuid; } } } diff --git a/OptimizelySDK/Event/UserEventFactory.cs b/OptimizelySDK/Event/UserEventFactory.cs index adb9c87b..c8063b88 100644 --- a/OptimizelySDK/Event/UserEventFactory.cs +++ b/OptimizelySDK/Event/UserEventFactory.cs @@ -59,6 +59,7 @@ public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig, /// The user's attributes /// experiment key or feature key /// experiment or featureDecision source + /// Optional CMAB UUID for contextual multi-armed bandit experiments /// ImpressionEvent instance public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig, ExperimentCore activatedExperiment, @@ -67,7 +68,8 @@ public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig, UserAttributes userAttributes, string flagKey, string ruleType, - bool enabled = false + bool enabled = false, + string cmabUuid = null ) { if ((ruleType == FeatureDecision.DECISION_SOURCE_ROLLOUT || variation == null) && @@ -91,7 +93,7 @@ public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig, ruleKey = activatedExperiment?.Key ?? string.Empty; } - var metadata = new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey, enabled); + var metadata = new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey, enabled, cmabUuid); return new ImpressionEvent.Builder().WithEventContext(eventContext). WithBotFilteringEnabled(projectConfig.BotFiltering). diff --git a/OptimizelySDK/Odp/LruCache.cs b/OptimizelySDK/Odp/LruCache.cs index 45b9be5d..e3f85754 100644 --- a/OptimizelySDK/Odp/LruCache.cs +++ b/OptimizelySDK/Odp/LruCache.cs @@ -22,7 +22,7 @@ namespace OptimizelySDK.Odp { - public class LruCache : ICache where T : class + public class LruCache : ICacheWithRemove where T : class { /// /// The maximum number of elements that should be stored diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index 4e0a0bce..a27e228b 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -16,6 +16,7 @@ #if !(NET35 || NET40 || NETSTANDARD1_6) #define USE_ODP +#define USE_CMAB #endif using OptimizelySDK.Bucketing; @@ -40,6 +41,10 @@ using OptimizelySDK.Odp; #endif +#if USE_CMAB +using OptimizelySDK.Cmab; +#endif + namespace OptimizelySDK { #if NET35 @@ -193,6 +198,7 @@ public Optimizely(string datafile, /// EventProcessor /// Default Decide options /// Optional ODP Manager + /// Optional CMAB Configuration public Optimizely(ProjectConfigManager configManager, NotificationCenter notificationCenter = null, IEventDispatcher eventDispatcher = null, @@ -203,15 +209,29 @@ public Optimizely(ProjectConfigManager configManager, OptimizelyDecideOption[] defaultDecideOptions = null #if USE_ODP , IOdpManager odpManager = null +#endif +#if USE_CMAB + , CmabConfig cmabConfig = null #endif ) { ProjectConfigManager = configManager; -#if USE_ODP +#if USE_ODP && USE_CMAB + InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService, + notificationCenter, eventProcessor, defaultDecideOptions, odpManager, null, cmabConfig); +#elif USE_ODP InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService, notificationCenter, eventProcessor, defaultDecideOptions, odpManager); +#elif USE_CMAB + InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService, + notificationCenter, eventProcessor, defaultDecideOptions, null, cmabConfig); +#else + InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService, + notificationCenter, eventProcessor, defaultDecideOptions); +#endif +#if USE_ODP var projectConfig = ProjectConfigManager.CachedProjectConfig; if (ProjectConfigManager.CachedProjectConfig != null) @@ -234,10 +254,6 @@ public Optimizely(ProjectConfigManager configManager, projectConfig.Segments.ToList()); }); } - -#else - InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService, - notificationCenter, eventProcessor, defaultDecideOptions); #endif } @@ -251,6 +267,10 @@ private void InitializeComponents(IEventDispatcher eventDispatcher = null, OptimizelyDecideOption[] defaultDecideOptions = null #if USE_ODP , IOdpManager odpManager = null +#endif +#if USE_CMAB + , ICmabService cmabService = null + , CmabConfig cmabConfig = null #endif ) { @@ -261,8 +281,35 @@ private void InitializeComponents(IEventDispatcher eventDispatcher = null, EventBuilder = new EventBuilder(Bucketer, Logger); UserProfileService = userProfileService; NotificationCenter = notificationCenter ?? new NotificationCenter(Logger); + +#if USE_CMAB + var config = cmabConfig ?? new CmabConfig(); + ICacheWithRemove cache; + + if (config.Cache != null) + { + cache = config.Cache; + } + else + { + var cacheSize = config.CacheSize ?? CmabConstants.DEFAULT_CACHE_SIZE; + var cacheTtl = config.CacheTtl ?? CmabConstants.DEFAULT_CACHE_TTL; + cache = new LruCache(cacheSize, cacheTtl, Logger); + } + + var cmabRetryConfig = new CmabRetryConfig(CmabConstants.CMAB_MAX_RETRIES, + CmabConstants.CMAB_INITIAL_BACKOFF); + var cmabClient = new DefaultCmabClient(null, cmabRetryConfig, Logger); + + cmabService = new DefaultCmabService(cache, cmabClient, Logger); + + DecisionService = + new DecisionService(Bucketer, ErrorHandler, userProfileService, Logger, + cmabService); +#else DecisionService = new DecisionService(Bucketer, ErrorHandler, userProfileService, Logger); +#endif EventProcessor = eventProcessor ?? new ForwardingEventProcessor(EventDispatcher, NotificationCenter, Logger); @@ -442,8 +489,9 @@ private Variation GetVariation(string experimentKey, string userId, ProjectConfi userAttributes = userAttributes ?? new UserAttributes(); var userContext = CreateUserContextCopy(userId, userAttributes); - var variation = DecisionService.GetVariation(experiment, userContext, config) + var variationResult = DecisionService.GetVariation(experiment, userContext, config) ?.ResultObject; + var variation = variationResult?.Variation; var decisionInfo = new Dictionary { { "experimentKey", experimentKey }, { "variationKey", variation?.Key }, @@ -598,7 +646,11 @@ public virtual bool IsFeatureEnabled(string featureKey, string userId, }; SendImpressionEvent(decision?.Experiment, variation, userId, userAttributes, config, - featureKey, decisionSource, featureEnabled); + featureKey, decisionSource, featureEnabled +#if USE_CMAB + , decision?.CmabUuid +#endif + ); NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Decision, DecisionNotificationTypes.FEATURE, userId, @@ -986,6 +1038,27 @@ internal Dictionary DecideForKeys(OptimizelyUserCont var flagDecision = flagDecisions[key]; var decisionReasons = decisionReasonsMap[key]; + if (flagDecision?.Error == true) + { + var includeReasons = allOptions.Contains(OptimizelyDecideOption.INCLUDE_REASONS); + var reasonsToReport = decisionReasons.ToReport(includeReasons).ToArray(); + var errorDecision = OptimizelyDecision.NewErrorDecision( + key, + user, + reasonsToReport, + ErrorHandler, + Logger + ); + + if (!allOptions.Contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || + errorDecision.Enabled) + { + decisionDictionary.Add(key, errorDecision); + } + + continue; + } + var optimizelyDecision = CreateOptimizelyDecision(user, key, flagDecision, decisionReasons, allOptions.ToList(), projectConfig); if (!allOptions.Contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || @@ -1063,7 +1136,11 @@ ProjectConfig projectConfig projectConfig, flagKey, decisionSource, - flagEnabled); + flagEnabled +#if USE_CMAB + , flagDecision.CmabUuid +#endif + ); } var decisionInfo = new Dictionary @@ -1089,7 +1166,8 @@ ProjectConfig projectConfig ruleKey, flagKey, user, - reasonsToReport); + reasonsToReport + ); } private Result> GetDecisionVariableMap(FeatureFlag flag, Variation variation, bool featureEnabled) @@ -1162,9 +1240,13 @@ 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 + /// Optional CMAB UUID for contextual multi-armed bandit experiments private bool SendImpressionEvent(ExperimentCore experiment, Variation variation, string userId, UserAttributes userAttributes, ProjectConfig config, string flagKey, string ruleType, bool enabled +#if USE_CMAB + , string cmabUuid = null +#endif ) { if (experiment != null && !experiment.isRunning) @@ -1174,7 +1256,11 @@ private bool SendImpressionEvent(ExperimentCore experiment, Variation variation, } var userEvent = UserEventFactory.CreateImpressionEvent(config, experiment, variation, - userId, userAttributes, flagKey, ruleType, enabled); + userId, userAttributes, flagKey, ruleType, enabled +#if USE_CMAB + , cmabUuid +#endif + ); if (userEvent == null) { return false; diff --git a/OptimizelySDK/OptimizelyDecisions/OptimizelyDecision.cs b/OptimizelySDK/OptimizelyDecisions/OptimizelyDecision.cs index 7fe6c0c8..5e26432e 100644 --- a/OptimizelySDK/OptimizelyDecisions/OptimizelyDecision.cs +++ b/OptimizelySDK/OptimizelyDecisions/OptimizelyDecision.cs @@ -100,5 +100,28 @@ ILogger logger optimizelyUserContext, new string[] { error }); } + + /// + /// Static function to return OptimizelyDecision with multiple error reasons. + /// Similar to the single error overload but accepts an array of reasons. + /// OptimizelyDecision will have null variation key, false enabled, empty variables, null rule key + /// and the provided reasons array. + /// + public static OptimizelyDecision NewErrorDecision(string key, + OptimizelyUserContext optimizelyUserContext, + string[] reasons, + IErrorHandler errorHandler, + ILogger logger + ) + { + return new OptimizelyDecision( + null, + false, + new OptimizelyJSON(new Dictionary(), errorHandler, logger), + null, + key, + optimizelyUserContext, + reasons); + } } } diff --git a/OptimizelySDK/OptimizelyFactory.cs b/OptimizelySDK/OptimizelyFactory.cs index d42d85fd..7e2b682e 100644 --- a/OptimizelySDK/OptimizelyFactory.cs +++ b/OptimizelySDK/OptimizelyFactory.cs @@ -18,6 +18,10 @@ #define USE_ODP #endif +#if !(NET35 || NET40 || NETSTANDARD1_6) +#define USE_CMAB +#endif + using System; #if !NETSTANDARD1_6 && !NET35 using System.Configuration; @@ -35,6 +39,11 @@ using OptimizelySDK.Odp; #endif +#if USE_CMAB +using OptimizelySDK.Cmab; +using OptimizelySDK.Utils; +#endif + namespace OptimizelySDK { @@ -49,6 +58,9 @@ public static class OptimizelyFactory private static TimeSpan BlockingTimeOutPeriod; private static ILogger OptimizelyLogger; private const string ConfigSectionName = "optlySDKConfigSection"; +#if USE_CMAB + private static CmabConfig CmabConfiguration; +#endif #if !NETSTANDARD1_6 && !NET35 public static void SetBatchSize(int batchSize) @@ -76,6 +88,17 @@ public static void SetLogger(ILogger logger) OptimizelyLogger = logger; } +#if USE_CMAB + /// + /// Sets the CMAB (Contextual Multi-Armed Bandit) configuration. + /// + /// CMAB configuration with cache settings. + public static void SetCmabConfig(CmabConfig config) + { + CmabConfiguration = config; + } +#endif + public static Optimizely NewDefaultInstance() { var logger = OptimizelyLogger ?? new DefaultLogger(); @@ -224,13 +247,23 @@ public static Optimizely NewDefaultInstance(ProjectConfigManager configManager, UserProfileService userprofileService = null, EventProcessor eventProcessor = null ) { -#if USE_ODP +#if USE_ODP && USE_CMAB + var odpManager = new OdpManager.Builder() + .WithErrorHandler(errorHandler) + .WithLogger(logger) + .Build(); + return new Optimizely(configManager, notificationCenter, eventDispatcher, logger, + errorHandler, userprofileService, eventProcessor, null, odpManager, CmabConfiguration); +#elif USE_ODP var odpManager = new OdpManager.Builder() .WithErrorHandler(errorHandler) .WithLogger(logger) .Build(); return new Optimizely(configManager, notificationCenter, eventDispatcher, logger, errorHandler, userprofileService, eventProcessor, null, odpManager); +#elif USE_CMAB + return new Optimizely(configManager, notificationCenter, eventDispatcher, logger, + errorHandler, userprofileService, eventProcessor, null, CmabConfiguration); #else return new Optimizely(configManager, notificationCenter, eventDispatcher, logger, errorHandler, userprofileService, eventProcessor); diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index 7091cf01..df6c0c1c 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -76,6 +76,7 @@ + @@ -182,6 +183,7 @@ + @@ -209,6 +211,7 @@ + diff --git a/OptimizelySDK/Utils/ICacheWithRemove.cs b/OptimizelySDK/Utils/ICacheWithRemove.cs new file mode 100644 index 00000000..969286c9 --- /dev/null +++ b/OptimizelySDK/Utils/ICacheWithRemove.cs @@ -0,0 +1,36 @@ +/* + * 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 OptimizelySDK.Odp; + +namespace OptimizelySDK.Utils +{ + /// + /// Extended cache interface that adds the ability to remove individual entries. + /// This interface extends ICache with additional removal functionality needed for + /// certain use cases like CMAB decision caching. + /// + /// The type of values stored in the cache + public interface ICacheWithRemove : ICache + where T : class + { + /// + /// Remove the element associated with the provided key from the cache + /// + /// Key of element to remove from the cache + void Remove(string key); + } +} From c3d9b16c8faf7f0cadcddb4aaa17fe65b84eeca9 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Thu, 6 Nov 2025 18:16:38 +0600 Subject: [PATCH 18/18] [FSSDK-12014] prediction endpoint addition (#395) --- .../CmabTests/DefaultCmabClientTest.cs | 46 +++++++++--- .../CmabTests/DefaultCmabServiceTest.cs | 14 ++-- OptimizelySDK.Tests/OptimizelyTest.cs | 72 +++++++++++++++++++ OptimizelySDK/Cmab/CmabConfig.cs | 20 +++++- OptimizelySDK/Cmab/CmabConstants.cs | 2 +- OptimizelySDK/Cmab/DefaultCmabClient.cs | 5 +- OptimizelySDK/Optimizely.cs | 2 +- 7 files changed, 141 insertions(+), 20 deletions(-) diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs index 87a80e33..3ff1de96 100644 --- a/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs +++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs @@ -96,7 +96,7 @@ private static string ValidBody(string variationId = "v1") 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 client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null, logger: new NoOpLogger(), errorHandler: new NoOpErrorHandler()); var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1"); Assert.AreEqual("v1", result); @@ -106,7 +106,7 @@ public void FetchDecisionReturnsSuccessNoRetry() public void FetchDecisionHttpExceptionNoRetry() { var http = MakeClientExceptionSequence(new HttpRequestException("boom")); - var client = new DefaultCmabClient(http, retryConfig: null); + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null); Assert.Throws(() => client.FetchDecision("rule-1", "user-1", null, "uuid-1")); @@ -116,7 +116,7 @@ public void FetchDecisionHttpExceptionNoRetry() public void FetchDecisionNon2xxNoRetry() { var http = MakeClient(new ResponseStep(HttpStatusCode.InternalServerError, null)); - var client = new DefaultCmabClient(http, retryConfig: null); + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null); Assert.Throws(() => client.FetchDecision("rule-1", "user-1", null, "uuid-1")); @@ -126,7 +126,7 @@ public void FetchDecisionNon2xxNoRetry() public void FetchDecisionInvalidJsonNoRetry() { var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "not json")); - var client = new DefaultCmabClient(http, retryConfig: null); + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null); Assert.Throws(() => client.FetchDecision("rule-1", "user-1", null, "uuid-1")); @@ -136,7 +136,7 @@ public void FetchDecisionInvalidJsonNoRetry() public void FetchDecisionInvalidStructureNoRetry() { var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "{\"predictions\":[]}")); - var client = new DefaultCmabClient(http, retryConfig: null); + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null); Assert.Throws(() => client.FetchDecision("rule-1", "user-1", null, "uuid-1")); @@ -147,7 +147,7 @@ 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 client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry); var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1"); Assert.AreEqual("v2", result); @@ -162,7 +162,7 @@ public void FetchDecisionSuccessWithRetryThirdTry() 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 client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry); var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1"); Assert.AreEqual("v3", result); @@ -177,10 +177,40 @@ public void FetchDecisionExhaustsAllRetries() 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); + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry); Assert.Throws(() => client.FetchDecision("rule-1", "user-1", null, "uuid-1")); } + + [Test] + public void FetchDecision_CustomEndpoint_CallsCorrectUrl() + { + var customEndpoint = "https://custom.example.com/api/%7B0%7D"; + string capturedUrl = null; + + var handler = new Mock(MockBehavior.Strict); + handler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns((HttpRequestMessage req, CancellationToken _) => + { + capturedUrl = req.RequestUri.ToString(); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(ValidBody("variation123")) + }; + return Task.FromResult(response); + }); + + var http = new HttpClient(handler.Object); + var client = new DefaultCmabClient(customEndpoint, http, retryConfig: null); + var result = client.FetchDecision("rule-456", "user-1", null, "uuid-1"); + + Assert.AreEqual("variation123", result); + Assert.AreEqual("https://custom.example.com/api/rule-456", capturedUrl, + "Should call custom endpoint with rule ID formatted into template"); + } } } diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs index 5c891a9d..2e101f26 100644 --- a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs +++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs @@ -383,7 +383,7 @@ public void ConstructorWithoutConfigUsesDefaultCacheSettings() { var cache = new LruCache(CmabConstants.DEFAULT_CACHE_SIZE, CmabConstants.DEFAULT_CACHE_TTL, _logger); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(cache, client, _logger); var internalCache = GetInternalCache(service) as LruCache; @@ -397,7 +397,7 @@ public void ConstructorWithoutConfigUsesDefaultCacheSettings() public void ConstructorAppliesCustomCacheSize() { var cache = new LruCache(42, CmabConstants.DEFAULT_CACHE_TTL, _logger); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(cache, client, _logger); var internalCache = GetInternalCache(service) as LruCache; @@ -413,7 +413,7 @@ public void ConstructorAppliesCustomCacheTtl() var expectedTtl = TimeSpan.FromMinutes(3); var cache = new LruCache(CmabConstants.DEFAULT_CACHE_SIZE, expectedTtl, _logger); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(cache, client, _logger); var internalCache = GetInternalCache(service) as LruCache; @@ -428,7 +428,7 @@ public void ConstructorAppliesCustomCacheSizeAndTtl() { var expectedTtl = TimeSpan.FromSeconds(90); var cache = new LruCache(5, expectedTtl, _logger); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(cache, client, _logger); var internalCache = GetInternalCache(service) as LruCache; @@ -442,7 +442,7 @@ public void ConstructorAppliesCustomCacheSizeAndTtl() public void ConstructorUsesProvidedCustomCacheInstance() { var customCache = new LruCache(3, TimeSpan.FromSeconds(5), _logger); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(customCache, client, _logger); var cache = GetInternalCache(service); @@ -455,7 +455,7 @@ public void ConstructorUsesProvidedCustomCacheInstance() public void ConstructorAcceptsAnyICacheImplementation() { var fakeCache = new FakeCache(); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(fakeCache, client, _logger); var cache = GetInternalCache(service); @@ -470,7 +470,7 @@ public void ConstructorCreatesDefaultClientWhenNoneProvided() { var cache = new LruCache(CmabConstants.DEFAULT_CACHE_SIZE, CmabConstants.DEFAULT_CACHE_TTL, _logger); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(cache, client, _logger); var internalClient = GetInternalClient(service); diff --git a/OptimizelySDK.Tests/OptimizelyTest.cs b/OptimizelySDK.Tests/OptimizelyTest.cs index 3025dc89..26052021 100644 --- a/OptimizelySDK.Tests/OptimizelyTest.cs +++ b/OptimizelySDK.Tests/OptimizelyTest.cs @@ -22,6 +22,7 @@ using Moq; using NUnit.Framework; using OptimizelySDK.Bucketing; +using OptimizelySDK.Cmab; using OptimizelySDK.Config; using OptimizelySDK.Entity; using OptimizelySDK.ErrorHandler; @@ -6268,5 +6269,76 @@ public void TestConstructedOptimizelyWithDatafileShouldHaveOdpEnabledByDefault() } #endregion + + #region Test Optimizely & CMAB + + [Test] + public void TestInitializeCmabServiceWithCustomEndpointPropagatesCorrectly() + { + var customEndpoint = "https://custom.example.com/predict/%7B0%7D"; + var cmabConfig = new CmabConfig().SetPredictionEndpointTemplate(customEndpoint); + var configManager = new Mock(); + var datafileConfig = DatafileProjectConfig.Create(TestData.Datafile, LoggerMock.Object, ErrorHandlerMock.Object); + configManager.Setup(cm => cm.GetConfig()).Returns(datafileConfig); + + var optimizely = new Optimizely( + configManager: configManager.Object, + notificationCenter: null, + eventDispatcher: EventDispatcherMock.Object, + logger: LoggerMock.Object, + errorHandler: ErrorHandlerMock.Object, + userProfileService: null, + eventProcessor: null, + defaultDecideOptions: null, + odpManager: null, + cmabConfig: cmabConfig + ); + + var decisionService = optimizely.GetType() + .GetField("DecisionService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(optimizely); + var cmabService = decisionService?.GetType() + .GetField("CmabService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(decisionService); + var client = cmabService?.GetType() + .GetField("_cmabClient", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(cmabService); + var actualEndpoint = client?.GetType() + .GetField("_predictionEndpointTemplate", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(client) as string; + + Assert.AreEqual(customEndpoint, actualEndpoint, "Custom endpoint should propagate to CMAB client"); + } + + [Test] + public void TestInitializeCmabServiceWithoutCustomEndpointUsesDefault() + { + var configManager = new Mock(); + var datafileConfig = DatafileProjectConfig.Create(TestData.Datafile, LoggerMock.Object, ErrorHandlerMock.Object); + configManager.Setup(cm => cm.GetConfig()).Returns(datafileConfig); + + var optimizely = new Optimizely( + configManager: configManager.Object, + notificationCenter: null, + eventDispatcher: EventDispatcherMock.Object, + logger: LoggerMock.Object, + errorHandler: ErrorHandlerMock.Object, + userProfileService: null, + eventProcessor: null, + defaultDecideOptions: null, + odpManager: null, + cmabConfig: null + ); + + var decisionService = optimizely.GetType() + .GetField("DecisionService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(optimizely); + var cmabService = decisionService?.GetType() + .GetField("CmabService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(decisionService); + var client = cmabService?.GetType() + .GetField("_cmabClient", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(cmabService); + var actualEndpoint = client?.GetType() + .GetField("_predictionEndpointTemplate", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(client) as string; + + Assert.AreEqual(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, + actualEndpoint, "Should use default endpoint when no config provided"); + } + + #endregion } } diff --git a/OptimizelySDK/Cmab/CmabConfig.cs b/OptimizelySDK/Cmab/CmabConfig.cs index 55b7fc11..ae7f9f12 100644 --- a/OptimizelySDK/Cmab/CmabConfig.cs +++ b/OptimizelySDK/Cmab/CmabConfig.cs @@ -43,6 +43,11 @@ public class CmabConfig /// public ICacheWithRemove Cache { get; private set; } + /// + /// Gets or sets the prediction endpoint URL template for CMAB requests. + /// + public string PredictionEndpointTemplate { get; private set; } = CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE; + /// /// Sets the maximum number of entries in the CMAB cache. /// @@ -58,7 +63,7 @@ public CmabConfig SetCacheSize(int cacheSize) /// Sets the time-to-live for CMAB cache entries. /// /// Time-to-live for cache entries. - /// This CmabConfig instance for method chaining. + /// CmabConfig instance public CmabConfig SetCacheTtl(TimeSpan cacheTtl) { CacheTtl = cacheTtl; @@ -70,11 +75,22 @@ public CmabConfig SetCacheTtl(TimeSpan cacheTtl) /// When set, CacheSize and CacheTtl will be ignored. /// /// Custom cache implementation for CMAB decisions. - /// This CmabConfig instance for method chaining. + /// CmabConfig Instance public CmabConfig SetCache(ICacheWithRemove cache) { Cache = cache ?? throw new ArgumentNullException(nameof(cache)); return this; } + + /// + /// Sets the prediction endpoint URL template for CMAB requests. + /// + /// The URL template + /// CmabConfig Instance + public CmabConfig SetPredictionEndpointTemplate(string template) + { + PredictionEndpointTemplate = template; + return this; + } } } diff --git a/OptimizelySDK/Cmab/CmabConstants.cs b/OptimizelySDK/Cmab/CmabConstants.cs index 0b7525a3..c30f6de2 100644 --- a/OptimizelySDK/Cmab/CmabConstants.cs +++ b/OptimizelySDK/Cmab/CmabConstants.cs @@ -20,7 +20,7 @@ namespace OptimizelySDK.Cmab { internal static class CmabConstants { - public const string PREDICTION_URL = "https://prediction.cmab.optimizely.com/predict"; + public const string DEFAULT_PREDICTION_URL_TEMPLATE = "https://prediction.cmab.optimizely.com/predict/%7B0%7D"; public const int DEFAULT_CACHE_SIZE = 10_000; public const string CONTENT_TYPE = "application/json"; diff --git a/OptimizelySDK/Cmab/DefaultCmabClient.cs b/OptimizelySDK/Cmab/DefaultCmabClient.cs index a06f2149..c76341f9 100644 --- a/OptimizelySDK/Cmab/DefaultCmabClient.cs +++ b/OptimizelySDK/Cmab/DefaultCmabClient.cs @@ -38,13 +38,16 @@ public class DefaultCmabClient : ICmabClient private readonly CmabRetryConfig _retryConfig; private readonly ILogger _logger; private readonly IErrorHandler _errorHandler; + private readonly string _predictionEndpointTemplate; public DefaultCmabClient( + string predictionEndpointTemplate, HttpClient httpClient = null, CmabRetryConfig retryConfig = null, ILogger logger = null, IErrorHandler errorHandler = null) { + _predictionEndpointTemplate = predictionEndpointTemplate; _httpClient = httpClient ?? new HttpClient(); _retryConfig = retryConfig; _logger = logger ?? new NoOpLogger(); @@ -58,7 +61,7 @@ private async Task FetchDecisionAsync( string cmabUuid, TimeSpan? timeout = null) { - var url = $"{CmabConstants.PREDICTION_URL}/{ruleId}"; + var url = string.Format(_predictionEndpointTemplate, ruleId); var body = BuildRequestBody(ruleId, userId, attributes, cmabUuid); var perAttemptTimeout = timeout ?? CmabConstants.MAX_TIMEOUT; diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index a27e228b..ed4f7469 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -299,7 +299,7 @@ private void InitializeComponents(IEventDispatcher eventDispatcher = null, var cmabRetryConfig = new CmabRetryConfig(CmabConstants.CMAB_MAX_RETRIES, CmabConstants.CMAB_INITIAL_BACKOFF); - var cmabClient = new DefaultCmabClient(null, cmabRetryConfig, Logger); + var cmabClient = new DefaultCmabClient(config.PredictionEndpointTemplate, null, cmabRetryConfig, Logger, null); cmabService = new DefaultCmabService(cache, cmabClient, Logger);