diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml
index f417b0e8..1650a685 100644
--- a/.github/workflows/csharp.yml
+++ b/.github/workflows/csharp.yml
@@ -5,19 +5,21 @@ on:
branches: [ master ]
pull_request:
branches: [ master ]
+ types: [ opened, synchronize, reopened, ready_for_review ]
jobs:
lintCodebase:
+ name: Lint Codebase if Not Draft
+ if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
- name: Lint Codebase
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
# Full git history is needed to get a proper list of changed files
fetch-depth: 0
- name: Run Super-Linter
- uses: github/super-linter@v4
+ uses: github/super-linter@v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VALIDATE_ALL_CODEBASE: false
@@ -37,7 +39,7 @@ jobs:
CURRENT_BRANCH: ${{ github.head_ref || github.ref_name }}
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Add msbuild to PATH
uses: microsoft/setup-msbuild@v1
- name: Setup NuGet
@@ -48,9 +50,9 @@ jobs:
run: msbuild /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk /p:Configuration=Release ./OptimizelySDK.NETFramework.sln
- name: Install & Run NUnit tests
run: |
- nuget install NUnit.Console -Version 3.15.2 -DirectDownload -OutputDirectory .
+ 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.15.2\tools\nunit3-console.exe /timeout 10000 /process Separate ./OptimizelySDK.Tests/bin/Release/OptimizelySDK.Tests.dll
+ ./NUnit.ConsoleRunner.3.18.1\tools\nunit3-console.exe /timeout 10000 /process Separate ./OptimizelySDK.Tests/bin/Release/OptimizelySDK.Tests.dll
netStandard16:
name: Build Standard 1.6
@@ -65,7 +67,7 @@ jobs:
CURRENT_BRANCH: ${{ github.head_ref || github.ref_name }}
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v2
with:
@@ -73,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
@@ -88,7 +90,7 @@ jobs:
CURRENT_BRANCH: ${{ github.head_ref || github.ref_name }}
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v2
with:
@@ -96,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
@@ -104,7 +106,6 @@ jobs:
uses: optimizely/csharp-sdk/.github/workflows/integration_test.yml@master
secrets:
CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }}
- TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }}
fullstack_production_suite:
name: Run Performance Tests
@@ -114,4 +115,3 @@ jobs:
FULLSTACK_TEST_REPO: ProdTesting
secrets:
CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }}
- TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }}
diff --git a/.github/workflows/csharp_release.yml b/.github/workflows/csharp_release.yml
index 90e680a7..80461161 100644
--- a/.github/workflows/csharp_release.yml
+++ b/.github/workflows/csharp_release.yml
@@ -3,32 +3,29 @@
on:
release:
types: [ published ] # Trigger on published pre-releases and releases
+ workflow_dispatch:
jobs:
variables:
name: Set Variables
runs-on: ubuntu-latest
env:
- # ⚠️ IMPORTANT: tag should always start with integer & will be used verbatim to string end
TAG: ${{ github.event.release.tag_name }}
steps:
- - name: Set semantic version variable
+ - name: Extract semantic version from tag
id: set_version
run: |
- TAG=${{ env.TAG }}
+ # Remove the "v" prefix if it exists and extract the semantic version number
SEMANTIC_VERSION=$(echo "${TAG}" | grep -Po "(?<=^|[^0-9])([0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?(-[a-zA-Z]+[0-9]*)?)")
+ SEMANTIC_VERSION=${SEMANTIC_VERSION#"v"}
if [ -z "${SEMANTIC_VERSION}" ]; then
- echo "Tag did not start with a semantic version number (e.g., #.#.#; #.#.#.#; #.#.#.#-beta)"
+ echo "Error: Tag '${TAG}' does not start with a valid semantic version number (e.g., #.#.#; #.#.#.#; #.#.#.#-beta)"
exit 1
fi
+ echo "Extracted semantic version: ${SEMANTIC_VERSION}"
echo "semantic_version=${SEMANTIC_VERSION}" >> $GITHUB_OUTPUT
- - name: Output tag & semantic version
- id: outputs
- run: |
- echo ${{ env.TAG }}
- echo ${{ steps.set_version.outputs.semantic_version }}
outputs:
- tag: ${{ env.TAG }}
+ tag: $TAG
semanticVersion: ${{ steps.set_version.outputs.semantic_version }}
buildFrameworkVersions:
@@ -37,21 +34,21 @@ 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
run: msbuild /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk /p:Configuration=Release ./OptimizelySDK.NETFramework.sln
- name: Upload Framework artifacts
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v4
with:
- name: nuget-files
+ name: unsigned-dlls
if-no-files-found: error
path: ./**/bin/Release/**/Optimizely*.dll
@@ -61,19 +58,19 @@ 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
run: dotnet build OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk -c Release
- name: Upload Standard 1.6 artifact
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v4
with:
- name: nuget-files
+ name: unsigned-dlls
if-no-files-found: error
path: ./**/bin/Release/**/Optimizely*.dll
@@ -83,31 +80,79 @@ 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
run: dotnet build OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk -c Release
- name: Build and strongly name assemblies
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v4
with:
- name: nuget-files
+ name: unsigned-dlls
if-no-files-found: error
path: ./**/bin/Release/**/Optimizely*.dll
- pack:
- name: Sign & pack NuGet package
+ sign:
+ name: Send DLLs for signing
needs: [ variables, buildFrameworkVersions, buildStandard16, buildStandard20 ]
runs-on: ubuntu-latest
+ env:
+ # TODO: Replace actual values
+ SIGNING_SERVER_PRIVATE_KEY: ${{ secrets.SIGNING_SERVER_PRIVATE_KEY }}
+ SIGNING_SERVER_HOST: ${{ secrets.SIGNING_SERVER_HOST }}
+ SIGNING_SERVER_UPLOAD_PATH: /path/to/UPLOAD/directory
+ SIGNING_SERVER_DOWNLOAD_PATH: /path/to/DOWNLOAD/directory
+ steps:
+ # TODO: Remove this when we're ready to automate
+ - name: Temporarily halt progress
+ run: exit 1
+ - name: Download the unsigned files
+ uses: actions/download-artifact@v4
+ with:
+ name: unsigned-dlls
+ path: ./unsigned-dlls
+ - name: Setup SSH
+ uses: shimataro/ssh-key-action@v2
+ with:
+ key: $SIGNING_SERVER_PRIVATE_KEY
+ - name: Send files to signing server
+ run: scp -r ./unsigned-dlls $SIGNING_SERVER_HOST:$SIGNING_SERVER_UPLOAD_PATH
+ - name: Wait for artifact to be published
+ run: |
+ for i in {1..60}; do
+ # Replace with actual path
+ if ssh $SIGNING_SERVER_HOST "ls $SIGNING_SERVER_DOWNLOAD_PATH"; then
+ exit 0
+ fi
+ sleep 10
+ done
+ exit 1
+ - name: Download signed files
+ run: |
+ mkdir ./signed-dlls
+ scp -r $SIGNING_SERVER_HOST:$SIGNING_SERVER_DOWNLOAD_PATH ./signed-dlls
+ - name: Delete signed files from server
+ run: ssh $SIGNING_SERVER_HOST "rm -rf $SIGNING_SERVER_DOWNLOAD_PATH/*"
+ - name: Upload signed files
+ uses: actions/upload-artifact@v4
+ with:
+ name: signed-dlls
+ if-no-files-found: error
+ path: ./signed-dlls
+
+ pack:
+ name: Pack NuGet package
+ needs: [ variables, sign ]
+ runs-on: ubuntu-latest
env:
VERSION: ${{ needs.variables.outputs.semanticVersion }}
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: ${{ needs.variables.outputs.tag }}
- name: Install mono
@@ -115,55 +160,25 @@ jobs:
sudo apt update
sudo apt install -y mono-devel
- name: Download NuGet files
- uses: actions/download-artifact@v2
+ uses: actions/download-artifact@v4
with:
- name: nuget-files
- path: ./nuget-files
+ name: signed-dlls
+ path: ./signed-dlls
- name: Organize files
run: |
- pushd ./nuget-files
+ pushd ./signed-dlls
# Move all dlls to the root directory
- find . -type f -name "*.dll" -exec mv {} . \;
+ find . -type f -name "*.dll" -exec mv {} .
popd
# Create directories
mkdir -p nuget/lib/net35/ nuget/lib/net40/ nuget/lib/net45/ nuget/lib/netstandard1.6/ nuget/lib/netstandard2.0/
pushd ./nuget
# Move files to directories
- mv ../nuget-files/OptimizelySDK.Net35.dll lib/net35/
- mv ../nuget-files/OptimizelySDK.Net40.dll lib/net40/
- mv ../nuget-files/OptimizelySDK.dll lib/net45/
- mv ../nuget-files/OptimizelySDK.NetStandard16.dll lib/netstandard1.6/
- mv ../nuget-files/OptimizelySDK.NetStandard20.dll lib/netstandard2.0/
- popd
- - name: Setup signing prerequisites
- env:
- CERTIFICATE_P12: ${{ secrets.CERTIFICATE_P12 }}
- CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
- run: |
- pushd ./nuget
- echo $CERTIFICATE_P12 | base64 --decode > authenticode.pfx
- openssl pkcs12 -in authenticode.pfx -nocerts -nodes -legacy -out key.pem -password env:CERTIFICATE_PASSWORD
- openssl rsa -in key.pem -outform PVK -pvk-none -out authenticode.pvk
- openssl pkcs12 -in authenticode.pfx -nokeys -nodes -legacy -out cert.pem -password env:CERTIFICATE_PASSWORD
- openssl crl2pkcs7 -nocrl -certfile cert.pem -outform DER -out authenticode.spc
- popd
- - name: Sign the DLLs
- run: |
- pushd ./nuget
- find . -type f -name "*.dll" -print0 | while IFS= read -r -d '' file; do
- echo "Signing ${file}"
- signcode \
- -spc ./authenticode.spc \
- -v ./authenticode.pvk \
- -a sha1 -$ commercial \
- -n "Optimizely, Inc" \
- -i "https://www.optimizely.com/" \
- -t "http://timestamp.digicert.com" \
- -tr 10 \
- ${file}
- rm ${file}.bak
- done
- rm *.spc *.pem *.pvk *.pfx
+ mv ../signed-dlls/OptimizelySDK.Net35.dll lib/net35/
+ mv ../signed-dlls/OptimizelySDK.Net40.dll lib/net40/
+ mv ../signed-dlls/OptimizelySDK.dll lib/net45/
+ mv ../signed-dlls/OptimizelySDK.NetStandard16.dll lib/netstandard1.6/
+ mv ../signed-dlls/OptimizelySDK.NetStandard20.dll lib/netstandard2.0/
popd
- name: Create nuspec
# Uses env.VERSION in OptimizelySDK.nuspec.template
@@ -176,27 +191,29 @@ jobs:
nuget pack OptimizelySDK.nuspec
popd
- name: Upload nupkg artifact
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v4
with:
name: nuget-package
if-no-files-found: error
path: ./nuget/Optimizely.SDK.${{ env.VERSION }}.nupkg
publish:
- name: Publish package to NuGet
+ name: Publish package to NuGet after reviewing the artifact
needs: [ variables, pack ]
runs-on: ubuntu-latest
+ # Review the `nuget-package` artifact ensuring the dlls are
+ # organized and signed before approving.
+ environment: 'i-reviewed-nuget-package-artifact'
env:
VERSION: ${{ needs.variables.outputs.semanticVersion }}
steps:
- name: Download NuGet files
- uses: actions/download-artifact@v2
+ uses: actions/download-artifact@v4
with:
name: nuget-package
path: ./nuget
- name: Setup .NET
- uses: actions/setup-dotnet@v3
+ uses: actions/setup-dotnet@v4
- name: Publish NuGet package
- # Unset secrets.NUGET_API_KEY to simulate dry run
run: |
dotnet nuget push ./nuget/Optimizely.SDK.${{ env.VERSION }}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}
diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml
index 423e2dfe..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,19 +17,21 @@ 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:
+ HEAD_REF: ${{ github.head_ref }}
if: ${{ github.event_name == 'pull_request' }}
run: |
- echo "SDK_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV
- echo "TRAVIS_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV
+ echo "SDK_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=${{ github.ref_name }}" >> $GITHUB_ENV
- echo "TRAVIS_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
+ echo "SDK_BRANCH=$REF_NAME" >> $GITHUB_ENV
- name: Trigger build
env:
SDK: csharp
@@ -47,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/.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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b9620be..dca45cda 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,85 @@
# Optimizely C# SDK Changelog
+## 4.1.0
+November 7th, 2024
+
+### Enhancement
+
+- Added support for batch processing in `DecideAll` and `DecideForKeys`, enabling more efficient handling of multiple decisions in the User Profile Service. ([#375](https://github.com/optimizely/csharp-sdk/pull/375))
+
+### Bug Fixes
+- GitHub Actions YAML files vulnerable to script injections ([#372](https://github.com/optimizely/csharp-sdk/pull/372))
+
+## 4.0.0
+January 16th, 2024
+
+### New Features
+
+#### Advanced Audience Targeting
+
+The 4.0.0 release introduces a new primary feature, [Advanced Audience Targeting]( https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting)
+ enabled through integration with [Optimizely Data Platform (ODP)](https://docs.developers.optimizely.com/optimizely-data-platform/docs) (
+ [#305](https://github.com/optimizely/csharp-sdk/pull/305),
+ [#310](https://github.com/optimizely/csharp-sdk/pull/310),
+ [#311](https://github.com/optimizely/csharp-sdk/pull/311),
+ [#315](https://github.com/optimizely/csharp-sdk/pull/315),
+ [#321](https://github.com/optimizely/csharp-sdk/pull/321),
+ [#322](https://github.com/optimizely/csharp-sdk/pull/322),
+ [#323](https://github.com/optimizely/csharp-sdk/pull/323),
+ [#324](https://github.com/optimizely/csharp-sdk/pull/324)
+ ).
+
+You can use ODP, a high-performance [Customer Data Platform (CDP)]( https://www.optimizely.com/optimization-glossary/customer-data-platform/), to easily create complex
+real-time segments (RTS) using first-party and 50+ third-party data sources out of the box. You can create custom schemas that support the user attributes important
+for your business, and stitch together user behavior done on different devices to better understand and target your customers for personalized user experiences. ODP can
+be used as a single source of truth for these segments in any Optimizely or 3rd party tool.
+
+With ODP accounts integrated into Optimizely projects, you can build audiences using segments pre-defined in ODP. The SDK will fetch the segments for given users and
+make decisions using the segments. For access to ODP audience targeting in your Feature Experimentation account, please contact your Optimizely Customer Success Manager.
+
+This version includes the following changes:
+- New API added to `OptimizelyUserContext`:
+ - `FetchQualifiedSegments()`: this API will retrieve user segments from the ODP server. The fetched segments will be used for audience evaluation. The fetched data will be stored in the local cache to avoid repeated network delays.
+ - When an `OptimizelyUserContext` is created, the SDK will automatically send an identify request to the ODP server to facilitate observing user activities.
+- New APIs added to `OptimizelyClient`:
+ - `SendOdpEvent()`: customers can build/send arbitrary ODP events that will bind user identifiers and data to user profiles in ODP.
+
+For details, refer to our documentation pages:
+- [Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting)
+- [Server SDK Support](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/advanced-audience-targeting-for-server-side-sdks)
+- [Initialize C# SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-csharp)
+- [OptimizelyUserContext C# SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizelyusercontext-csharp)
+- [Advanced Audience Targeting segment qualification methods](https://docs.developers.optimizely.com/feature-experimentation/docs/advanced-audience-targeting-segment-qualification-methods-csharp)
+- [Send Optimizely Data Platform data using Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/send-odp-data-using-advanced-audience-targeting-csharp)
+
+#### Polling warning
+
+Add warning to polling intervals below 30 seconds ([#365](https://github.com/optimizely/csharp-sdk/pull/365))
+
+### Breaking Changes
+- `OdpManager` in the SDK is enabled by default. Unless an ODP account is integrated into the Optimizely projects, most `OdpManager` functions will be ignored. If needed, `OdpManager` can be disabled when `OptimizelyClient` is instantiated.
+- `ProjectConfigManager` interface additions + implementing class updates
+- `Evaluate()` updates in `BaseCondition`
+
+### Bug Fixes
+- Return Latest Experiment When Duplicate Keys in Config enhancement
+
+### Documentation
+- Corrections to markdown files in docs directory ([#368](https://github.com/optimizely/csharp-sdk/pull/368))
+- GitHub template updates ([#366](https://github.com/optimizely/csharp-sdk/pull/366))
+
+## 3.11.4
+July 26th, 2023
+
+### Bug Fixes
+- Fix Last-Modified date & time format for If-Modified-Since ([#361](https://github.com/optimizely/csharp-sdk/pull/361))
+
+## 3.11.3
+July 18th, 2023
+
+### Bug Fixes
+- Last-Modified in header not found and used to reduce polling payload ([#355](https://github.com/optimizely/csharp-sdk/pull/355)).
+
## 4.0.0-beta
April 28th, 2023
diff --git a/LICENSE b/LICENSE
index 089978b9..2f8be781 100644
--- a/LICENSE
+++ b/LICENSE
@@ -187,7 +187,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
- Copyright 2016 Optimizely
+ Copyright 2016-2024, Optimizely, Inc. and contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/OptimizelySDK.DemoApp/Properties/AssemblyInfo.cs b/OptimizelySDK.DemoApp/Properties/AssemblyInfo.cs
index ef42316d..147aa1a8 100644
--- a/OptimizelySDK.DemoApp/Properties/AssemblyInfo.cs
+++ b/OptimizelySDK.DemoApp/Properties/AssemblyInfo.cs
@@ -37,6 +37,6 @@
//
// You can specify all the values or you can default the Revision and Build Numbers
// by using the '*' as shown below:
-[assembly: AssemblyVersion("4.0.0.0")]
-[assembly: AssemblyFileVersion("4.0.0.0")]
-[assembly: AssemblyInformationalVersion("4.0.0-beta")] // Used by Nuget.
+[assembly: AssemblyVersion("4.1.0.0")]
+[assembly: AssemblyFileVersion("4.1.0.0")]
+[assembly: AssemblyInformationalVersion("4.1.0")] // Used by NuGet.
diff --git a/OptimizelySDK.DemoApp/Scripts/README.md b/OptimizelySDK.DemoApp/Scripts/README.md
index ff1e9551..12510e26 100644
--- a/OptimizelySDK.DemoApp/Scripts/README.md
+++ b/OptimizelySDK.DemoApp/Scripts/README.md
@@ -7,7 +7,6 @@
-
@@ -34,7 +33,7 @@ to make it possible to position it near a given reference element.
The engine is completely modular and most of its features are implemented as **modifiers**
(similar to middlewares or plugins).
-The whole code base is written in ES2015 and its features are automatically tested on real browsers thanks to [SauceLabs](https://saucelabs.com/) and [TravisCI](https://travis-ci.org/).
+The whole code base is written in ES2015 and its features are automatically tested on real browsers thanks to [SauceLabs](https://saucelabs.com/).
Popper.js has zero dependencies. No jQuery, no LoDash, nothing.
It's used by big companies like [Twitter in Bootstrap v4](https://getbootstrap.com/), [Microsoft in WebClipper](https://github.com/OneNoteDev/WebClipper) and [Atlassian in AtlasKit](https://aui-cdn.atlassian.com/atlaskit/registry/).
diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
index 8daf74ce..a4495471 100644
--- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
+++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
@@ -209,6 +209,9 @@
Bucketing\UserProfile.cs
+
+ Bucketing\UserProfileTracker.cs
+
Bucketing\ExperimentUtils
@@ -356,4 +359,4 @@
-->
-
\ No newline at end of file
+
diff --git a/OptimizelySDK.Net35/Properties/AssemblyInfo.cs b/OptimizelySDK.Net35/Properties/AssemblyInfo.cs
index 69b04e7f..dd5b9ab2 100644
--- a/OptimizelySDK.Net35/Properties/AssemblyInfo.cs
+++ b/OptimizelySDK.Net35/Properties/AssemblyInfo.cs
@@ -37,6 +37,6 @@
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
-[assembly: AssemblyVersion("4.0.0.0")]
-[assembly: AssemblyFileVersion("4.0.0.0")]
-[assembly: AssemblyInformationalVersion("4.0.0-beta")] // Used by Nuget.
+[assembly: AssemblyVersion("4.1.0.0")]
+[assembly: AssemblyFileVersion("4.1.0.0")]
+[assembly: AssemblyInformationalVersion("4.1.0")] // Used by NuGet.
diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
index a0f98b77..05785575 100644
--- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
+++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
@@ -208,6 +208,9 @@
Bucketing\UserProfile.cs
+
+ Bucketing\UserProfileTracker.cs
+
Bucketing\ExperimentUtils
@@ -366,4 +369,4 @@
-
\ No newline at end of file
+
diff --git a/OptimizelySDK.Net40/Properties/AssemblyInfo.cs b/OptimizelySDK.Net40/Properties/AssemblyInfo.cs
index c3e01c00..48b0cb1d 100644
--- a/OptimizelySDK.Net40/Properties/AssemblyInfo.cs
+++ b/OptimizelySDK.Net40/Properties/AssemblyInfo.cs
@@ -37,6 +37,6 @@
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
-[assembly: AssemblyVersion("4.0.0.0")]
-[assembly: AssemblyFileVersion("4.0.0.0")]
-[assembly: AssemblyInformationalVersion("4.0.0-beta")] // Used by Nuget.
+[assembly: AssemblyVersion("4.1.0.0")]
+[assembly: AssemblyFileVersion("4.1.0.0")]
+[assembly: AssemblyInformationalVersion("4.1.0")] // Used by NuGet.
diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
index 329b8b72..b17f79e7 100644
--- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
+++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
@@ -72,6 +72,7 @@
+
diff --git a/OptimizelySDK.NetStandard16/Properties/AssemblyInfo.cs b/OptimizelySDK.NetStandard16/Properties/AssemblyInfo.cs
index 7ba7db50..f82a11ad 100644
--- a/OptimizelySDK.NetStandard16/Properties/AssemblyInfo.cs
+++ b/OptimizelySDK.NetStandard16/Properties/AssemblyInfo.cs
@@ -37,6 +37,6 @@
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
-[assembly: AssemblyVersion("4.0.0")]
-[assembly: AssemblyFileVersion("4.0.0.0")]
-[assembly: AssemblyInformationalVersion("4.0.0-beta")] // Used by Nuget.
+[assembly: AssemblyVersion("4.1.0")]
+[assembly: AssemblyFileVersion("4.1.0.0")]
+[assembly: AssemblyInformationalVersion("4.1.0")] // Used by NuGet.
diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index 6d7ea638..b7114653 100644
--- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
+++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
@@ -133,6 +133,9 @@
Bucketing\UserProfile.cs
+
+ Bucketing\UserProfileTracker.cs
+
Bucketing\UserProfileService.cs
diff --git a/OptimizelySDK.NetStandard20/Properties/AssemblyInfo.cs b/OptimizelySDK.NetStandard20/Properties/AssemblyInfo.cs
index e493466c..72640cfa 100644
--- a/OptimizelySDK.NetStandard20/Properties/AssemblyInfo.cs
+++ b/OptimizelySDK.NetStandard20/Properties/AssemblyInfo.cs
@@ -37,6 +37,6 @@
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
-[assembly: AssemblyVersion("4.0.0.0")]
-[assembly: AssemblyFileVersion("4.0.0.0")]
-[assembly: AssemblyInformationalVersion("4.0.0-beta")] // Used by Nuget.
+[assembly: AssemblyVersion("4.1.0.0")]
+[assembly: AssemblyFileVersion("4.1.0.0")]
+[assembly: AssemblyInformationalVersion("4.1.0")] // Used by NuGet.
diff --git a/OptimizelySDK.Tests/DecisionServiceTest.cs b/OptimizelySDK.Tests/DecisionServiceTest.cs
index 633847ae..8fbedf23 100644
--- a/OptimizelySDK.Tests/DecisionServiceTest.cs
+++ b/OptimizelySDK.Tests/DecisionServiceTest.cs
@@ -1,6 +1,6 @@
/**
*
- * Copyright 2017-2021, Optimizely and contributors
+ * Copyright 2017-2021, 2024 Optimizely and contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -65,7 +65,7 @@ public void SetUp()
DecisionService = new DecisionService(new Bucketer(LoggerMock.Object),
ErrorHandlerMock.Object, null, LoggerMock.Object);
DecisionServiceMock = new Mock(BucketerMock.Object,
- ErrorHandlerMock.Object, null, LoggerMock.Object)
+ ErrorHandlerMock.Object, null, LoggerMock.Object)
{ CallBase = true };
DecisionReasons = new DecisionReasons();
@@ -150,6 +150,11 @@ public void TestGetVariationLogsErrorWhenUserProfileMapItsNull()
var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object,
UserProfileServiceMock.Object, LoggerMock.Object);
var options = new OptimizelyDecideOption[] { OptimizelyDecideOption.INCLUDE_REASONS };
+ var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(),
+ LoggerMock.Object);
+ OptimizelyUserContextMock = new Mock(optlyObject,
+ WhitelistedUserId, new UserAttributes(), ErrorHandlerMock.Object,
+ LoggerMock.Object);
OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns(GenericUserId);
var variationResult = decisionService.GetVariation(experiment,
@@ -157,8 +162,10 @@ public void TestGetVariationLogsErrorWhenUserProfileMapItsNull()
Assert.AreEqual(variationResult.DecisionReasons.ToReport(true)[0],
"We were unable to get a user profile map from the UserProfileService.");
Assert.AreEqual(variationResult.DecisionReasons.ToReport(true)[1],
- "Audiences for experiment \"etag3\" collectively evaluated to FALSE");
+ "No previously activated variation of experiment \"etag3\" for user \"genericUserId\" found in user profile.");
Assert.AreEqual(variationResult.DecisionReasons.ToReport(true)[2],
+ "Audiences for experiment \"etag3\" collectively evaluated to FALSE");
+ Assert.AreEqual(variationResult.DecisionReasons.ToReport(true)[3],
"User \"genericUserId\" does not meet conditions to be in experiment \"etag3\".");
}
@@ -291,6 +298,9 @@ public void TestBucketReturnsVariationStoredInUserProfile()
{
var experiment = ProjectConfig.Experiments[6];
var variation = experiment.Variations[0];
+ var variationResult = Result.NewResult(
+ experiment.Variations[0],
+ DecisionReasons);
var decision = new Decision(variation.Id);
var userProfile = new UserProfile(UserProfileId, new Dictionary
@@ -300,8 +310,10 @@ public void TestBucketReturnsVariationStoredInUserProfile()
UserProfileServiceMock.Setup(_ => _.Lookup(UserProfileId)).Returns(userProfile.ToMap());
- var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object,
- UserProfileServiceMock.Object, LoggerMock.Object);
+ BucketerMock.
+ Setup(bm => bm.Bucket(ProjectConfig, experiment, It.IsAny(),
+ It.IsAny())).
+ Returns(variationResult);
var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(),
LoggerMock.Object);
@@ -310,6 +322,8 @@ public void TestBucketReturnsVariationStoredInUserProfile()
LoggerMock.Object);
OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns(UserProfileId);
+ var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object,
+ UserProfileServiceMock.Object, LoggerMock.Object);
var actualVariation = decisionService.GetVariation(experiment,
OptimizelyUserContextMock.Object, ProjectConfig);
@@ -736,7 +750,8 @@ public void TestGetVariationForFeatureExperimentGivenNonMutexGroupAndUserIsBucke
DecisionServiceMock.Setup(ds => ds.GetVariation(
ProjectConfig.GetExperimentFromKey("test_experiment_multivariate"),
OptimizelyUserContextMock.Object, ProjectConfig,
- It.IsAny())).
+ It.IsAny(), It.IsAny(),
+ It.IsAny())).
Returns(variation);
var featureFlag = ProjectConfig.GetFeatureFlagFromKey("multi_variate_feature");
@@ -789,13 +804,18 @@ public void TestGetVariationForFeatureExperimentGivenMutexGroupAndUserIsBucketed
[Test]
public void TestGetVariationForFeatureExperimentGivenMutexGroupAndUserNotBucketed()
{
- var mutexExperiment = ProjectConfig.GetExperimentFromKey("group_experiment_1");
+ var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(),
+ LoggerMock.Object);
+ OptimizelyUserContextMock = new Mock(optlyObject,
+ WhitelistedUserId, new UserAttributes(), ErrorHandlerMock.Object,
+ LoggerMock.Object);
OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns("user1");
DecisionServiceMock.
Setup(ds => ds.GetVariation(It.IsAny(),
It.IsAny(), ProjectConfig,
- It.IsAny())).
+ It.IsAny(), It.IsAny(),
+ It.IsAny())).
Returns(Result.NullResult(null));
var featureFlag = ProjectConfig.GetFeatureFlagFromKey("boolean_feature");
@@ -856,7 +876,7 @@ public void TestGetVariationForFeatureRolloutWhenRolloutIsNotInDataFile()
ds.GetVariationForFeatureExperiment(It.IsAny(),
It.IsAny(), It.IsAny(),
ProjectConfig,
- new OptimizelyDecideOption[] { })).
+ new OptimizelyDecideOption[] { }, null)).
Returns(null);
var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(),
LoggerMock.Object);
@@ -1201,7 +1221,7 @@ public void TestGetVariationForFeatureWhenTheUserIsBucketedIntoFeatureExperiment
DecisionServiceMock.Setup(ds => ds.GetVariationForFeatureExperiment(
It.IsAny(), It.IsAny(),
It.IsAny(), ProjectConfig,
- It.IsAny())).
+ It.IsAny(), null)).
Returns(expectedDecision);
OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns("user1");
@@ -1228,7 +1248,7 @@ public void
DecisionServiceMock.Setup(ds => ds.GetVariationForFeatureExperiment(
It.IsAny(), It.IsAny(),
It.IsAny(), ProjectConfig,
- It.IsAny())).
+ It.IsAny(), null)).
Returns(Result.NullResult(null));
DecisionServiceMock.Setup(ds => ds.GetVariationForFeatureRollout(
It.IsAny(), It.IsAny(),
@@ -1262,7 +1282,7 @@ public void
ds.GetVariationForFeatureExperiment(It.IsAny(),
It.IsAny(), It.IsAny(),
ProjectConfig,
- new OptimizelyDecideOption[] { })).
+ new OptimizelyDecideOption[] { }, null)).
Returns(Result.NullResult(null));
DecisionServiceMock.
Setup(ds => ds.GetVariationForFeatureRollout(It.IsAny(),
@@ -1309,6 +1329,11 @@ public void TestGetVariationForFeatureWhenTheUserIsBuckedtedInBothExperimentAndR
WhitelistedUserId, userAttributes, ErrorHandlerMock.Object, LoggerMock.Object);
OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns(UserProfileId);
+ BucketerMock.
+ Setup(bm => bm.Bucket(ProjectConfig, experiment, It.IsAny(),
+ It.IsAny())).
+ Returns(variation);
+
DecisionServiceMock.Setup(ds => ds.GetVariation(experiment,
OptimizelyUserContextMock.Object, ProjectConfig,
It.IsAny())).
diff --git a/OptimizelySDK.Tests/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 49fd91a4..21ae10db 100644
--- a/OptimizelySDK.Tests/OptimizelyUserContextTest.cs
+++ b/OptimizelySDK.Tests/OptimizelyUserContextTest.cs
@@ -1,5 +1,5 @@
/*
- * Copyright 2020-2021, 2022-2023 Optimizely and contributors
+ * Copyright 2020-2021, 2022-2024 Optimizely and contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,7 +16,7 @@
using System;
using System.Collections.Generic;
-using System.Threading;
+using System.Linq;
using Castle.Core.Internal;
using Moq;
using NUnit.Framework;
@@ -62,6 +62,22 @@ public void SetUp()
LoggerMock.Object, ErrorHandlerMock.Object);
}
+ private Mock MakeUserProfileServiceMock()
+ {
+ var projectConfig = DatafileProjectConfig.Create(TestData.Datafile, LoggerMock.Object,
+ ErrorHandlerMock.Object);
+ var experiment = projectConfig.Experiments[8];
+ var variation = experiment.Variations[0];
+ var decision = new Decision(variation.Id);
+ var userProfile = new UserProfile(UserID, new Dictionary
+ {
+ { experiment.Id, decision },
+ });
+ var userProfileServiceMock = new Mock();
+ userProfileServiceMock.Setup(up => up.Lookup(UserID)).Returns(userProfile.ToMap());
+ return userProfileServiceMock;
+ }
+
[Test]
public void OptimizelyUserContextWithAttributes()
{
@@ -193,7 +209,7 @@ public void SetAttributeToOverrideAttribute()
Assert.AreEqual(user.GetAttributes()["k1"], true);
}
- #region decide
+ #region Decide
[Test]
public void TestDecide()
@@ -409,9 +425,112 @@ public void DecideWhenConfigIsNull()
Assert.IsTrue(TestData.CompareObjects(decision, decisionExpected));
}
- #endregion decide
+ [Test]
+ public void SeparateDecideShouldHaveSameNumberOfUpsSaveAndLookup()
+ {
+ var flag1 = "double_single_variable_feature";
+ var flag2 = "integer_single_variable_feature";
+ var userProfileServiceMock = MakeUserProfileServiceMock();
+ var saveArgsCollector = new List>();
+ userProfileServiceMock.Setup(up => up.Save(Capture.In(saveArgsCollector)));
+ var optimizely = new Optimizely(TestData.Datafile, EventDispatcherMock.Object,
+ LoggerMock.Object, ErrorHandlerMock.Object, userProfileServiceMock.Object);
+ var user = optimizely.CreateUserContext(UserID);
+ var flag1UserProfile = new UserProfile(UserID, new Dictionary
+ {
+ { "224", new Decision("280") },
+ { "122238", new Decision("122240") },
+ });
+ var flag2UserProfile = new UserProfile(UserID, new Dictionary
+ {
+ { "224", new Decision("280") },
+ { "122241", new Decision("122242") },
+ });
+
+ user.Decide(flag1);
+ user.Decide(flag2);
+
+ LoggerMock.Verify(
+ l => l.Log(LogLevel.INFO,
+ "We were unable to get a user profile map from the UserProfileService."),
+ Times.Never);
+ LoggerMock.Verify(
+ l => l.Log(LogLevel.ERROR, "The UserProfileService returned an invalid map."),
+ Times.Never);
+ userProfileServiceMock.Verify(l => l.Lookup(UserID), Times.Exactly(2));
+ userProfileServiceMock.Verify(l => l.Save(It.IsAny>()),
+ Times.Exactly(2));
+ Assert.AreEqual(saveArgsCollector[0], flag1UserProfile.ToMap());
+ Assert.AreEqual(saveArgsCollector[1], flag2UserProfile.ToMap());
+ }
+
+ [Test]
+ public void DecideWithUpsShouldOnlyLookupSaveOnce()
+ {
+ var flagKeyFromTestDataJson = "double_single_variable_feature";
+ var userProfileServiceMock = MakeUserProfileServiceMock();
+ var saveArgsCollector = new List>();
+ userProfileServiceMock.Setup(up => up.Save(Capture.In(saveArgsCollector)));
+ var optimizely = new Optimizely(TestData.Datafile, EventDispatcherMock.Object,
+ LoggerMock.Object, ErrorHandlerMock.Object, userProfileServiceMock.Object);
+ var user = optimizely.CreateUserContext(UserID);
+ var expectedUserProfile = new UserProfile(UserID, new Dictionary
+ {
+ { "224", new Decision("280") },
+ { "122238", new Decision("122240") },
+ });
+
+ user.Decide(flagKeyFromTestDataJson);
+
+ LoggerMock.Verify(
+ l => l.Log(LogLevel.INFO,
+ "We were unable to get a user profile map from the UserProfileService."),
+ Times.Never);
+ LoggerMock.Verify(
+ l => l.Log(LogLevel.ERROR, "The UserProfileService returned an invalid map."),
+ Times.Never);
+ userProfileServiceMock.Verify(l => l.Lookup(UserID), Times.Once);
+ userProfileServiceMock.Verify(l => l.Save(It.IsAny>()),
+ Times.Once);
+ Assert.AreEqual(saveArgsCollector.First(), expectedUserProfile.ToMap());
+ }
+
+ #endregion Decide
- #region decideAll
+ #region DecideForKeys
+
+ [Test]
+ public void DecideForKeysWithUpsShouldOnlyLookupSaveOnceWithMultipleFlags()
+ {
+ var flagKeys = new[] { "double_single_variable_feature", "boolean_feature" };
+ var userProfileServiceMock = MakeUserProfileServiceMock();
+ var saveArgsCollector = new List>();
+ userProfileServiceMock.Setup(up => up.Save(Capture.In(saveArgsCollector)));
+ var optimizely = new Optimizely(TestData.Datafile, EventDispatcherMock.Object,
+ LoggerMock.Object, ErrorHandlerMock.Object, userProfileServiceMock.Object);
+ var userContext = optimizely.CreateUserContext(UserID);
+ var expectedUserProfile = new UserProfile(UserID, new Dictionary
+ {
+ { "224", new Decision("280") },
+ { "122238", new Decision("122240") },
+ { "7723330021", new Decision(null) },
+ { "7718750065", new Decision(null) },
+ });
+
+ userContext.DecideForKeys(flagKeys);
+
+ LoggerMock.Verify(
+ l => l.Log(LogLevel.INFO,
+ "We were unable to get a user profile map from the UserProfileService."),
+ Times.Never);
+ LoggerMock.Verify(
+ l => l.Log(LogLevel.ERROR, "The UserProfileService returned an invalid map."),
+ Times.Never);
+ userProfileServiceMock.Verify(l => l.Lookup(UserID), Times.Once);
+ userProfileServiceMock.Verify(l => l.Save(It.IsAny>()),
+ Times.Once);
+ Assert.AreEqual(saveArgsCollector.First(), expectedUserProfile.ToMap());
+ }
[Test]
public void DecideForKeysWithOneFlag()
@@ -443,6 +562,44 @@ public void DecideForKeysWithOneFlag()
Assert.IsTrue(TestData.CompareObjects(decision, expDecision));
}
+ #endregion DecideForKeys
+
+ #region DecideAll
+
+ [Test]
+ public void DecideAllWithUpsShouldOnlyLookupSaveOnce()
+ {
+ var userProfileServiceMock = MakeUserProfileServiceMock();
+ var saveArgsCollector = new List>();
+ userProfileServiceMock.Setup(up => up.Save(Capture.In(saveArgsCollector)));
+ var optimizely = new Optimizely(TestData.Datafile, EventDispatcherMock.Object,
+ LoggerMock.Object, ErrorHandlerMock.Object, userProfileServiceMock.Object);
+ var user = optimizely.CreateUserContext(UserID);
+ var expectedUserProfile = new UserProfile(UserID, new Dictionary
+ {
+ { "224", new Decision("280") },
+ { "122238", new Decision("122240") },
+ { "122241", new Decision("122242") },
+ { "122235", new Decision("122236") },
+ { "7723330021", new Decision(null) },
+ { "7718750065", new Decision(null) },
+ });
+
+ user.DecideAll();
+
+ LoggerMock.Verify(
+ l => l.Log(LogLevel.INFO,
+ "We were unable to get a user profile map from the UserProfileService."),
+ Times.Never);
+ LoggerMock.Verify(
+ l => l.Log(LogLevel.ERROR, "The UserProfileService returned an invalid map."),
+ Times.Never);
+ userProfileServiceMock.Verify(l => l.Lookup(UserID), Times.Once);
+ userProfileServiceMock.Verify(l => l.Save(It.IsAny>()),
+ Times.Once);
+ Assert.AreEqual(saveArgsCollector.First(), expectedUserProfile.ToMap());
+ }
+
[Test]
public void DecideAllTwoFlag()
{
@@ -650,7 +807,7 @@ public void DecideAllAllFlags()
null,
flagKey10,
user,
- new string[0]);
+ new[] { "Variable value for key \"any_key\" is invalid or wrong type." });
Assert.IsTrue(TestData.CompareObjects(decisions[flagKey10], expDecision10));
}
@@ -924,6 +1081,12 @@ public void TestDecisionNotification()
{
"decisionEventDispatched", true
},
+ {
+ "experimentId", "122235"
+ },
+ {
+ "variationId", "122236"
+ },
};
var userAttributes = new UserAttributes
diff --git a/OptimizelySDK.Tests/Properties/AssemblyInfo.cs b/OptimizelySDK.Tests/Properties/AssemblyInfo.cs
index 8ceae607..f122893f 100644
--- a/OptimizelySDK.Tests/Properties/AssemblyInfo.cs
+++ b/OptimizelySDK.Tests/Properties/AssemblyInfo.cs
@@ -30,6 +30,6 @@
//
// You can specify all the values or you can default the Revision and Build Numbers
// by using the '*' as shown below:
-[assembly: AssemblyVersion("4.0.0.0")]
-[assembly: AssemblyFileVersion("4.0.0.0")]
-[assembly: AssemblyInformationalVersion("4.0.0-beta")] // Used by Nuget.
+[assembly: AssemblyVersion("4.1.0.0")]
+[assembly: AssemblyFileVersion("4.1.0.0")]
+[assembly: AssemblyInformationalVersion("4.1.0")] // Used by NuGet.
diff --git a/OptimizelySDK.sln.DotSettings b/OptimizelySDK.sln.DotSettings
index 3ccf7ffc..8ee6e5a4 100644
--- a/OptimizelySDK.sln.DotSettings
+++ b/OptimizelySDK.sln.DotSettings
@@ -43,10 +43,15 @@
<Policy Inspect="True" Prefix="I" Suffix="" Style="AaBb" /><Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" />
+ <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static readonly fields (private)"><ElementKinds><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy>
+ <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Enum members"><ElementKinds><Kind Name="ENUM_MEMBER" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb" /></Policy></Policy>
+ <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Local constants"><ElementKinds><Kind Name="LOCAL_CONSTANT" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /></Policy>
+ <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Interfaces"><ElementKinds><Kind Name="INTERFACE" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="I" Suffix="" Style="AaBb" /></Policy>TrueTrueTrueTrue
+ TrueTrueTrueTrue
diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs
index e6088d7e..1e364b29 100644
--- a/OptimizelySDK/Bucketing/DecisionService.cs
+++ b/OptimizelySDK/Bucketing/DecisionService.cs
@@ -1,5 +1,5 @@
/*
-* Copyright 2017-2022, Optimizely
+* Copyright 2017-2022, 2024 Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using OptimizelySDK.Entity;
using OptimizelySDK.ErrorHandler;
using OptimizelySDK.Logger;
@@ -84,9 +85,9 @@ public DecisionService(Bucketer bucketer, IErrorHandler errorHandler,
///
/// Get a Variation of an Experiment for a user to be allocated into.
///
- /// The Experiment the user will be bucketed into.
- /// Optimizely user context.
- /// Project config.
+ /// The Experiment the user will be bucketed into.
+ /// Optimizely user context.
+ /// Project config.
/// The Variation the user is allocated into.
public virtual Result GetVariation(Experiment experiment,
OptimizelyUserContext user,
@@ -99,11 +100,11 @@ ProjectConfig config
///
/// Get a Variation of an Experiment for a user to be allocated into.
///
- /// The Experiment the user will be bucketed into.
- /// optimizely user context.
- /// Project Config.
- /// An array of decision options.
- /// The Variation the user is allocated into.
+ /// The Experiment the user will be bucketed into.
+ /// Optimizely user context.
+ /// Project Config.
+ /// An array of decision options.
+ ///
public virtual Result GetVariation(Experiment experiment,
OptimizelyUserContext user,
ProjectConfig config,
@@ -111,97 +112,107 @@ OptimizelyDecideOption[] options
)
{
var reasons = new DecisionReasons();
- var userId = user.GetUserId();
+
+ var ignoreUps = options.Contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE);
+ UserProfileTracker userProfileTracker = null;
+
+ if (UserProfileService != null && !ignoreUps)
+ {
+ userProfileTracker = new UserProfileTracker(UserProfileService, user.GetUserId(),
+ Logger, ErrorHandler);
+ userProfileTracker.LoadUserProfile(reasons);
+ }
+
+ var response = GetVariation(experiment, user, config, options, userProfileTracker,
+ reasons);
+
+ if (UserProfileService != null && !ignoreUps &&
+ userProfileTracker?.ProfileUpdated == true)
+ {
+ userProfileTracker.SaveUserProfile();
+ }
+
+ return response;
+ }
+
+ ///
+ /// Get a Variation of an Experiment for a user to be allocated into.
+ ///
+ /// The Experiment the user will be bucketed into.
+ /// Optimizely user context.
+ /// Project Config.
+ /// An array of decision options.
+ /// A UserProfileTracker object.
+ /// Set of reasons for the decision.
+ /// The Variation the user is allocated into.
+ public virtual Result GetVariation(Experiment experiment,
+ OptimizelyUserContext user,
+ ProjectConfig config,
+ OptimizelyDecideOption[] options,
+ UserProfileTracker userProfileTracker,
+ DecisionReasons reasons = null
+ )
+ {
+ if (reasons == null)
+ {
+ reasons = new DecisionReasons();
+ }
+
if (!ExperimentUtils.IsExperimentActive(experiment, Logger))
{
+ var message = reasons.AddInfo($"Experiment {experiment.Key} is not running.");
+ Logger.Log(LogLevel.INFO, message);
return Result.NullResult(reasons);
}
- // check if a forced variation is set
- var decisionVariationResult = GetForcedVariation(experiment.Key, userId, config);
- reasons += decisionVariationResult.DecisionReasons;
- var variation = decisionVariationResult.ResultObject;
+ var userId = user.GetUserId();
+
+ var decisionVariation = GetForcedVariation(experiment.Key, userId, config);
+ reasons += decisionVariation.DecisionReasons;
+ var variation = decisionVariation.ResultObject;
if (variation == null)
{
- decisionVariationResult = GetWhitelistedVariation(experiment, user.GetUserId());
- reasons += decisionVariationResult.DecisionReasons;
-
- variation = decisionVariationResult.ResultObject;
+ decisionVariation = GetWhitelistedVariation(experiment, user.GetUserId());
+ reasons += decisionVariation.DecisionReasons;
+ variation = decisionVariation.ResultObject;
}
if (variation != null)
{
- decisionVariationResult.SetReasons(reasons);
- return decisionVariationResult;
+ decisionVariation.SetReasons(reasons);
+ return decisionVariation;
}
- // fetch the user profile map from the user profile service
- var ignoreUPS = Array.Exists(options,
- option => option == OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE);
-
- UserProfile userProfile = null;
- if (!ignoreUPS && UserProfileService != null)
+ if (userProfileTracker != null)
{
- try
- {
- var userProfileMap = UserProfileService.Lookup(user.GetUserId());
- if (userProfileMap != null &&
- UserProfileUtil.IsValidUserProfileMap(userProfileMap))
- {
- userProfile = UserProfileUtil.ConvertMapToUserProfile(userProfileMap);
- decisionVariationResult =
- GetStoredVariation(experiment, userProfile, config);
- reasons += decisionVariationResult.DecisionReasons;
- if (decisionVariationResult.ResultObject != null)
- {
- return decisionVariationResult.SetReasons(reasons);
- }
- }
- else if (userProfileMap == null)
- {
- Logger.Log(LogLevel.INFO,
- reasons.AddInfo(
- "We were unable to get a user profile map from the UserProfileService."));
- }
- else
- {
- Logger.Log(LogLevel.ERROR,
- reasons.AddInfo("The UserProfileService returned an invalid map."));
- }
- }
- catch (Exception exception)
+ decisionVariation =
+ GetStoredVariation(experiment, userProfileTracker.UserProfile, config);
+ reasons += decisionVariation.DecisionReasons;
+ variation = decisionVariation.ResultObject;
+ if (variation != null)
{
- Logger.Log(LogLevel.ERROR, reasons.AddInfo(exception.Message));
- ErrorHandler.HandleError(
- new Exceptions.OptimizelyRuntimeException(exception.Message));
+ return decisionVariation;
}
}
- var filteredAttributes = user.GetAttributes();
- var doesUserMeetAudienceConditionsResult =
- ExperimentUtils.DoesUserMeetAudienceConditions(config, experiment, user,
- LOGGING_KEY_TYPE_EXPERIMENT, experiment.Key, Logger);
- reasons += doesUserMeetAudienceConditionsResult.DecisionReasons;
- if (doesUserMeetAudienceConditionsResult.ResultObject)
+ var decisionMeetAudience = ExperimentUtils.DoesUserMeetAudienceConditions(config,
+ experiment, user,
+ LOGGING_KEY_TYPE_EXPERIMENT, experiment.Key, Logger);
+ reasons += decisionMeetAudience.DecisionReasons;
+ if (decisionMeetAudience.ResultObject)
{
- // Get Bucketing ID from user attributes.
- var bucketingIdResult = GetBucketingId(userId, filteredAttributes);
- reasons += bucketingIdResult.DecisionReasons;
+ var bucketingId = GetBucketingId(userId, user.GetAttributes()).ResultObject;
- decisionVariationResult = Bucketer.Bucket(config, experiment,
- bucketingIdResult.ResultObject, userId);
- reasons += decisionVariationResult.DecisionReasons;
+ decisionVariation = Bucketer.Bucket(config, experiment, bucketingId, userId);
+ reasons += decisionVariation.DecisionReasons;
+ variation = decisionVariation.ResultObject;
- if (decisionVariationResult.ResultObject?.Key != null)
+ if (variation != null)
{
- if (UserProfileService != null && !ignoreUPS)
+ if (userProfileTracker != null)
{
- var bucketerUserProfile = userProfile ??
- new UserProfile(userId,
- new Dictionary());
- SaveVariation(experiment, decisionVariationResult.ResultObject,
- bucketerUserProfile);
+ userProfileTracker.UpdateUserProfile(experiment, variation);
}
else
{
@@ -210,7 +221,7 @@ OptimizelyDecideOption[] options
}
}
- return decisionVariationResult.SetReasons(reasons);
+ return decisionVariation.SetReasons(reasons);
}
Logger.Log(LogLevel.INFO,
@@ -253,8 +264,7 @@ ProjectConfig config
if (experimentToVariationMap.ContainsKey(experimentId) == false)
{
Logger.Log(LogLevel.DEBUG,
- $@"No experiment ""{experimentKey}"" mapped to user ""{userId
- }"" in the forced variation map.");
+ $@"No experiment ""{experimentKey}"" mapped to user ""{userId}"" in the forced variation map.");
return Result.NullResult(reasons);
}
@@ -263,8 +273,7 @@ ProjectConfig config
if (string.IsNullOrEmpty(variationId))
{
Logger.Log(LogLevel.DEBUG,
- $@"No variation mapped to experiment ""{experimentKey
- }"" in the forced variation map.");
+ $@"No variation mapped to experiment ""{experimentKey}"" in the forced variation map.");
return Result.NullResult(reasons);
}
@@ -277,8 +286,7 @@ ProjectConfig config
}
Logger.Log(LogLevel.DEBUG,
- reasons.AddInfo($@"Variation ""{variationKey}"" is mapped to experiment ""{
- experimentKey}"" and user ""{userId}"" in the forced variation map"));
+ reasons.AddInfo($@"Variation ""{variationKey}"" is mapped to experiment ""{experimentKey}"" and user ""{userId}"" in the forced variation map"));
var variation = config.GetVariationFromKey(experimentKey, variationKey);
@@ -322,8 +330,7 @@ ProjectConfig config
}
Logger.Log(LogLevel.DEBUG,
- $@"Variation mapped to experiment ""{experimentKey
- }"" has been removed for user ""{userId}"".");
+ $@"Variation mapped to experiment ""{experimentKey}"" has been removed for user ""{userId}"".");
return true;
}
@@ -345,8 +352,7 @@ ProjectConfig config
ForcedVariationMap[userId][experimentId] = variationId;
Logger.Log(LogLevel.DEBUG,
- $@"Set variation ""{variationId}"" for experiment ""{experimentId}"" and user ""{
- userId}"" in the forced variation map.");
+ $@"Set variation ""{variationId}"" for experiment ""{experimentId}"" and user ""{userId}"" in the forced variation map.");
return true;
}
@@ -638,7 +644,8 @@ public virtual Result GetVariationForFeatureExperiment(
OptimizelyUserContext user,
UserAttributes filteredAttributes,
ProjectConfig config,
- OptimizelyDecideOption[] options
+ OptimizelyDecideOption[] options,
+ UserProfileTracker userProfileTracker = null
)
{
var reasons = new DecisionReasons();
@@ -679,7 +686,8 @@ OptimizelyDecideOption[] options
}
else
{
- var decisionResponse = GetVariation(experiment, user, config, options);
+ var decisionResponse = GetVariation(experiment, user, config, options,
+ userProfileTracker);
reasons += decisionResponse?.DecisionReasons;
decisionVariation = decisionResponse.ResultObject;
@@ -706,9 +714,9 @@ OptimizelyDecideOption[] options
///
/// Get the variation the user is bucketed into for the FeatureFlag
///
- /// The feature flag the user wants to access.
- /// User Identifier
- /// The user's attributes. This should be filtered to just attributes in the Datafile.
+ /// The feature flag the user wants to access.
+ /// The user context.
+ /// The project config.
/// null if the user is not bucketed into any variation or the FeatureDecision entity if the user is
/// successfully bucketed.
public virtual Result GetVariationForFeature(FeatureFlag featureFlag,
@@ -719,53 +727,101 @@ public virtual Result GetVariationForFeature(FeatureFlag featur
new OptimizelyDecideOption[] { });
}
- ///
- /// Get the variation the user is bucketed into for the FeatureFlag
- ///
- /// The feature flag the user wants to access.
- /// User Identifier
- /// The user's attributes. This should be filtered to just attributes in the Datafile.
- /// The user's attributes. This should be filtered to just attributes in the Datafile.
- /// An array of decision options.
- /// null if the user is not bucketed into any variation or the FeatureDecision entity if the user is
- /// successfully bucketed.
- public virtual Result GetVariationForFeature(FeatureFlag featureFlag,
+ public virtual List> GetVariationsForFeatureList(
+ List featureFlags,
OptimizelyUserContext user,
- ProjectConfig config,
+ ProjectConfig projectConfig,
UserAttributes filteredAttributes,
OptimizelyDecideOption[] options
)
{
- var reasons = new DecisionReasons();
- var userId = user.GetUserId();
- // Check if the feature flag has an experiment and the user is bucketed into that experiment.
- var decisionResult = GetVariationForFeatureExperiment(featureFlag, user,
- filteredAttributes, config, options);
- reasons += decisionResult.DecisionReasons;
+ var upsReasons = new DecisionReasons();
+
+ var ignoreUps = options.Contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE);
+ UserProfileTracker userProfileTracker = null;
- if (decisionResult.ResultObject != null)
+ if (UserProfileService != null && !ignoreUps)
{
- return Result.NewResult(decisionResult.ResultObject, reasons);
+ userProfileTracker = new UserProfileTracker(UserProfileService, user.GetUserId(),
+ Logger, ErrorHandler);
+ userProfileTracker.LoadUserProfile(upsReasons);
}
- // Check if the feature flag has rollout and the the user is bucketed into one of its rules.
- decisionResult = GetVariationForFeatureRollout(featureFlag, user, config);
- reasons += decisionResult.DecisionReasons;
+ var userId = user.GetUserId();
+ var decisions = new List>();
- if (decisionResult.ResultObject != null)
+ foreach (var featureFlag in featureFlags)
{
- Logger.Log(LogLevel.INFO,
- reasons.AddInfo(
- $"The user \"{userId}\" is bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
- return Result.NewResult(decisionResult.ResultObject, reasons);
+ var reasons = new DecisionReasons();
+ reasons += upsReasons;
+
+ // Check if the feature flag has an experiment and the user is bucketed into that experiment.
+ var decisionResult = GetVariationForFeatureExperiment(featureFlag, user,
+ filteredAttributes, projectConfig, options, userProfileTracker);
+ reasons += decisionResult.DecisionReasons;
+
+ if (decisionResult.ResultObject != null)
+ {
+ decisions.Add(
+ Result.NewResult(decisionResult.ResultObject, reasons));
+ continue;
+ }
+
+ // Check if the feature flag has rollout and the the user is bucketed into one of its rules.
+ decisionResult = GetVariationForFeatureRollout(featureFlag, user, projectConfig);
+ reasons += decisionResult.DecisionReasons;
+
+ if (decisionResult.ResultObject == null)
+ {
+ Logger.Log(LogLevel.INFO,
+ reasons.AddInfo(
+ $"The user \"{userId}\" is not bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
+ decisions.Add(Result.NewResult(
+ new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_ROLLOUT),
+ reasons));
+ }
+ else
+ {
+ Logger.Log(LogLevel.INFO,
+ reasons.AddInfo(
+ $"The user \"{userId}\" is bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
+ decisions.Add(
+ Result.NewResult(decisionResult.ResultObject, reasons));
+ }
}
- Logger.Log(LogLevel.INFO,
- reasons.AddInfo(
- $"The user \"{userId}\" is not bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
- return Result.NewResult(
- new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_ROLLOUT), reasons);
- ;
+ if (UserProfileService != null && !ignoreUps &&
+ userProfileTracker?.ProfileUpdated == true)
+ {
+ userProfileTracker.SaveUserProfile();
+ }
+
+ return decisions;
+ }
+
+ ///
+ /// Get the variation the user is bucketed into for the FeatureFlag
+ ///
+ /// The feature flag the user wants to access.
+ /// The user context.
+ /// The project config.
+ /// The user's attributes. This should be filtered to just attributes in the Datafile.
+ /// An array of decision options.
+ /// null if the user is not bucketed into any variation or the FeatureDecision entity if the user is
+ /// successfully bucketed.
+ public virtual Result GetVariationForFeature(FeatureFlag featureFlag,
+ OptimizelyUserContext user,
+ ProjectConfig config,
+ UserAttributes filteredAttributes,
+ OptimizelyDecideOption[] options
+ )
+ {
+ return GetVariationsForFeatureList(new List { featureFlag },
+ user,
+ config,
+ filteredAttributes,
+ options).
+ First();
}
///
diff --git a/OptimizelySDK/Bucketing/UserProfileTracker.cs b/OptimizelySDK/Bucketing/UserProfileTracker.cs
new file mode 100644
index 00000000..226cca48
--- /dev/null
+++ b/OptimizelySDK/Bucketing/UserProfileTracker.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Collections.Generic;
+using OptimizelySDK.Entity;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Logger;
+using OptimizelySDK.OptimizelyDecisions;
+
+namespace OptimizelySDK.Bucketing
+{
+ public class UserProfileTracker
+ {
+ public UserProfile UserProfile { get; private set; }
+ public bool ProfileUpdated { get; private set; }
+
+ private readonly UserProfileService _userProfileService;
+ private readonly string _userId;
+ private readonly ILogger _logger;
+ private readonly IErrorHandler _errorHandler;
+
+ public UserProfileTracker(UserProfileService userProfileService, string userId, ILogger logger, IErrorHandler errorHandler)
+ {
+ _userProfileService = userProfileService;
+ _userId = userId;
+ _logger = logger;
+ _errorHandler = errorHandler;
+ ProfileUpdated = false;
+ UserProfile = null;
+ }
+
+ public void LoadUserProfile(DecisionReasons reasons)
+ {
+ try
+ {
+ var userProfileMap = _userProfileService.Lookup(_userId);
+ if (userProfileMap == null)
+ {
+ _logger.Log(LogLevel.INFO,
+ reasons.AddInfo(
+ "We were unable to get a user profile map from the UserProfileService."));
+ }
+ else if (UserProfileUtil.IsValidUserProfileMap(userProfileMap))
+ {
+ UserProfile = UserProfileUtil.ConvertMapToUserProfile(userProfileMap);
+ }
+ else
+ {
+ _logger.Log(LogLevel.WARN,
+ reasons.AddInfo("The UserProfileService returned an invalid map."));
+ }
+ }
+ catch (Exception exception)
+ {
+ _logger.Log(LogLevel.ERROR, reasons.AddInfo(exception.Message));
+ _errorHandler.HandleError(
+ new Exceptions.OptimizelyRuntimeException(exception.Message));
+ }
+
+ if (UserProfile == null)
+ {
+ UserProfile = new UserProfile(_userId, new Dictionary());
+ }
+ }
+
+ public void UpdateUserProfile(Experiment experiment, Variation variation)
+ {
+ var experimentId = experiment.Id;
+ var variationId = variation.Id;
+ Decision decision;
+ if (UserProfile.ExperimentBucketMap.ContainsKey(experimentId))
+ {
+ decision = UserProfile.ExperimentBucketMap[experimentId];
+ decision.VariationId = variationId;
+ }
+ else
+ {
+ decision = new Decision(variationId);
+ }
+
+ UserProfile.ExperimentBucketMap[experimentId] = decision;
+ ProfileUpdated = true;
+
+ _logger.Log(LogLevel.INFO,
+ $"Saved variation \"{variationId}\" of experiment \"{experimentId}\" for user \"{UserProfile.UserId}\".");
+ }
+
+ public void SaveUserProfile()
+ {
+ if (!ProfileUpdated)
+ {
+ return;
+ }
+
+ try
+ {
+ _userProfileService.Save(UserProfile.ToMap());
+ _logger.Log(LogLevel.INFO,
+ $"Saved user profile of user \"{UserProfile.UserId}\".");
+ }
+ catch (Exception exception)
+ {
+ _logger.Log(LogLevel.WARN,
+ $"Failed to save user profile of user \"{UserProfile.UserId}\".");
+ _errorHandler.HandleError(
+ new Exceptions.OptimizelyRuntimeException(exception.Message));
+ }
+ }
+ }
+}
diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs
index 9da300f8..99cbdaaf 100644
--- a/OptimizelySDK/Optimizely.cs
+++ b/OptimizelySDK/Optimizely.cs
@@ -1,5 +1,5 @@
/*
- * Copyright 2017-2023, Optimizely
+ * Copyright 2017-2024, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use file except in compliance with the License.
@@ -573,8 +573,7 @@ public virtual bool IsFeatureEnabled(string featureKey, string userId,
else
{
Logger.Log(LogLevel.INFO,
- $@"The user ""{userId}"" is not being experimented on feature ""{featureKey
- }"".");
+ $@"The user ""{userId}"" is not being experimented on feature ""{featureKey}"".");
}
}
@@ -624,8 +623,7 @@ public virtual T GetFeatureVariableValueForType(string featureKey, string var
if (config == null)
{
Logger.Log(LogLevel.ERROR,
- $@"Datafile has invalid format. Failing '{
- FeatureVariable.GetFeatureVariableTypeName(variableType)}'.");
+ $@"Datafile has invalid format. Failing '{FeatureVariable.GetFeatureVariableTypeName(variableType)}'.");
return default;
}
@@ -649,15 +647,13 @@ public virtual T GetFeatureVariableValueForType(string featureKey, string var
if (featureVariable == null)
{
Logger.Log(LogLevel.ERROR,
- $@"No feature variable was found for key ""{variableKey}"" in feature flag ""{
- featureKey}"".");
+ $@"No feature variable was found for key ""{variableKey}"" in feature flag ""{featureKey}"".");
return default;
}
else if (featureVariable.Type != variableType)
{
Logger.Log(LogLevel.ERROR,
- $@"Variable is of type ""{featureVariable.Type
- }"", but you requested it as type ""{variableType}"".");
+ $@"Variable is of type ""{featureVariable.Type}"", but you requested it as type ""{variableType}"".");
return default;
}
@@ -681,28 +677,24 @@ public virtual T GetFeatureVariableValueForType(string featureKey, string var
{
variableValue = featureVariableUsageInstance.Value;
Logger.Log(LogLevel.INFO,
- $@"Got variable value ""{variableValue}"" for variable ""{variableKey
- }"" of feature flag ""{featureKey}"".");
+ $@"Got variable value ""{variableValue}"" for variable ""{variableKey}"" of feature flag ""{featureKey}"".");
}
else
{
Logger.Log(LogLevel.INFO,
- $@"Feature ""{featureKey}"" is not enabled for user {userId
- }. Returning the default variable value ""{variableValue}"".");
+ $@"Feature ""{featureKey}"" is not enabled for user {userId}. Returning the default variable value ""{variableValue}"".");
}
}
else
{
Logger.Log(LogLevel.INFO,
- $@"Variable ""{variableKey}"" is not used in variation ""{variation.Key
- }"", returning default value ""{variableValue}"".");
+ $@"Variable ""{variableKey}"" is not used in variation ""{variation.Key}"", returning default value ""{variableValue}"".");
}
}
else
{
Logger.Log(LogLevel.INFO,
- $@"User ""{userId}"" is not in any variation for feature flag ""{featureKey
- }"", returning default value ""{variableValue}"".");
+ $@"User ""{userId}"" is not in any variation for feature flag ""{featureKey}"", returning default value ""{variableValue}"".");
}
var sourceInfo = new Dictionary();
@@ -861,6 +853,7 @@ private OptimizelyUserContext CreateUserContextCopy(string userId,
///
If the SDK finds an error, it’ll return a decision with null for variationKey. The decision will include an error message in reasons.
///
///
+ /// User context to be used to make decision.
/// A flag key for which a decision will be made.
/// A list of options for decision-making.
/// A decision result.
@@ -877,196 +870,251 @@ OptimizelyDecideOption[] options
ErrorHandler, Logger);
}
- if (key == null)
- {
- return OptimizelyDecision.NewErrorDecision(key,
- user,
- DecisionMessage.Reason(DecisionMessage.FLAG_KEY_INVALID, key),
- ErrorHandler, Logger);
- }
+ var allOptions = GetAllOptions(options).
+ Where(opt => opt != OptimizelyDecideOption.ENABLED_FLAGS_ONLY).
+ ToArray();
+
+ return DecideForKeys(user, new[] { key }, allOptions, true)[key];
+ }
- var flag = config.GetFeatureFlagFromKey(key);
- if (flag.Key == null)
+ internal Dictionary DecideAll(OptimizelyUserContext user,
+ OptimizelyDecideOption[] options
+ )
+ {
+ var decisionMap = new Dictionary();
+
+ var projectConfig = ProjectConfigManager?.GetConfig();
+ if (projectConfig == null)
{
- return OptimizelyDecision.NewErrorDecision(key,
- user,
- DecisionMessage.Reason(DecisionMessage.FLAG_KEY_INVALID, key),
- ErrorHandler, Logger);
+ Logger.Log(LogLevel.ERROR,
+ "Optimizely instance is not valid, failing DecideAll call.");
+ return decisionMap;
}
- var userId = user?.GetUserId();
- var userAttributes = user?.GetAttributes();
- var decisionEventDispatched = false;
- var allOptions = GetAllOptions(options);
- var decisionReasons = new DecisionReasons();
- FeatureDecision decision = null;
+ var allFlags = projectConfig.FeatureFlags;
+ var allFlagKeys = allFlags.Select(v => v.Key).ToArray();
- var decisionContext = new OptimizelyDecisionContext(flag.Key);
- var forcedDecisionVariation =
- DecisionService.ValidatedForcedDecision(decisionContext, config, user);
- decisionReasons += forcedDecisionVariation.DecisionReasons;
+ return DecideForKeys(user, allFlagKeys, options);
+ }
+
+ internal Dictionary DecideForKeys(OptimizelyUserContext user,
+ string[] keys,
+ OptimizelyDecideOption[] options,
+ bool ignoreDefaultOptions = false
+ )
+ {
+ var decisionDictionary = new Dictionary();
- if (forcedDecisionVariation.ResultObject != null)
+ var projectConfig = ProjectConfigManager?.GetConfig();
+ if (projectConfig == null)
{
- decision = new FeatureDecision(null, forcedDecisionVariation.ResultObject,
- FeatureDecision.DECISION_SOURCE_FEATURE_TEST);
+ Logger.Log(LogLevel.ERROR,
+ "Optimizely instance is not valid, failing DecideForKeys call.");
+ return decisionDictionary;
}
- else
+
+ if (keys.Length == 0)
{
- var flagDecisionResult = DecisionService.GetVariationForFeature(
- flag,
- user,
- config,
- userAttributes,
- allOptions
- );
- decisionReasons += flagDecisionResult.DecisionReasons;
- decision = flagDecisionResult.ResultObject;
+ return decisionDictionary;
}
- var featureEnabled = false;
+ var allOptions = ignoreDefaultOptions ? options : GetAllOptions(options);
- if (decision?.Variation != null)
+ var flagDecisions = new Dictionary();
+ var decisionReasonsMap = new Dictionary();
+
+ var flagsWithoutForcedDecisions = new List();
+
+ var validKeys = new List();
+
+ foreach (var key in keys)
{
- featureEnabled = decision.Variation.FeatureEnabled.GetValueOrDefault();
+ var flag = projectConfig.GetFeatureFlagFromKey(key);
+ if (flag.Key == null)
+ {
+ decisionDictionary.Add(key,
+ OptimizelyDecision.NewErrorDecision(key, user,
+ DecisionMessage.Reason(DecisionMessage.FLAG_KEY_INVALID, key),
+ ErrorHandler, Logger));
+ continue;
+ }
+
+ validKeys.Add(key);
+
+ var decisionReasons = new DecisionReasons();
+ decisionReasonsMap.Add(key, decisionReasons);
+
+ var optimizelyDecisionContext = new OptimizelyDecisionContext(key);
+ var forcedDecisionVariation =
+ DecisionService.ValidatedForcedDecision(optimizelyDecisionContext, projectConfig, user);
+ decisionReasons += forcedDecisionVariation.DecisionReasons;
+
+ if (forcedDecisionVariation.ResultObject != null)
+ {
+ flagDecisions.Add(key, new FeatureDecision(null,
+ forcedDecisionVariation.ResultObject,
+ FeatureDecision.DECISION_SOURCE_FEATURE_TEST));
+ }
+ else
+ {
+ flagsWithoutForcedDecisions.Add(flag);
+ }
}
- if (featureEnabled)
+ var decisionsList = DecisionService.GetVariationsForFeatureList(
+ flagsWithoutForcedDecisions, user, projectConfig, user.GetAttributes(),
+ allOptions);
+
+ for (var i = 0; i < decisionsList.Count; i += 1)
{
- Logger.Log(LogLevel.INFO,
- "Feature \"" + key + "\" is enabled for user \"" + userId + "\"");
+ var decision = decisionsList[i];
+ var flagKey = flagsWithoutForcedDecisions[i].Key;
+ flagDecisions.Add(flagKey, decision.ResultObject);
+ decisionReasonsMap[flagKey] += decision.DecisionReasons;
}
- else
+
+ foreach (var key in validKeys)
{
- Logger.Log(LogLevel.INFO,
- "Feature \"" + key + "\" is not enabled for user \"" + userId + "\"");
+ var flagDecision = flagDecisions[key];
+ var decisionReasons = decisionReasonsMap[key];
+
+ var optimizelyDecision = CreateOptimizelyDecision(user, key, flagDecision,
+ decisionReasons, allOptions.ToList(), projectConfig);
+ if (!allOptions.Contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) ||
+ optimizelyDecision.Enabled)
+ {
+ decisionDictionary.Add(key, optimizelyDecision);
+ }
}
- var variableMap = new Dictionary();
- if (flag?.Variables != null &&
- !allOptions.Contains(OptimizelyDecideOption.EXCLUDE_VARIABLES))
+ return decisionDictionary;
+ }
+
+ private OptimizelyDecision CreateOptimizelyDecision(
+ OptimizelyUserContext user,
+ string flagKey,
+ FeatureDecision flagDecision,
+ DecisionReasons decisionReasons,
+ List allOptions,
+ ProjectConfig projectConfig
+ )
+ {
+ var userId = user.GetUserId();
+ string experimentId = null;
+ string variationId = null;
+ var flagEnabled = false;
+ if (flagDecision.Variation != null)
{
- foreach (var featureVariable in flag?.Variables)
+ if (flagDecision.Variation.IsFeatureEnabled)
{
- var variableValue = featureVariable.DefaultValue;
- if (featureEnabled)
- {
- var featureVariableUsageInstance =
- decision?.Variation.GetFeatureVariableUsageFromId(featureVariable.Id);
- if (featureVariableUsageInstance != null)
- {
- variableValue = featureVariableUsageInstance.Value;
- }
- }
+ 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}");
- var typeCastedValue =
- GetTypeCastedVariableValue(variableValue, featureVariable.Type);
+ var variableMap = new Dictionary();
+ if (!allOptions.Contains(OptimizelyDecideOption.EXCLUDE_VARIABLES))
+ {
+ var decisionVariables = GetDecisionVariableMap(
+ projectConfig.GetFeatureFlagFromKey(flagKey),
+ flagDecision.Variation,
+ flagEnabled);
+ variableMap = decisionVariables.ResultObject;
+ decisionReasons += decisionVariables.DecisionReasons;
+ }
- if (typeCastedValue is OptimizelyJSON)
- {
- typeCastedValue = ((OptimizelyJSON)typeCastedValue).ToDictionary();
- }
+ var optimizelyJson = new OptimizelyJSON(variableMap, ErrorHandler, Logger);
- variableMap.Add(featureVariable.Key, typeCastedValue);
- }
+ var decisionSource = FeatureDecision.DECISION_SOURCE_ROLLOUT;
+ if (flagDecision.Source != null)
+ {
+ decisionSource = flagDecision.Source;
}
- var optimizelyJSON = new OptimizelyJSON(variableMap, ErrorHandler, Logger);
+ var includeReasons = allOptions.Contains(OptimizelyDecideOption.INCLUDE_REASONS);
+ var reasonsToReport = decisionReasons.ToReport(includeReasons).ToArray();
+ var variationKey = flagDecision.Variation?.Key;
+ // TODO: add ruleKey values when available later. use a copy of experimentKey until then.
+ // add to event metadata as well (currently set to experimentKey)
+ var ruleKey = flagDecision.Experiment?.Key;
- var decisionSource = decision?.Source ?? FeatureDecision.DECISION_SOURCE_ROLLOUT;
+ var decisionEventDispatched = false;
if (!allOptions.Contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT))
{
- decisionEventDispatched = SendImpressionEvent(decision?.Experiment,
- decision?.Variation, userId, userAttributes, config, key, decisionSource,
- featureEnabled);
+ decisionEventDispatched = SendImpressionEvent(
+ flagDecision.Experiment,
+ flagDecision.Variation,
+ userId,
+ user.GetAttributes(),
+ projectConfig,
+ flagKey,
+ decisionSource,
+ flagEnabled);
}
- var reasonsToReport = decisionReasons
- .ToReport(allOptions.Contains(OptimizelyDecideOption.INCLUDE_REASONS))
- .ToArray();
- var variationKey = decision?.Variation?.Key;
-
- // TODO: add ruleKey values when available later. use a copy of experimentKey until then.
- var ruleKey = decision?.Experiment?.Key;
-
var decisionInfo = new Dictionary
{
- { "flagKey", key },
- { "enabled", featureEnabled },
+ { "flagKey", flagKey },
+ { "enabled", flagEnabled },
{ "variables", variableMap },
{ "variationKey", variationKey },
{ "ruleKey", ruleKey },
{ "reasons", reasonsToReport },
{ "decisionEventDispatched", decisionEventDispatched },
+ { "experimentId", experimentId },
+ { "variationId", variationId },
};
NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Decision,
- DecisionNotificationTypes.FLAG, userId,
- userAttributes ?? new UserAttributes(), decisionInfo);
+ DecisionNotificationTypes.FLAG, userId, user.GetAttributes(), decisionInfo);
return new OptimizelyDecision(
variationKey,
- featureEnabled,
- optimizelyJSON,
+ flagEnabled,
+ optimizelyJson,
ruleKey,
- key,
+ flagKey,
user,
reasonsToReport);
}
- internal Dictionary DecideAll(OptimizelyUserContext user,
- OptimizelyDecideOption[] options
- )
- {
- var decisionMap = new Dictionary();
-
- var projectConfig = ProjectConfigManager?.GetConfig();
- if (projectConfig == null)
- {
- Logger.Log(LogLevel.ERROR,
- "Optimizely instance is not valid, failing isFeatureEnabled call.");
- return decisionMap;
- }
-
- var allFlags = projectConfig.FeatureFlags;
- var allFlagKeys = allFlags.Select(v => v.Key).ToArray();
-
- return DecideForKeys(user, allFlagKeys, options);
- }
-
- internal Dictionary DecideForKeys(OptimizelyUserContext user,
- string[] keys,
- OptimizelyDecideOption[] options
- )
+ private Result> GetDecisionVariableMap(FeatureFlag flag, Variation variation, bool featureEnabled)
{
- var decisionDictionary = new Dictionary();
-
- var projectConfig = ProjectConfigManager?.GetConfig();
- if (projectConfig == null)
- {
- Logger.Log(LogLevel.ERROR,
- "Optimizely instance is not valid, failing isFeatureEnabled call.");
- return decisionDictionary;
- }
+ var reasons = new DecisionReasons();
+ var valuesMap = new Dictionary();
- if (keys.Length == 0)
+ foreach (var variable in flag.Variables)
{
- return decisionDictionary;
- }
-
- var allOptions = GetAllOptions(options);
+ var value = variable.DefaultValue;
+ if (featureEnabled)
+ {
+ var instance = variation.GetFeatureVariableUsageFromId(variable.Id);
+ if (instance != null)
+ {
+ value = instance.Value;
+ }
+ }
- foreach (var key in keys)
- {
- var decision = Decide(user, key, options);
- if (!allOptions.Contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) ||
- decision.Enabled)
+ var convertedValue = GetTypeCastedVariableValue(value, variable.Type);
+ if (convertedValue == null)
{
- decisionDictionary.Add(key, decision);
+ reasons.AddError(DecisionMessage.Reason(DecisionMessage.VARIABLE_VALUE_INVALID, variable.Key));
}
+ else if (convertedValue is OptimizelyJSON optimizelyJson)
+ {
+ convertedValue = optimizelyJson.ToDictionary();
+ }
+
+ valuesMap[variable.Key] = convertedValue;
}
- return decisionDictionary;
+ return Result>.NewResult(valuesMap, reasons);
}
private OptimizelyDecideOption[] GetAllOptions(OptimizelyDecideOption[] options)
diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj
index 1812a2ad..5f041ac1 100644
--- a/OptimizelySDK/OptimizelySDK.csproj
+++ b/OptimizelySDK/OptimizelySDK.csproj
@@ -75,6 +75,7 @@
+
diff --git a/OptimizelySDK/Properties/AssemblyInfo.cs b/OptimizelySDK/Properties/AssemblyInfo.cs
index 07e29d0a..96d04867 100644
--- a/OptimizelySDK/Properties/AssemblyInfo.cs
+++ b/OptimizelySDK/Properties/AssemblyInfo.cs
@@ -41,6 +41,6 @@
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
-[assembly: AssemblyVersion("4.0.0.0")]
-[assembly: AssemblyFileVersion("4.0.0.0")]
-[assembly: AssemblyInformationalVersion("4.0.0-beta")] // Used by Nuget.
+[assembly: AssemblyVersion("4.1.0.0")]
+[assembly: AssemblyFileVersion("4.1.0.0")]
+[assembly: AssemblyInformationalVersion("4.1.0")] // Used by NuGet.
diff --git a/README.md b/README.md
index eb0da22c..9de4ddc7 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Optimizely C# SDK

-[](https://travis-ci.org/optimizely/csharp-sdk)
+
[](https://www.nuget.org/packages/Optimizely.SDK/)
[](http://www.apache.org/licenses/LICENSE-2.0)
@@ -104,7 +104,7 @@ User can provide variables using following procedure:
```
+ type="OptimizelySDK.OptimizelySDKConfigSection, OptimizelySDK, Version=4.1.0.0, Culture=neutral, PublicKeyToken=null" />
```
2. Now add **optlySDKConfigSection** below ****. In this section you can add and set following **HttpProjectConfigManager** and **BatchEventProcessor** variables:
@@ -251,4 +251,4 @@ Optimizely SDK uses third party software:
- Ruby - https://github.com/optimizely/ruby-sdk
-- Swift - https://github.com/optimizely/swift-sdk
\ No newline at end of file
+- Swift - https://github.com/optimizely/swift-sdk