diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 000000000..7b737155f
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,20 @@
+{
+ "name": "Javascript SDK",
+ "image": "mcr.microsoft.com/devcontainers/typescript-node:1-18-bookworm",
+
+ "postCreateCommand": "cd /workspaces/javascript-sdk && npm install -g npm && npm install",
+
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "dbaeumer.vscode-eslint",
+ "eamodio.gitlens",
+ "esbenp.prettier-vscode",
+ "Gruntfuggly.todo-tree",
+ "github.vscode-github-actions",
+ "ms-vscode.test-adapter-converter",
+ "vitest.explorer"
+ ]
+ }
+ }
+}
diff --git a/packages/optimizely-sdk/.eslintignore b/.eslintignore
similarity index 100%
rename from packages/optimizely-sdk/.eslintignore
rename to .eslintignore
diff --git a/packages/optimizely-sdk/.eslintrc.js b/.eslintrc.js
similarity index 100%
rename from packages/optimizely-sdk/.eslintrc.js
rename to .eslintrc.js
diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml
new file mode 100644
index 000000000..855cdf50d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml
@@ -0,0 +1,106 @@
+name: 🐞 Bug
+description: File a bug/issue
+title: "[BUG]
"
+labels: ["bug", "needs-triage"]
+body:
+- type: checkboxes
+ attributes:
+ label: Is there an existing issue for this?
+ description: Please search to see if an issue already exists for the bug you encountered.
+ options:
+ - label: I have searched the existing issues
+ required: true
+- type: textarea
+ attributes:
+ label: SDK Version
+ description: Version of the SDK in use?
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: Current Behavior
+ description: A concise description of what you're experiencing.
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: Expected Behavior
+ description: A concise description of what you expected to happen.
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: Steps To Reproduce
+ description: Steps to reproduce the behavior.
+ placeholder: |
+ 1. In this environment...
+ 1. With this config...
+ 1. Run '...'
+ 1. See error...
+ validations:
+ required: true
+- type: dropdown
+ attributes:
+ label: SDK Type
+ description: Please select the type of JS SDK.
+ multiple: false
+ options:
+ - Browser
+ - Node
+ - React Native
+ - Edge/Lite
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: Node Version
+ description: What version of Node are you using?
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: Browsers impacted
+ description: What browsers are impacted?
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: Link
+ description: Link to code demonstrating the problem.
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: Logs
+ description: Logs/stack traces related to the problem (⚠️do not include sensitive information).
+ validations:
+ required: false
+- type: dropdown
+ attributes:
+ label: Severity
+ description: What is the severity of the problem?
+ multiple: true
+ options:
+ - Blocking development
+ - Affecting users
+ - Minor issue
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: Workaround/Solution
+ description: Do you have any workaround or solution in mind for the problem?
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: "Recent Change"
+ description: Has this issue started happening after an update or experiment change?
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: Conflicts
+ description: Are there other libraries/dependencies potentially in conflict?
+ validations:
+ required: false
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml
new file mode 100644
index 000000000..79c53247b
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml
@@ -0,0 +1,45 @@
+name: ✨Enhancement
+description: Create a new ticket for a Enhancement/Tech-initiative for the benefit of the SDK which would be considered for a minor version update.
+title: "[ENHANCEMENT] "
+labels: ["enhancement"]
+body:
+ - type: textarea
+ id: description
+ attributes:
+ label: "Description"
+ description: Briefly describe the enhancement in a few sentences.
+ placeholder: Short description...
+ validations:
+ required: true
+ - type: textarea
+ id: benefits
+ attributes:
+ label: "Benefits"
+ description: How would the enhancement benefit to your product or usage?
+ placeholder: Benefits...
+ validations:
+ required: true
+ - type: textarea
+ id: detail
+ attributes:
+ label: "Detail"
+ description: How would you like the enhancement to work? Please provide as much detail as possible
+ placeholder: Detailed description...
+ validations:
+ required: false
+ - type: textarea
+ id: examples
+ attributes:
+ label: "Examples"
+ description: Are there any examples of this enhancement in other products/services? If so, please provide links or references.
+ placeholder: Links/References...
+ validations:
+ required: false
+ - type: textarea
+ id: risks
+ attributes:
+ label: "Risks/Downsides"
+ description: Do you think this enhancement could have any potential downsides or risks?
+ placeholder: Risks/Downsides...
+ validations:
+ required: false
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md
new file mode 100644
index 000000000..a061f3356
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md
@@ -0,0 +1,4 @@
+
+## Feedback requesting a new feature can be shared [here.](https://feedback.optimizely.com/)
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..d28ef3dd4
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: 💡Feature Requests
+ url: https://feedback.optimizely.com/
+ about: Feedback requesting a new feature can be shared here.
\ No newline at end of file
diff --git a/.github/issue_template.md b/.github/issue_template.md
deleted file mode 100644
index 01f3e9b9e..000000000
--- a/.github/issue_template.md
+++ /dev/null
@@ -1,39 +0,0 @@
-
-## How would the enhancement work?
-
-## When would the enhancement be useful?
-
-
-
-## What I wanted to do
-
-## What I expected to happen
-
-## What actually happened
-
-## Steps to reproduce
-Link to repository that can reproduce the issue:
-
-
-
-**`@optimizely/optimizely-sdk` version:**
-
-
-
-**Browser and version:**
-
-**`node` version:**
-
-**`npm` version:**
-
-Versions of any other relevant tools (like module bundlers, transpilers, etc.):
-
diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml
index 6b73ba748..70b391e18 100644
--- a/.github/workflows/integration_test.yml
+++ b/.github/workflows/integration_test.yml
@@ -23,15 +23,19 @@ jobs:
path: 'home/runner/travisci-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
+ 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=${{ github.ref_name }}" >> $GITHUB_ENV
- echo "TRAVIS_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
+ echo "SDK_BRANCH=$REF_NAME" >> $GITHUB_ENV
+ echo "TRAVIS_BRANCH=$REF_NAME" >> $GITHUB_ENV
- name: Trigger build
env:
SDK: javascript
diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml
index 18c063677..c097ff585 100644
--- a/.github/workflows/javascript.yml
+++ b/.github/workflows/javascript.yml
@@ -17,11 +17,11 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v3
with:
- node-version: 12
- cache-dependency-path: packages/optimizely-sdk/package-lock.json
+ node-version: 16
+ cache-dependency-path: ./package-lock.json
cache: 'npm'
- name: Run linting
- working-directory: ./packages/optimizely-sdk
+ working-directory: .
run: |
npm install
npm run lint
@@ -40,33 +40,30 @@ jobs:
CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }}
TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }}
- crossbrowser_and_umd_unit_tests:
- runs-on: ubuntu-latest
- env:
- BROWSER_STACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
- BROWSER_STACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
- steps:
- - uses: actions/checkout@v3
- - name: Move to package
- run: |
- cd packages/optimizely-sdk
- - name: Set up Node
- uses: actions/setup-node@v3
- with:
- node-version: 14
- cache: 'npm'
- cache-dependency-path: packages/optimizely-sdk/package-lock.json
- - name: Cross-browser and umd unit tests
- working-directory: ./packages/optimizely-sdk
- run: |
- npm install
- npm run test-ci
+ # crossbrowser_and_umd_unit_tests:
+ # runs-on: ubuntu-latest
+ # env:
+ # BROWSER_STACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
+ # BROWSER_STACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
+ # steps:
+ # - uses: actions/checkout@v3
+ # - name: Set up Node
+ # uses: actions/setup-node@v3
+ # with:
+ # node-version: 16
+ # cache: 'npm'
+ # cache-dependency-path: ./package-lock.json
+ # - name: Cross-browser and umd unit tests
+ # working-directory: .
+ # run: |
+ # npm install
+ # npm run test-ci
unit_tests:
runs-on: ubuntu-latest
strategy:
matrix:
- node: ['14', '16', '18' ]
+ node: ['18', '20', '22', '24']
steps:
- uses: actions/checkout@v3
- name: Set up Node ${{ matrix.node }}
@@ -74,9 +71,9 @@ jobs:
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- cache-dependency-path: packages/optimizely-sdk/package-lock.json
+ cache-dependency-path: ./package-lock.json
- name: Unit tests
- working-directory: ./packages/optimizely-sdk
+ working-directory: .
run: |
npm install
npm run coveralls
@@ -84,11 +81,11 @@ jobs:
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- path-to-lcov: ./packages/optimizely-sdk/coverage/lcov.info
+ path-to-lcov: ./coverage/lcov.info
flag-name: run-${{ matrix.node }}
# This is a parallel build so need this
parallel: true
- base-path: ./packages/optimizely-sdk
+ base-path: .
# As testing against multiple versions need this to
# finish the parallel build
@@ -101,28 +98,7 @@ jobs:
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.github_token }}
- path-to-lcov: ./packages/optimizely-sdk/coverage/lcov.info
+ path-to-lcov: ./coverage/lcov.info
parallel-finished: true
- base-path: ./packages/optimizely-sdk
-
- test_sub_packages:
- runs-on: ubuntu-latest
- strategy:
- matrix:
- package: [ 'packages/utils', 'packages/event-processor', 'packages/logging', 'packages/datafile-manager']
- steps:
- - uses: actions/checkout@v3
- - name: Move to package ${{ matrix.package }}
- run: |
- cd ${{ matrix.package }}
- - name: Set up Node
- uses: actions/setup-node@v3
- with:
- node-version: 12
- cache: 'npm'
- cache-dependency-path: ${{ matrix.package }}/package-lock.json
- - name: Test sub packages
- working-directory: ./${{ matrix.package }}
- run: |
- npm install
- npm test
+ base-path: .
+
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 000000000..f3e710a44
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,81 @@
+name: Publish SDK to NPM
+
+on:
+ release:
+ types: [published, edited]
+ workflow_dispatch: {}
+
+jobs:
+ publish:
+ name: Publish to NPM
+ runs-on: ubuntu-latest
+ if: ${{ github.event_name == 'workflow_dispatch' || !github.event.release.draft }}
+ steps:
+ - name: Checkout branch
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v3
+ with:
+ node-version: 18
+ registry-url: "https://registry.npmjs.org/"
+ always-auth: "true"
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
+
+ - name: Install dependencies
+ run: npm install
+
+ - id: latest-release
+ name: Export latest release git tag
+ run: |
+ echo "latest-release-tag=$(curl -qsSL \
+ -H "Accept: application/vnd.github+json" \
+ -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/latest" \
+ | jq -r .tag_name)" >> $GITHUB_OUTPUT
+
+ - id: npm-tag
+ name: Determine NPM tag
+ env:
+ GITHUB_RELEASE_TAG: ${{ github.event.release.tag_name }}
+ run: |
+ VERSION=$(jq -r '.version' package.json)
+ LATEST_RELEASE_TAG="${{ steps.latest-release.outputs['latest-release-tag']}}"
+
+ if [[ ${{ github.event_name }} == "workflow_dispatch" ]]; then
+ RELEASE_TAG=${GITHUB_REF#refs/tags/}
+ else
+ RELEASE_TAG=$GITHUB_RELEASE_TAG
+ fi
+
+ if [[ $RELEASE_TAG == $LATEST_RELEASE_TAG ]]; then
+ echo "npm-tag=latest" >> "$GITHUB_OUTPUT"
+ elif [[ "$VERSION" == *"-beta"* ]]; then
+ echo "npm-tag=beta" >> "$GITHUB_OUTPUT"
+ elif [[ "$VERSION" == *"-alpha"* ]]; then
+ echo "npm-tag=alpha" >> "$GITHUB_OUTPUT"
+ elif [[ "$VERSION" == *"-rc"* ]]; then
+ echo "npm-tag=rc" >> "$GITHUB_OUTPUT"
+ else
+ echo "npm-tag=v$(echo $VERSION | awk -F. '{print $1}')-latest" >> "$GITHUB_OUTPUT"
+ fi
+
+ - id: release
+ name: Test, build and publish to npm
+ env:
+ BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
+ BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
+ run: |
+ if [[ ${{ github.event_name }} == "workflow_dispatch" ]]; then
+ DRY_RUN="--dry-run"
+ fi
+ npm publish --tag=${{ steps.npm-tag.outputs['npm-tag'] }} $DRY_RUN
+
+ # - name: Report results to Jellyfish
+ # uses: optimizely/jellyfish-deployment-reporter-action@main
+ # if: ${{ always() && github.event_name == 'release' && (steps.release.outcome == 'success' || steps.release.outcome == 'failure') }}
+ # with:
+ # jellyfish_api_token: ${{ secrets.JELLYFISH_API_TOKEN }}
+ # is_successful: ${{ steps.release.outcome == 'success' }}
diff --git a/.gitignore b/.gitignore
index 702b61d6d..4ab687ed5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,5 @@
node_modules/
-/packages/*/optimizely-*.tgz
+optimizely-*.tgz
npm-debug.log
lerna-debug.log
@@ -10,3 +10,8 @@ dist/
# user-specific ignores ought to be defined in user's `core.excludesfile`
.idea/*
.DS_STORE
+
+browserstack.err
+local.log
+
+**/*.gen.ts
diff --git a/.prettierrc b/.prettierrc
index 95d49510c..62301e2e3 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -1,10 +1,10 @@
{
- "printWidth": 89,
+ "printWidth": 120,
"tabWidth": 2,
"useTabs": false,
- "semi": false,
+ "semi": true,
"singleQuote": true,
- "trailingComma": "all",
+ "trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false
}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 000000000..ce072c82c
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "editor.tabSize": 2
+}
diff --git a/packages/optimizely-sdk/CHANGELOG.md b/CHANGELOG.md
similarity index 73%
rename from packages/optimizely-sdk/CHANGELOG.md
rename to CHANGELOG.md
index ea8ba3e82..4c3bcba29 100644
--- a/packages/optimizely-sdk/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,9 +5,315 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [6.2.0] - October 23, 2025
+
+### New Features
+
+- **Added support for Contextual Multi-Armed Bandit (CMAB)**: Added support for CMAB experiments(Contextual Bandits rules) with new configuration options and cache control. To get decision from CMAB rules, `decideAsync` and related methods must be used. The sync `decide` method does not support CMABs and will just skip CMAB rules while making decision for a flag.
+
+ #### CMAB Configuration Options
+
+ The following new options have been added to configure the cmab cache:
+
+ ```js
+ import { createInstance } from '@optimizely/optimizely-sdk'
+
+ const optimizely = createInstance({
+ // ... other config options
+ cmab: {
+ cacheSize: 1000, // Optional: Set CMAB cache size (default: 1000)
+ cacheTtl: 30 * 60 * 1000, // Optional: Set CMAB cache TTL in milliseconds (default: 30 * 60 * 1000)
+ cache: customCache // Optional: Custom cache implementation, instance of CacheWithRemove interface
+ }
+ });
+ ```
+
+ #### CMAB-Related OptimizelyDecideOptions
+
+ New decide options are available to control CMAB caching behavior:
+
+ - `OptimizelyDecideOption.IGNORE_CMAB_CACHE`: Bypass CMAB cache for fresh decisions
+ - `OptimizelyDecideOption.RESET_CMAB_CACHE`: Clear and reset CMAB cache before making decisions
+ - `OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE`: Invalidate CMAB cache for the particular user and experiment
+
+ ```js
+
+ // Example usage with CMAB decide options
+ const decision = await userContext.decideAsync('feature-flag-key', [
+ optimizelySdk.enums.OptimizelyDecideOption.IGNORE_CMAB_CACHE
+ ]);
+ ```
+
+### Bug Fixes
+- Flush events without closing client on page unload which causes event processing to stop working when page is loaded from bfcache ([#1087](https://github.com/optimizely/javascript-sdk/pull/1087))
+- Fixed typo in clientEngine option ([#1095](https://github.com/optimizely/javascript-sdk/pull/1095))
+
+## [5.4.0] - Oct 13, 2025
+
+### New Features
+- Added `customHeaders` option to `datafileOptions` for passing custom HTTP headers in datafile requests ([#1092](https://github.com/optimizely/javascript-sdk/pull/1092))
+### Bug Fixes
+- Fix the EventTags type to allow event properties ([#1040](https://github.com/optimizely/javascript-sdk/pull/1040))
+- Fix typo in event.experimentIds field in project config ([#1088](https://github.com/optimizely/javascript-sdk/pull/1088))
+
+## [6.1.0] - September 8, 2025
+
+### New Features
+
+- Added multi-region support for logx events ([#1072](https://github.com/optimizely/javascript-sdk/pull/1072))
+
+### Performance Improvements
+
+- Improved performance of variations parsing from datafile ([#1080](https://github.com/optimizely/javascript-sdk/pull/1080))
+- General cleanups and improvements in event processing ([#1073](https://github.com/optimizely/javascript-sdk/pull/1073))
+
+## [6.0.0] - May 29, 2025
### Breaking Changes
+
+- Modularized SDK architecture: The monolithic `createInstance` call has been split into multiple factory functions for greater flexibility and control.
+- Core functionalities (project configuration, event processing, ODP, VUID, logging, and error handling) are now configured through dedicated components created via factory functions, giving you greater flexibility and control in enabling/disabling certain components and allowing optimizing the bundle size for frontend projects.
+- `onReady` Promise behavior changed: It now resolves only when the SDK is ready and rejects on initialization errors.
+- event processing is disabled by default and must be explicitly enabled by passing a `eventProcessor` to the client.
+- Event dispatcher interface updated to use Promises instead of callbacks.
+- Logging is disabled by default and must be explicitly enabled using a logger created via a factory function.
+- VUID tracking is disabled by default and must be explicitly enabled by passing a `vuidManager` to the client instance.
+- ODP functionality is no longer enabled by default. You must explicitly pass an `odpManager` to enable it.
+- Dropped support for older browser versions and Node.js versions earlier than 18.0.0.
+
+### New Features
+- Added support for async user profile service and async decide methods (see dcoumentation for [User Profile Service](https://docs.developers.optimizely.com/feature-experimentation/docs/implement-a-user-profile-service-for-the-javascript-sdk) and [Decide methods](https://docs.developers.optimizely.com/feature-experimentation/docs/decide-methods-for-the-javascript-sdk))
+
+### Migration Guide
+
+For detailed migration instructions, refer to the [Migration Guide](MIGRATION.md).
+
+### Documentation
+
+For more details, see the official documentation: [JavaScript SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/javascript-sdk).
+
+## [5.3.5] - Jan 29, 2025
+
+### Bug Fixes
+
+- Rollout experiment key exclusion from activate method([#949](https://github.com/optimizely/javascript-sdk/pull/949))
+- Using optimizely.readyPromise instead of optimizely.onReady to avoid setTimeout call in edge environments. ([#995](https://github.com/optimizely/javascript-sdk/pull/995))
+
+## [4.10.1] - November 18, 2024
+
+### Changed
+- update uuid module improt and usage ([#961](https://github.com/optimizely/javascript-sdk/pull/961))
+
+
+## [5.3.4] - Jun 28, 2024
+
+### Changed
+
+- crypto and text encoder polyfill addition for React native ([#936](https://github.com/optimizely/javascript-sdk/pull/936))
+
+## [5.3.3] - Jun 06, 2024
+
+### Changed
+
+- queueMicroTask fallback addition for embedded environments / unsupported platforms ([#933](https://github.com/optimizely/javascript-sdk/pull/933))
+
+## [5.3.2] - May 20, 2024
+
+### Changed
+
+- Added public facing API for ODP integration information ([#930](https://github.com/optimizely/javascript-sdk/pull/930))
+
+
+## [5.3.1] - May 20, 2024
+
+### Changed
+- Fix Memory Leak: Closed http request after getting response to release memory immediately (node) ([#927](https://github.com/optimizely/javascript-sdk/pull/927))
+
+## [5.3.1-rc.1] - May 13, 2024
+
+### Changed
+- Fix Memory Leak: Closed http request after getting response to release memory immediately (node) ([#927](https://github.com/optimizely/javascript-sdk/pull/927))
+
+## [5.3.0] - April 8, 2024
+
+### Changed
+- Refactor: ODP corrections [#920](https://github.com/optimizely/javascript-sdk/pull/920) including
+ - ODPManager should not be running and scheduling timer if ODP is not integrated to the project (which causes memory leak if one sdk instance is created per request)
+ - CreateUserContext should work even when called before the datafile is downloaded and should send the `identify` ODP events after datafile download completes
+ - Other automatic odp events (vuid registration, client initialized) should also be sent after datafile is available and should not be dropped if batching is disabled.
+ - [see PR for more]
+
+
+## [5.2.1] - March 25, 2024
+
+### Bug fixes
+- Fix: empty segments collection is valid ([#916](https://github.com/optimizely/javascript-sdk/pull/916))
+- Update vulnerable dependencies ([#918](https://github.com/optimizely/javascript-sdk/pull/918))
+
+## [5.2.0] - March 18, 2024
+
+### New Features
+- Add `persistentCacheProvider` option to `createInstance` to allow providing custom persistent cache implementation in react native ([#914](https://github.com/optimizely/javascript-sdk/pull/914))
+
+## [5.1.0] - March 1, 2024
+
+### New Features
+- Add explicit entry points for node, browser and react_native, allowing imports like `import optimizelySdk from '@optimizely/optimizely-sdk/node'`, `import optimizelySdk from '@optimizely/optimizely-sdk/browser'`, `import optimizelySdk from '@optimizely/optimizely-sdk/react_native'` ([#905](https://github.com/optimizely/javascript-sdk/pull/905))
+
+### Changed
+- Log an error in DatafileManager when datafile fetch fails ([#904](https://github.com/optimizely/javascript-sdk/pull/904))
+
+## [5.0.1] - February 20, 2024
+
+### Bug fixes
+- Improved conditional ODP instantiation when `odpOptions.disabled: true` is used ([#902](https://github.com/optimizely/javascript-sdk/pull/902))
+
+### Changed
+- Updated Dependabot alerts ([#896](https://github.com/optimizely/javascript-sdk/pull/896))
+- Updated several devDependencies ([#898](https://github.com/optimizely/javascript-sdk/pull/898), [#900](https://github.com/optimizely/javascript-sdk/pull/900), [#901](https://github.com/optimizely/javascript-sdk/pull/901))
+
+
+## [5.0.0] - January 19, 2024
+
+### New Features
+
+The 5.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) ([#765](https://github.com/optimizely/javascript-sdk/pull/765), [#775](https://github.com/optimizely/javascript-sdk/pull/775), [#776](https://github.com/optimizely/javascript-sdk/pull/776), [#777](https://github.com/optimizely/javascript-sdk/pull/777), [#778](https://github.com/optimizely/javascript-sdk/pull/778), [#786](https://github.com/optimizely/javascript-sdk/pull/786), [#789](https://github.com/optimizely/javascript-sdk/pull/789), [#790](https://github.com/optimizely/javascript-sdk/pull/790), [#797](https://github.com/optimizely/javascript-sdk/pull/797), [#799](https://github.com/optimizely/javascript-sdk/pull/799), [#808](https://github.com/optimizely/javascript-sdk/pull/808)).
+
+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 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.
+
+ - `createUserContext()` with anonymous user IDs: user-contexts can be created without a userId. The SDK will create and use a persistent `VUID` specific to a device when userId is not provided.
+
+For details, refer to our documentation pages:
+
+- [Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting)
+
+- [Client SDK Support](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/advanced-audience-targeting-for-client-side-sdks)
+
+- [Initialize JavaScript SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-javascript-aat)
+
+- [OptimizelyUserContext JavaScript SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizelyusercontext-javascript-aat)
+
+- [Advanced Audience Targeting segment qualification methods](https://docs.developers.optimizely.com/feature-experimentation/docs/advanced-audience-targeting-segment-qualification-methods-javascript)
+
+- [Send Optimizely Data Platform data using Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/send-odp-data-using-advanced-audience-targeting-javascript)
+
+Additionally, a handful of major package updates are also included in this release including `murmurhash`, `uuid`, and others. For more information, check out the **Breaking Changes** section below. ([#892](https://github.com/optimizely/javascript-sdk/pull/892), [#762](https://github.com/optimizely/javascript-sdk/pull/762))
+
+### 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.
+- Updated `murmurhash` dependency to version `2.0.1`.
+- Updated `uuid` dependency to version `9.0.1`.
+- Dropped support for the following browser versions.
+ - All versions of Microsof Internet Explorer.
+ - Chrome versions earlier than `102.0`.
+ - Microsoft Edge versions earlier than `84.0`.
+ - Firefox versions earlier than `91.0`.
+ - Opera versions earlier than `76.0`.
+ - Safari versions earlier than `13.0`.
+- Dropped support for Node JS versions earlier than `16`.
+
+## Changed
+- Updated `createUserContext`'s `userId` parameter to be optional due to the Browser variation's use of the new `vuid` field. Note: The Node variation of the SDK does **not** use the new `vuid` field and you should pass in a `userId` when within the context of the Node variant.
+
+
+## [4.10.0] - October 11, 2023
+
+### New Features
+- Add support for configurable closing event dispatcher, and dispatching events using sendBeacon in the browser on instance close ([#876](https://github.com/optimizely/javascript-sdk/pull/876), [#874](https://github.com/optimizely/javascript-sdk/pull/874), [#873](https://github.com/optimizely/javascript-sdk/pull/873))
+
+## [5.0.0-beta5] - September 1, 2023
+
+### Changed
+- Exported logging related types and values from the package entrypoint ([#858](https://github.com/optimizely/javascript-sdk/pull/858))
+- Removed /lib directory from the published pacakage ([#862](https://github.com/optimizely/javascript-sdk/pull/862))
+
+## [5.0.0-beta4] - August 22, 2023
+
+### New Features
+- Added support for configurable user agent parser for ODP ([#854](https://github.com/optimizely/javascript-sdk/pull/854))
+
+### Bug fixes
+- Fixed typescript compilation failure due to missing types ([#856](https://github.com/optimizely/javascript-sdk/pull/856))
+
+## [5.0.0-beta3] - August 18, 2023
+
+### Bug fixes
+- Fixed odp event sending not working for Europe and Asia-Pacific regions ([#852](https://github.com/optimizely/javascript-sdk/pull/852))
+
+### Changed
+- Remove 1 second polling floor to allow datafile polling at any frequency but for intervals under 30 seconds, log a warning ([#841](https://github.com/optimizely/javascript-sdk/pull/841)).
+
+## [5.0.0-beta2] - July 19, 2023
+
+### Performance Improvements
+- Improved OptimizelyConfig class instantiation performance from O(n^2) to O(n) where n = number of feature flags ([#828](https://github.com/optimizely/javascript-sdk/pull/828))
+
+### Bug fixes
+
+- Fixed ODP config update issue on datafile update ([#830](https://github.com/optimizely/javascript-sdk/pull/830))
+
+## [4.9.4] - June 8, 2023
+
+### Performance Improvements
+- Improve OptimizelyConfig class instantiation performance from O(n^2) to O(n) where n = number of feature flags ([#829](https://github.com/optimizely/javascript-sdk/pull/829))
+
+## 5.0.0-beta
+May 4, 2023
+
+### New Features
+
+The 5.0.0-beta 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) ([#765](https://github.com/optimizely/javascript-sdk/pull/765), [#775](https://github.com/optimizely/javascript-sdk/pull/775), [#776](https://github.com/optimizely/javascript-sdk/pull/776), [#777](https://github.com/optimizely/javascript-sdk/pull/777), [#778](https://github.com/optimizely/javascript-sdk/pull/778), [#786](https://github.com/optimizely/javascript-sdk/pull/786), [#789](https://github.com/optimizely/javascript-sdk/pull/789), [#790](https://github.com/optimizely/javascript-sdk/pull/790), [#797](https://github.com/optimizely/javascript-sdk/pull/797), [#799](https://github.com/optimizely/javascript-sdk/pull/799), [#808](https://github.com/optimizely/javascript-sdk/pull/808)).
+
+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 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.
+
+ - `createUserContext()` with anonymous user IDs: user-contexts can be created without a userId. The SDK will create and use a persistent `VUID` specific to a device when userId is not provided.
+
+For details, refer to our documentation pages:
+
+- [Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting)
+
+- [Client SDK Support](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/advanced-audience-targeting-for-client-side-sdks)
+
+- [Initialize JavaScript SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-javascript-aat)
+
+- [OptimizelyUserContext JavaScript SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizelyusercontext-javascript-aat)
+
+- [Advanced Audience Targeting segment qualification methods](https://docs.developers.optimizely.com/feature-experimentation/docs/advanced-audience-targeting-segment-qualification-methods-javascript)
+
+- [Send Optimizely Data Platform data using Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/send-odp-data-using-advanced-audience-targeting-javascript)
+
+Additionally, a handful of major package updates are also included in this release including `murmurhash`, `uuid`, and others. For more information, check out the **Breaking Changes** section below. ([#762](https://github.com/optimizely/javascript-sdk/pull/762))
+
+### 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.
- Updated `murmurhash` dependency to version `2.0.1`.
- Updated `uuid` dependency to version `8.3.2`.
- Dropped support for the following browser versions.
@@ -19,6 +325,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Safari versions earlier than `13.0`.
- Dropped support for Node JS versions earlier than `14`.
+## Changed
+- Updated `createUserContext`'s `userId` parameter to be optional due to the Browser variation's use of the new `vuid` field. Note: The Node variation of the SDK does **not** use the new `vuid` field and you should pass in a `userId` when within the context of the Node variant.
+
+## [4.9.3] - March 17, 2023
+
+### Changed
+- Updated README.md and package.json files to reflect that this SDK supports both Optimizely Feature Experimentation and Optimizely Full Stack ([#803](https://github.com/optimizely/javascript-sdk/pull/803)).
+
## [4.9.2] - June 27, 2022
### Changed
diff --git a/LICENSE b/LICENSE
index b9f80c5bd..e2d144779 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-2017, Optimizely, Inc. and contributors
+ © Optimizely 2016
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/MIGRATION.md b/MIGRATION.md
new file mode 100644
index 000000000..8a2173ebf
--- /dev/null
+++ b/MIGRATION.md
@@ -0,0 +1,464 @@
+# Migrating v5 to v6
+
+This guide will help you migrate your implementation from Optimizely JavaScript SDK v5 to v6. The new version introduces several architectural changes that provide more flexibility and control over SDK components.
+
+## Table of Contents
+
+1. [Major Changes](#major-changes)
+2. [Client Initialization](#client-initialization)
+3. [Project Configuration Management](#project-configuration-management)
+4. [Event Processing](#event-processing)
+5. [ODP Management](#odp-management)
+6. [VUID Management](#vuid-management)
+7. [Error Handling](#error-handling)
+8. [Logging](#logging)
+9. [onReady Promise Behavior](#onready-promise-behavior)
+10. [Dispose of Client](#dispose-of-client)
+11. [Migration Examples](#migration-examples)
+
+## Major Changes
+
+In v6, the SDK architecture has been modularized to give you more control over different components:
+
+- The monolithic `createInstance` call is now split into multiple factory functions
+- Core functionalities (project configuration, event processing, ODP, VUID, logging, and error handling) are now configured through dedicated components created via factory functions, giving you greater flexibility and control in enabling/disabling certain components and allowing optimizing the bundle size for frontend projects.
+- Event dispatcher interface has been updated to use Promises
+- onReady Promise behavior has changed
+
+## Client Initialization
+
+### v5 (Before)
+
+```javascript
+import { createInstance } from '@optimizely/optimizely-sdk';
+
+const optimizely = createInstance({
+ sdkKey: '',
+ datafile: datafile, // optional
+ datafileOptions: {
+ autoUpdate: true,
+ updateInterval: 300000, // 5 minutes
+ },
+ eventBatchSize: 10,
+ eventFlushInterval: 1000,
+ logLevel: LogLevel.DEBUG,
+ errorHandler: { handleError: (error) => console.error(error) },
+ odpOptions: {
+ disabled: false,
+ segmentsCacheSize: 100,
+ segmentsCacheTimeout: 600000, // 10 minutes
+ }
+});
+```
+
+### v6 (After)
+
+```javascript
+import {
+ createInstance,
+ createPollingProjectConfigManager,
+ createBatchEventProcessor,
+ createOdpManager,
+ createVuidManager,
+ createLogger,
+ createErrorNotifier,
+ DEBUG
+} from "@optimizely/optimizely-sdk";
+
+// Create a project config manager
+const projectConfigManager = createPollingProjectConfigManager({
+ sdkKey: '',
+ datafile: datafile, // optional
+ autoUpdate: true,
+ updateInterval: 300000, // 5 minutes in milliseconds
+});
+
+// Create an event processor
+const eventProcessor = createBatchEventProcessor({
+ batchSize: 10,
+ flushInterval: 1000,
+});
+
+// Create an ODP manager
+const odpManager = createOdpManager({
+ segmentsCacheSize: 100,
+ segmentsCacheTimeout: 600000, // 10 minutes
+});
+
+// Create a VUID manager (optional)
+const vuidManager = createVuidManager({
+ enableVuid: true
+});
+
+// Create a logger
+const logger = createLogger({
+ level: DEBUG
+});
+
+// Create an error notifier
+const errorNotifier = createErrorNotifier({
+ handleError: (error) => console.error(error)
+});
+
+// Create the Optimizely client instance
+const optimizely = createInstance({
+ projectConfigManager,
+ eventProcessor,
+ odpManager,
+ vuidManager,
+ logger,
+ errorNotifier
+});
+```
+
+In case an invalid config is passed to `createInstance`, it returned `null` in v5. In v6, it will throw an error instead of returning null.
+
+## Project Configuration Management
+
+In v6, datafile management must be configured by passing in a `projectConfigManager`. Choose either:
+
+### Polling Project Config Manager
+
+For automatic datafile updates:
+
+```javascript
+const projectConfigManager = createPollingProjectConfigManager({
+ sdkKey: '',
+ datafile: datafileString, // optional
+ autoUpdate: true,
+ updateInterval: 60000, // 1 minute
+ urlTemplate: 'https://custom-cdn.com/datafiles/%s.json' // optional
+});
+```
+
+### Static Project Config Manager
+
+When you want to manage datafile updates manually or want to use a fixed datafile:
+
+```javascript
+const projectConfigManager = createStaticProjectConfigManager({
+ datafile: datafileString,
+});
+```
+
+## Event Processing
+
+In v5, a batch event processor was enabled by default. In v6, an event processor must be instantiated and passed in
+explicitly to `createInstance` via the `eventProcessor` option to enable event processing, otherwise no events will
+be dispatched. v6 provides two types of event processors:
+
+### Batch Event Processor
+
+Queues events and sends them in batches:
+
+```javascript
+const batchEventProcessor = createBatchEventProcessor({
+ batchSize: 10, // optional, default is 10
+ flushInterval: 1000, // optional, default 1000 for browser
+});
+```
+
+### Forwarding Event Processor
+
+Sends events immediately:
+
+```javascript
+const forwardingEventProcessor = createForwardingEventProcessor();
+```
+
+### Custom event dispatcher
+In both v5 and v6, custom event dispatchers must implement the `EventDispatcher` interface. In v6, the `EventDispatcher` interface has been updated so that the `dispatchEvent` method returns a Promise instead of calling a callback.
+
+In v5 (Before):
+
+```javascript
+export type EventDispatcherResponse = {
+ statusCode: number
+}
+
+export type EventDispatcherCallback = (response: EventDispatcherResponse) => void
+
+export interface EventDispatcher {
+ dispatchEvent(event: EventV1Request, callback: EventDispatcherCallback): void
+}
+```
+
+In v6(After):
+
+```javascript
+export type EventDispatcherResponse = {
+ statusCode?: number
+}
+
+export interface EventDispatcher {
+ dispatchEvent(event: LogEvent): Promise
+}
+```
+
+## ODP Management
+
+In v5, ODP functionality was configured via `odpOptions` and enabled by default. In v6, instantiate an OdpManager and pass to `createInstance` to enable ODP:
+
+### v5 (Before)
+
+```javascript
+const optimizely = createInstance({
+ sdkKey: '',
+ odpOptions: {
+ disabled: false,
+ segmentsCacheSize: 100,
+ segmentsCacheTimeout: 600000, // 10 minutes
+ eventApiTimeout: 1000,
+ segmentsApiTimeout: 1000,
+ }
+});
+```
+
+### v6 (After)
+
+```javascript
+const odpManager = createOdpManager({
+ segmentsCacheSize: 100,
+ segmentsCacheTimeout: 600000, // 10 minutes
+ eventApiTimeout: 1000,
+ segmentsApiTimeout: 1000,
+ eventBatchSize: 5, // Now configurable in browser
+ eventFlushInterval: 3000, // Now configurable in browser
+});
+
+const optimizely = createInstance({
+ projectConfigManager,
+ odpManager
+});
+```
+
+To disable ODP functionality in v6, simply don't provide an ODP Manager to the client instance.
+
+## VUID Management
+
+In v6, VUID tracking is disabled by default and must be explicitly enabled by createing a vuidManager with `enableVuid` set to `true` and passing it to `createInstance`:
+
+```javascript
+const vuidManager = createVuidManager({
+ enableVuid: true, // Explicitly enable VUID tracking
+});
+
+const optimizely = createInstance({
+ projectConfigManager,
+ vuidManager
+});
+```
+
+## Error Handling
+
+Error handling in v6 uses a new errorNotifier object:
+
+### v5 (Before)
+
+```javascript
+const optimizely = createInstance({
+ errorHandler: {
+ handleError: (error) => {
+ console.error("Custom error handler", error);
+ }
+ }
+});
+```
+
+### v6 (After)
+
+```javascript
+const errorNotifier = createErrorNotifier({
+ handleError: (error) => {
+ console.error("Custom error handler", error);
+ }
+});
+
+const optimizely = createInstance({
+ projectConfigManager,
+ errorNotifier
+});
+```
+
+## Logging
+
+Logging in v6 is disabled by defualt, and must be enabled by passing in a logger created via a factory function:
+
+### v5 (Before)
+
+```javascript
+const optimizely = createInstance({
+ logLevel: LogLevel.DEBUG
+});
+```
+
+### v6 (After)
+
+```javascript
+import { createLogger, DEBUG } from "@optimizely/optimizely-sdk";
+
+const logger = createLogger({
+ level: DEBUG
+});
+
+const optimizely = createInstance({
+ projectConfigManager,
+ logger
+});
+```
+
+## onReady Promise Behavior
+
+The `onReady()` method behavior has changed in v6. In v5, onReady() fulfilled with an object that had two fields: `success` and `reason`. If the instance failed to initialize, `success` would be `false` and `reason` will contain an error message. In v6, if onReady() fulfills, that means the instance is ready to use, the fulfillment value is of unknown type and need not to be inspected. If the promise rejects, that means there was an error during initialization.
+
+### v5 (Before)
+
+```javascript
+optimizely.onReady().then(({ success, reason }) => {
+ if (success) {
+ // optimizely is ready to use
+ } else {
+ console.log(`initialization unsuccessful: ${reason}`);
+ }
+});
+```
+
+### v6 (After)
+
+```javascript
+optimizely
+ .onReady()
+ .then(() => {
+ // optimizely is ready to use
+ console.log("Client is ready");
+ })
+ .catch((err) => {
+ console.error("Error initializing Optimizely client:", err);
+ });
+```
+
+## Migration Examples
+
+### Basic Example with SDK Key
+
+#### v5 (Before)
+
+```javascript
+import { createInstance } from '@optimizely/optimizely-sdk';
+
+const optimizely = createInstance({
+ sdkKey: ''
+});
+
+optimizely.onReady().then(({ success }) => {
+ if (success) {
+ // Use the client
+ }
+});
+```
+
+#### v6 (After)
+
+```javascript
+import {
+ createInstance,
+ createPollingProjectConfigManager
+} from '@optimizely/optimizely-sdk';
+
+const projectConfigManager = createPollingProjectConfigManager({
+ sdkKey: ''
+});
+
+const optimizely = createInstance({
+ projectConfigManager
+});
+
+optimizely
+ .onReady()
+ .then(() => {
+ // Use the client
+ })
+ .catch(err => {
+ console.error(err);
+ });
+```
+
+### Complete Example with ODP and Event Batching
+
+#### v5 (Before)
+
+```javascript
+import { createInstance, LogLevel } from '@optimizely/optimizely-sdk';
+
+const optimizely = createInstance({
+ sdkKey: '',
+ datafileOptions: {
+ autoUpdate: true,
+ updateInterval: 60000 // 1 minute
+ },
+ eventBatchSize: 3,
+ eventFlushInterval: 10000, // 10 seconds
+ logLevel: LogLevel.DEBUG,
+ odpOptions: {
+ segmentsCacheSize: 10,
+ segmentsCacheTimeout: 60000 // 1 minute
+ }
+});
+
+optimizely.notificationCenter.addNotificationListener(
+ enums.NOTIFICATION_TYPES.TRACK,
+ (payload) => {
+ console.log("Track event", payload);
+ }
+);
+```
+
+#### v6 (After)
+
+```javascript
+import {
+ createInstance,
+ createPollingProjectConfigManager,
+ createBatchEventProcessor,
+ createOdpManager,
+ createLogger,
+ DEBUG,
+ NOTIFICATION_TYPES
+} from '@optimizely/optimizely-sdk';
+
+const projectConfigManager = createPollingProjectConfigManager({
+ sdkKey: '',
+ autoUpdate: true,
+ updateInterval: 60000 // 1 minute
+});
+
+const batchEventProcessor = createBatchEventProcessor({
+ batchSize: 3,
+ flushInterval: 10000, // 10 seconds
+});
+
+const odpManager = createOdpManager({
+ segmentsCacheSize: 10,
+ segmentsCacheTimeout: 60000 // 1 minute
+});
+
+const logger = createLogger({
+ level: DEBUG
+});
+
+const optimizely = createInstance({
+ projectConfigManager,
+ eventProcessor: batchEventProcessor,
+ odpManager,
+ logger
+});
+
+optimizely.notificationCenter.addNotificationListener(
+ NOTIFICATION_TYPES.TRACK,
+ (payload) => {
+ console.log("Track event", payload);
+ }
+);
+```
+
+For complete implementation examples, refer to the [Optimizely JavaScript SDK documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/javascript-browser-sdk-v6).
diff --git a/README.md b/README.md
index bc81907db..08ab9f5ad 100644
--- a/README.md
+++ b/README.md
@@ -1,66 +1,280 @@
-
- Optimizely JavaScript SDK
-
+# Optimizely JavaScript SDK
-
- This repository houses the official JavaScript SDK for use with Optimizely Full Stack and Optimizely Rollouts.
-
+[](https://www.npmjs.com/package/@optimizely/optimizely-sdk)
+[](https://www.npmjs.com/package/@optimizely/optimizely-sdk)
+[](https://github.com/optimizely/javascript-sdk/actions)
+[](https://coveralls.io/github/optimizely/javascript-sdk)
+[](https://choosealicense.com/licenses/apache-2.0/)
-Optimizely Full Stack is A/B testing and feature flag management for product development teams. Experiment in any application. Make every feature on your roadmap an opportunity to learn. Learn more at https://www.optimizely.com/platform/full-stack/, or see the [documentation](https://docs.developers.optimizely.com/full-stack/docs).
+This is the official JavaScript and TypeScript SDK for use with Optimizely Feature Experimentation and Optimizely Full Stack (legacy). The SDK now features a modular architecture for greater flexibility and control. If you're upgrading from a previous version, see our [Migration Guide](MIGRATION.md).
-Optimizely Rollouts is free feature flags for development teams. Easily roll out and roll back features in any application without code deploys. Mitigate risk for every feature on your roadmap. Learn more at https://www.optimizely.com/rollouts/, or see the [documentation](https://docs.developers.optimizely.com/rollouts/docs).
+Optimizely Feature Experimentation is an A/B testing and feature management tool for product development teams that enables you to experiment at every step. Using Optimizely Feature Experimentation allows for every feature on your roadmap to be an opportunity to discover hidden insights. Learn more at [Optimizely.com](https://www.optimizely.com/products/experiment/feature-experimentation/), or see the [developer documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/introduction).
+Optimizely Rollouts is [free feature flags](https://www.optimizely.com/free-feature-flagging/) for development teams. You can easily roll out and roll back features in any application without code deploys, mitigating risk for every feature on your roadmap.
-## Packages
+---
-This repository is a monorepo. It houses the main Javascript SDK and its supporting packages.
+## Get Started
-| Package | Version | Docs | Description |
-| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
-| [`@optimizely/optimizely-sdk`](/packages/optimizely-sdk) | [](https://www.npmjs.com/package/@optimizely/optimizely-sdk) | [](https://docs.developers.optimizely.com/full-stack/docs/javascript-node-sdk) | The Optimizely SDK |
-| [`@optimizely/js-sdk-datafile-manager`](/packages/datafile-manager) | [](https://www.npmjs.com/package/@optimizely/js-sdk-datafile-manager) | [](https://docs.developers.optimizely.com/full-stack/docs/initialize-sdk-javascript-node#customize-datafile-management-behavior) | Datafile Manager for Optimizely SDK
-| [`@optimizely/js-sdk-event-processor`](/packages/event-processor) | [](https://www.npmjs.com/package/@optimizely/js-sdk-event-processor) | [](https://docs.developers.optimizely.com/full-stack/docs/event-batching-javascript-node) | Event Batching support for Optimizely SDK
-| [`@optimizely/js-sdk-logging`](/packages/logging) | [](https://www.npmjs.com/package/@optimizely/js-sdk-logging) | [](https://docs.developers.optimizely.com/full-stack/docs/customize-logger-javascript-node) | Logging Manager for Optimizely SDK
-| [`@optimizely/js-sdk-utils`](/packages/utils) | [](https://www.npmjs.com/package/@optimizely/js-sdk-utils) | | Utility functions for Optimizely packages
+> Refer to the [JavaScript SDK's developer documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/javascript-sdk) for detailed instructions on getting started with using the SDK.
-## About
-`@optimizely/optimizely-sdk` is developed and maintained by [Optimizely](https://optimizely.com) and many [contributors](https://github.com/optimizely/javascript-sdk/graphs/contributors). If you're interested in learning more about what Optimizely X Full Stack can do for your company, please [get in touch](mailto:eng@optimizely.com)!
+> For **Edge Functions**, we provide starter kits that utilize the Optimizely JavaScript SDK for the following platforms:
+>
+> - [Akamai (Edgeworkers)](https://github.com/optimizely/akamai-edgeworker-starter-kit)
+> - [AWS Lambda@Edge](https://github.com/optimizely/aws-lambda-at-edge-starter-kit)
+> - [Cloudflare Worker](https://github.com/optimizely/cloudflare-worker-template)
+> - [Fastly Compute@Edge](https://github.com/optimizely/fastly-compute-starter-kit)
+> - [Vercel Edge Middleware](https://github.com/optimizely/vercel-examples/tree/main/edge-middleware/feature-flag-optimizely)
+>
+> Note: We recommend using the **Lite** entrypoint (for version < 6) / **Universal** entrypoint (for version >=6) of the sdk for edge platforms. These starter kits also use the **Lite** variant of the JavaScript SDK.
+### Prerequisites
+
+Ensure the SDK supports all of the platforms you're targeting. In particular, the SDK targets modern ES6-compliant JavaScript environments. We officially support:
+
+- Node.js >= 18.0.0. By extension, environments like AWS Lambda, Google Cloud Functions, and Auth0 Webtasks are supported as well. Older Node.js releases likely work too (try `npm test` to validate for yourself), but are not formally supported.
+- Modern Web Browsers, such as Microsoft Edge 84+, Firefox 91+, Safari 13+, and Chrome 102+, Opera 76+
+
+In addition, other environments are likely compatible but are not formally supported including:
+
+- Progressive Web Apps, WebViews, and hybrid mobile apps like those built with React Native and Apache Cordova.
+- [Cloudflare Workers](https://developers.cloudflare.com/workers/) and [Fly](https://fly.io/), both of which are powered by recent releases of V8.
+- Anywhere else you can think of that might embed a JavaScript engine. The sky is the limit; experiment everywhere! 🚀
+
+### Install the SDK
+
+Once you've validated that the SDK supports the platforms you're targeting, fetch the package from [NPM](https://www.npmjs.com/package/@optimizely/optimizely-sdk):
+
+Using `npm`:
+
+```sh
+npm install --save @optimizely/optimizely-sdk
+```
+
+Using `yarn`:
+
+```sh
+yarn add @optimizely/optimizely-sdk
+```
+
+Using `pnpm`:
+
+```sh
+pnpm add @optimizely/optimizely-sdk
+```
+
+Using `deno` (no installation required):
+
+```javascript
+import optimizely from 'npm:@optimizely/optimizely-sdk';
+```
+
+## Use the JavaScript SDK
+
+See the [JavaScript SDK's developer documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/javascript-sdk) to learn how to set up your first JavaScript project using the SDK.
+
+The SDK uses a modular architecture with dedicated components for project configuration, event processing, and more. The examples below demonstrate the recommended initialization pattern.
+
+### Initialization with Package Managers (npm, yarn, pnpm)
+
+```javascript
+import {
+ createInstance,
+ createPollingProjectConfigManager,
+ createBatchEventProcessor,
+ createOdpManager,
+} from '@optimizely/optimizely-sdk';
+
+// 1. Configure your project config manager
+const pollingConfigManager = createPollingProjectConfigManager({
+ sdkKey: '',
+ autoUpdate: true, // Optional: enable automatic updates
+ updateInterval: 300000, // Optional: update every 5 minutes (in ms)
+});
+
+// 2. Create an event processor for analytics
+const batchEventProcessor = createBatchEventProcessor({
+ batchSize: 10, // Optional: default batch size
+ flushInterval: 1000, // Optional: flush interval in ms
+});
+
+// 3. Set up ODP manager for segments and audience targeting
+const odpManager = createOdpManager();
+
+// 4. Initialize the Optimizely client with the components
+const optimizelyClient = createInstance({
+ projectConfigManager: pollingConfigManager,
+ eventProcessor: batchEventProcessor,
+ odpManager: odpManager,
+});
+
+optimizelyClient
+ .onReady()
+ .then(() => {
+ console.log('Optimizely client is ready');
+ // Your application code using Optimizely goes here
+ })
+ .catch(error => {
+ console.error('Error initializing Optimizely client:', error);
+ });
+```
+
+### Initialization (Using HTML script tag)
+
+The package has different entry points for different environments. The browser entry point is an ES module, which can be used with an appropriate bundler like **Webpack** or **Rollup**. Additionally, for ease of use during initial evaluations you can include a standalone umd bundle of the SDK in your web page by fetching it from [unpkg](https://unpkg.com/):
+
+```html
+
+
+
+
+```
+
+> ⚠️ **Warning**: Always include a specific version number (such as @6) when using CDN URLs like the `unpkg` example above. If you use a URL without a version, your application may automatically receive breaking changes when a new major version is released, which can lead to unexpected issues.
+
+When evaluated, that bundle assigns the SDK's exports to `window.optimizelySdk`. If you wish to use the asset locally (for example, if unpkg is down), you can find it in your local copy of the package at dist/optimizely.browser.umd.min.js. We do not recommend using this method in production settings as it introduces a third-party performance dependency.
+
+As `window.optimizelySdk` should be a global variable at this point, you can continue to use it like so:
+
+```html
+
+```
+
+### Closing the SDK Instance
+
+Depending on the sdk configuration, the client instance might schedule tasks in the background. If the instance has background tasks scheduled,
+then the instance will not be garbage collected even though there are no more references to the instance in the code. (Basically, the background tasks will still hold references to the instance). Therefore, it's important to close it to properly clean up resources.
+
+```javascript
+// Close the Optimizely client when you're done using it
+optimizelyClient.close()
+```
+Using the following settings will cause background tasks to be scheduled
+
+- Polling Datafile Manager
+- Batch Event Processor with batchSize > 1
+- ODP manager with eventBatchSize > 1
+
+
+
+> ⚠️ **Warning**: Failure to close SDK instances when they're no longer needed may result in memory leaks. This is particularly important for applications that create multiple instances over time. For some environment like SSR applications, it might not be convenient to close each instance, in which case, the `disposable` option of `createInstance` can be used to disable all background tasks on the server side, allowing the instance to be garbage collected.
+
+
+## Special Notes
+
+### Migration Guides
+
+If you're updating your SDK version, please check the appropriate migration guide:
+
+- **Migrating from 5.x or lower to 6.x**: See our [Migration Guide](MIGRATION.md) for detailed instructions on updating to the new modular architecture.
+- **Migrating from 4.x or lower to 5.x**: Please refer to the [Changelog](CHANGELOG.md#500---january-19-2024) for details on these breaking changes.
+
+## SDK Development
+
+### Unit Tests
+
+There is a mix of testing paradigms used within the JavaScript SDK which include Mocha, Chai, Karma, and Vitest, indicated by their respective `*.tests.js` and `*.spec.ts` filenames.
+
+When contributing code to the SDK, aim to keep the percentage of code test coverage at the current level ([](https://coveralls.io/github/optimizely/javascript-sdk)) or above.
+
+To run unit tests, you can take the following steps:
+
+1. Ensure that you have run `npm install` to install all project dependencies.
+2. Run `npm test` to run all test files.
+3. Run `npm run test-vitest` to run only tests written using Vitest.
+4. Run `npm run test-mocha` to run only tests written using Mocha.
+4. (For cross-browser testing) Run `npm run test-xbrowser` to run tests in many browsers via BrowserStack.
+5. Resolve any tests that fail before continuing with your contribution.
+
+This information is relevant only if you plan on contributing to the SDK itself.
+
+```sh
+# Prerequisite: Install dependencies.
+npm install
+
+# Run unit tests.
+npm test
+
+# Run unit tests in many browsers, currently via BrowserStack.
+# For this to work, the following environment variables must be set:
+# - BROWSER_STACK_USERNAME
+# - BROWSER_STACK_PASSWORD
+npm run test-xbrowser
+```
+
+[/.github/workflows/javascript.yml](/.github/workflows/javascript.yml) contains the definitions for `BROWSER_STACK_USERNAME` and `BROWSER_STACK_ACCESS_KEY` used in the GitHub Actions CI pipeline. When developing locally, you must provide your own credentials in order to run `npm run test-xbrowser`. You can register for an account for free on [the BrowserStack official website here](https://www.browserstack.com/).
### Contributing
-Please see [CONTRIBUTING](CONTRIBUTING.md).
+For more information regarding contributing to the Optimizely JavaScript SDK, please read [Contributing](CONTRIBUTING.md).
+
+
+### Feature Management access
+
+To access the Feature Management configuration in the Optimizely dashboard, please contact your Optimizely customer success manager.
## Credits
-First-party code (under `packages/optimizely-sdk/lib/`, `packages/datafile-manager/lib`, `packages/datafile-manager/src`, `packages/datafile-manager/__test__`, `packages/event-processor/src`, `packages/event-processor/__tests__`, `packages/logging/src`, `packages/logging/__tests__`, `packages/utils/src`, `packages/utils/__tests__`) is copyright Optimizely, Inc. and contributors, licensed under Apache 2.0.
-
-## Additional Code
-
-Prod dependencies are as follows:
-
-```json
-{
- "json-schema@0.4.0": {
- "licenses": [
- "AFLv2.1",
- "BSD"
- ],
- "publisher": "Kris Zyp",
- "repository": "https://github.com/kriszyp/json-schema"
- },
- "murmurhash@0.0.2": {
- "licenses": "MIT*",
- "repository": "https://github.com/perezd/node-murmurhash"
- },
- "uuid@3.3.2": {
- "licenses": "MIT",
- "repository": "https://github.com/kelektiv/node-uuid"
- },
- "decompress-response@4.2.1": {
- "licenses": "MIT",
- "repository": "https://github.com/sindresorhus/decompress-response"
- }
-}
-```
+`@optimizely/optimizely-sdk` is developed and maintained by [Optimizely](https://optimizely.com) and many [contributors](https://github.com/optimizely/javascript-sdk/graphs/contributors). If you're interested in learning more about what Optimizely Feature Experimentation can do for your company you can visit the [official Optimizely Feature Experimentation product page here](https://www.optimizely.com/products/experiment/feature-experimentation/) to learn more.
+
+First-party code (under `lib/`) is copyright Optimizely, Inc., licensed under Apache 2.0.
+
+### Other Optimizely SDKs
+
+- Agent - https://github.com/optimizely/agent
+
+- Android - https://github.com/optimizely/android-sdk
+
+- C# - https://github.com/optimizely/csharp-sdk
+
+- Flutter - https://github.com/optimizely/optimizely-flutter-sdk
+
+- Go - https://github.com/optimizely/go-sdk
+
+- Java - https://github.com/optimizely/java-sdk
+
+- PHP - https://github.com/optimizely/php-sdk
+
+- Python - https://github.com/optimizely/python-sdk
+
+- React - https://github.com/optimizely/react-sdk
+
+- Ruby - https://github.com/optimizely/ruby-sdk
+
+- Swift - https://github.com/optimizely/swift-sdk
diff --git a/packages/optimizely-sdk/__mocks__/@react-native-async-storage/async-storage-event-processor.ts b/__mocks__/@react-native-async-storage/async-storage-event-processor.ts
similarity index 95%
rename from packages/optimizely-sdk/__mocks__/@react-native-async-storage/async-storage-event-processor.ts
rename to __mocks__/@react-native-async-storage/async-storage-event-processor.ts
index 1ba23231b..ad40f0152 100644
--- a/packages/optimizely-sdk/__mocks__/@react-native-async-storage/async-storage-event-processor.ts
+++ b/__mocks__/@react-native-async-storage/async-storage-event-processor.ts
@@ -36,6 +36,7 @@ export default class AsyncStorage {
return new Promise(resolve => {
setTimeout(() => {
items[key] && delete items[key]
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
resolve()
}, 1)
diff --git a/__mocks__/@react-native-async-storage/async-storage.ts b/__mocks__/@react-native-async-storage/async-storage.ts
new file mode 100644
index 000000000..36d3cf85d
--- /dev/null
+++ b/__mocks__/@react-native-async-storage/async-storage.ts
@@ -0,0 +1,56 @@
+/**
+ * Copyright 2020, 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.
+ */
+
+export default class AsyncStorage {
+ private static items: Record = {};
+
+ static getItem(
+ key: string,
+ callback?: (error?: Error, result?: string | null) => void
+ ): Promise {
+ const value = AsyncStorage.items[key] || null;
+ callback?.(undefined, value);
+ return Promise.resolve(value);
+ }
+
+ static setItem(
+ key: string,
+ value: string,
+ callback?: (error?: Error) => void
+ ): Promise {
+ AsyncStorage.items[key] = value;
+ callback?.(undefined);
+ return Promise.resolve();
+ }
+
+ static removeItem(
+ key: string,
+ callback?: (error?: Error, result?: string | null) => void
+ ): Promise {
+ const value = AsyncStorage.items[key] || null;
+ if (key in AsyncStorage.items) {
+ delete AsyncStorage.items[key];
+ }
+ callback?.(undefined, value);
+ return Promise.resolve(value);
+ }
+
+ static clearStore(): Promise {
+ AsyncStorage.items = {};
+ return Promise.resolve();
+ }
+
+}
diff --git a/packages/optimizely-sdk/__mocks__/@react-native-community/netinfo.ts b/__mocks__/@react-native-community/netinfo.ts
similarity index 100%
rename from packages/optimizely-sdk/__mocks__/@react-native-community/netinfo.ts
rename to __mocks__/@react-native-community/netinfo.ts
diff --git a/__mocks__/fast-text-encoding.ts b/__mocks__/fast-text-encoding.ts
new file mode 100644
index 000000000..cb0ff5c3b
--- /dev/null
+++ b/__mocks__/fast-text-encoding.ts
@@ -0,0 +1 @@
+export {};
diff --git a/__mocks__/react-native-get-random-values.ts b/__mocks__/react-native-get-random-values.ts
new file mode 100644
index 000000000..cb0ff5c3b
--- /dev/null
+++ b/__mocks__/react-native-get-random-values.ts
@@ -0,0 +1 @@
+export {};
diff --git a/packages/optimizely-sdk/karma.base.conf.js b/karma.base.conf.js
similarity index 100%
rename from packages/optimizely-sdk/karma.base.conf.js
rename to karma.base.conf.js
diff --git a/packages/optimizely-sdk/karma.bs.conf.js b/karma.bs.conf.js
similarity index 100%
rename from packages/optimizely-sdk/karma.bs.conf.js
rename to karma.bs.conf.js
diff --git a/packages/optimizely-sdk/karma.local_chrome.bs.conf.js b/karma.local_chrome.bs.conf.js
similarity index 100%
rename from packages/optimizely-sdk/karma.local_chrome.bs.conf.js
rename to karma.local_chrome.bs.conf.js
diff --git a/packages/optimizely-sdk/karma.local_chrome.umd.conf.js b/karma.local_chrome.umd.conf.js
similarity index 100%
rename from packages/optimizely-sdk/karma.local_chrome.umd.conf.js
rename to karma.local_chrome.umd.conf.js
diff --git a/packages/optimizely-sdk/karma.umd.conf.js b/karma.umd.conf.js
similarity index 100%
rename from packages/optimizely-sdk/karma.umd.conf.js
rename to karma.umd.conf.js
diff --git a/lib/client_factory.spec.ts b/lib/client_factory.spec.ts
new file mode 100644
index 000000000..1aa09bda0
--- /dev/null
+++ b/lib/client_factory.spec.ts
@@ -0,0 +1,61 @@
+/**
+ * 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.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import { getOptimizelyInstance } from './client_factory';
+import { createStaticProjectConfigManager } from './project_config/config_manager_factory';
+import Optimizely from './optimizely';
+
+describe('getOptimizelyInstance', () => {
+ it('should throw if the projectConfigManager is not a valid ProjectConfigManager', () => {
+ expect(() => getOptimizelyInstance({
+ projectConfigManager: undefined as any,
+ requestHandler: {} as any,
+ })).toThrow('Invalid config manager');
+
+ expect(() => getOptimizelyInstance({
+ projectConfigManager: null as any,
+ requestHandler: {} as any,
+ })).toThrow('Invalid config manager');
+
+ expect(() => getOptimizelyInstance({
+ projectConfigManager: 'abc' as any,
+ requestHandler: {} as any,
+ })).toThrow('Invalid config manager');
+
+ expect(() => getOptimizelyInstance({
+ projectConfigManager: 123 as any,
+ requestHandler: {} as any,
+ })).toThrow('Invalid config manager');
+
+ expect(() => getOptimizelyInstance({
+ projectConfigManager: {} as any,
+ requestHandler: {} as any,
+ })).toThrow('Invalid config manager');
+ });
+
+ it('should return an instance of Optimizely if a valid projectConfigManager is provided', () => {
+ const optimizelyInstance = getOptimizelyInstance({
+ projectConfigManager: createStaticProjectConfigManager({
+ datafile: '{}',
+ }),
+ requestHandler: {} as any,
+ });
+
+ expect(optimizelyInstance).toBeInstanceOf(Optimizely);
+ });
+});
diff --git a/lib/client_factory.ts b/lib/client_factory.ts
new file mode 100644
index 000000000..3be99b554
--- /dev/null
+++ b/lib/client_factory.ts
@@ -0,0 +1,96 @@
+/**
+ * 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.
+ */
+import { Config } from "./shared_types";
+import { extractLogger } from "./logging/logger_factory";
+import { extractErrorNotifier } from "./error/error_notifier_factory";
+import { extractConfigManager } from "./project_config/config_manager_factory";
+import { extractEventProcessor } from "./event_processor/event_processor_factory";
+import { extractOdpManager } from "./odp/odp_manager_factory";
+import { extractVuidManager } from "./vuid/vuid_manager_factory";
+import { RequestHandler } from "./utils/http_request_handler/http";
+import { CLIENT_VERSION, DEFAULT_CMAB_BACKOFF_MS, DEFAULT_CMAB_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT_MS, DEFAULT_CMAB_RETRIES, JAVASCRIPT_CLIENT_ENGINE } from "./utils/enums";
+import Optimizely from "./optimizely";
+import { DefaultCmabClient } from "./core/decision_service/cmab/cmab_client";
+import { CmabCacheValue, DefaultCmabService } from "./core/decision_service/cmab/cmab_service";
+import { InMemoryLruCache } from "./utils/cache/in_memory_lru_cache";
+import { transformCache, CacheWithRemove } from "./utils/cache/cache";
+import { ConstantBackoff } from "./utils/repeater/repeater";
+
+export type OptimizelyFactoryConfig = Config & {
+ requestHandler: RequestHandler;
+}
+
+export const getOptimizelyInstance = (config: OptimizelyFactoryConfig): Optimizely => {
+ const {
+ clientEngine,
+ clientVersion,
+ jsonSchemaValidator,
+ userProfileService,
+ userProfileServiceAsync,
+ defaultDecideOptions,
+ disposable,
+ requestHandler,
+ } = config;
+
+ const projectConfigManager = extractConfigManager(config.projectConfigManager);
+ const eventProcessor = extractEventProcessor(config.eventProcessor);
+ const odpManager = extractOdpManager(config.odpManager);
+ const vuidManager = extractVuidManager(config.vuidManager);
+ const errorNotifier = extractErrorNotifier(config.errorNotifier);
+ const logger = extractLogger(config.logger);
+
+ const cmabClient = new DefaultCmabClient({
+ requestHandler,
+ retryConfig: {
+ maxRetries: DEFAULT_CMAB_RETRIES,
+ backoffProvider: () => new ConstantBackoff(DEFAULT_CMAB_BACKOFF_MS),
+ },
+ predictionEndpointTemplate: config.cmab?.predictionEndpointTemplate,
+ });
+
+ const cmabCache: CacheWithRemove = config.cmab?.cache ?
+ transformCache(config.cmab.cache, (value) => JSON.parse(value), (value) => JSON.stringify(value)) :
+ (() => {
+ const cacheSize = config.cmab?.cacheSize || DEFAULT_CMAB_CACHE_SIZE;
+ const cacheTtl = config.cmab?.cacheTtl || DEFAULT_CMAB_CACHE_TIMEOUT_MS;
+ return new InMemoryLruCache(cacheSize, cacheTtl);
+ })();
+
+ const cmabService = new DefaultCmabService({
+ cmabClient,
+ cmabCache,
+ logger: logger?.child()
+ });
+
+ const optimizelyOptions = {
+ cmabService,
+ clientEngine: clientEngine || JAVASCRIPT_CLIENT_ENGINE,
+ clientVersion: clientVersion || CLIENT_VERSION,
+ jsonSchemaValidator,
+ userProfileService,
+ userProfileServiceAsync,
+ defaultDecideOptions,
+ disposable,
+ logger,
+ errorNotifier,
+ projectConfigManager,
+ eventProcessor,
+ odpManager,
+ vuidManager,
+ };
+
+ return new Optimizely(optimizelyOptions);
+}
diff --git a/lib/common_exports.ts b/lib/common_exports.ts
new file mode 100644
index 000000000..801fb7728
--- /dev/null
+++ b/lib/common_exports.ts
@@ -0,0 +1,37 @@
+/**
+ * Copyright 2023-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.
+ */
+
+export { createStaticProjectConfigManager } from './project_config/config_manager_factory';
+
+export { LogLevel } from './logging/logger';
+
+export {
+ DEBUG,
+ INFO,
+ WARN,
+ ERROR,
+} from './logging/logger_factory';
+
+export { createLogger } from './logging/logger_factory';
+export { createErrorNotifier } from './error/error_notifier_factory';
+
+export {
+ DECISION_SOURCES,
+} from './utils/enums';
+
+export { NOTIFICATION_TYPES, DECISION_NOTIFICATION_TYPES } from './notification_center/type';
+
+export { OptimizelyDecideOption } from './shared_types';
diff --git a/lib/core/audience_evaluator/index.spec.ts b/lib/core/audience_evaluator/index.spec.ts
new file mode 100644
index 000000000..e22654144
--- /dev/null
+++ b/lib/core/audience_evaluator/index.spec.ts
@@ -0,0 +1,713 @@
+/**
+ * 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.
+ */
+import { beforeEach, afterEach, describe, it, vi, expect, afterAll } from 'vitest';
+
+import AudienceEvaluator, { createAudienceEvaluator } from './index';
+import * as conditionTreeEvaluator from '../condition_tree_evaluator';
+import * as customAttributeConditionEvaluator from '../custom_attribute_condition_evaluator';
+import { AUDIENCE_EVALUATION_RESULT, EVALUATING_AUDIENCE } from '../../message/log_message';
+import { getMockLogger } from '../../tests/mock/mock_logger';
+import { Audience, OptimizelyDecideOption, OptimizelyDecision } from '../../shared_types';
+import { IOptimizelyUserContext } from '../../optimizely_user_context';
+
+let mockLogger = getMockLogger();
+
+const getMockUserContext = (attributes?: unknown, segments?: string[]): IOptimizelyUserContext => ({
+ getAttributes: () => ({ ...(attributes || {}) }),
+ isQualifiedFor: segment => segments ? segments.indexOf(segment) > -1 : false,
+ qualifiedSegments: segments || [],
+ getUserId: () => 'mockUserId',
+ setAttribute: (key: string, value: any) => {},
+
+ decide: (key: string, options?: OptimizelyDecideOption[]): OptimizelyDecision => ({
+ variationKey: 'mockVariationKey',
+ enabled: true,
+ variables: { mockVariable: 'mockValue' },
+ ruleKey: 'mockRuleKey',
+ reasons: ['mockReason'],
+ flagKey: 'flagKey',
+ userContext: getMockUserContext()
+ }),
+}) as IOptimizelyUserContext;
+
+const chromeUserAudience = {
+ id: '0',
+ name: 'chromeUserAudience',
+ conditions: [
+ 'and',
+ {
+ name: 'browser_type',
+ value: 'chrome',
+ type: 'custom_attribute',
+ },
+ ],
+};
+const iphoneUserAudience = {
+ id: '1',
+ name: 'iphoneUserAudience',
+ conditions: [
+ 'and',
+ {
+ name: 'device_model',
+ value: 'iphone',
+ type: 'custom_attribute',
+ },
+ ],
+};
+const specialConditionTypeAudience = {
+ id: '3',
+ name: 'specialConditionTypeAudience',
+ conditions: [
+ 'and',
+ {
+ match: 'interest_level',
+ value: 'special',
+ type: 'special_condition_type',
+ },
+ ],
+};
+const conditionsPassingWithNoAttrs = [
+ 'not',
+ {
+ match: 'exists',
+ name: 'input_value',
+ type: 'custom_attribute',
+ },
+];
+const conditionsPassingWithNoAttrsAudience = {
+ id: '2',
+ name: 'conditionsPassingWithNoAttrsAudience',
+ conditions: conditionsPassingWithNoAttrs,
+};
+
+const audiencesById: {
+[id: string]: Audience;
+} = {
+ "0": chromeUserAudience,
+ "1": iphoneUserAudience,
+ "2": conditionsPassingWithNoAttrsAudience,
+ "3": specialConditionTypeAudience,
+};
+
+
+describe('lib/core/audience_evaluator', () => {
+ let audienceEvaluator: AudienceEvaluator;
+
+ beforeEach(() => {
+ mockLogger = getMockLogger();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('APIs', () => {
+ describe('with default condition evaluator', () => {
+ beforeEach(() => {
+ audienceEvaluator = createAudienceEvaluator({});
+ });
+ describe('evaluate', () => {
+ it('should return true if there are no audiences', () => {
+ expect(audienceEvaluator.evaluate([], audiencesById, getMockUserContext({}))).toBe(true);
+ });
+
+ it('should return false if there are audiences but no attributes', () => {
+ expect(audienceEvaluator.evaluate(['0'], audiencesById, getMockUserContext({}))).toBe(false);
+ });
+
+ it('should return true if any of the audience conditions are met', () => {
+ const iphoneUsers = {
+ device_model: 'iphone',
+ };
+
+ const chromeUsers = {
+ browser_type: 'chrome',
+ };
+
+ const iphoneChromeUsers = {
+ browser_type: 'chrome',
+ device_model: 'iphone',
+ };
+
+ expect(audienceEvaluator.evaluate(['0', '1'], audiencesById, getMockUserContext(iphoneUsers))).toBe(true);
+ expect(audienceEvaluator.evaluate(['0', '1'], audiencesById, getMockUserContext(chromeUsers))).toBe(true);
+ expect(audienceEvaluator.evaluate(['0', '1'], audiencesById, getMockUserContext(iphoneChromeUsers))).toBe(
+ true
+ );
+ });
+
+ it('should return false if none of the audience conditions are met', () => {
+ const nexusUsers = {
+ device_model: 'nexus5',
+ };
+
+ const safariUsers = {
+ browser_type: 'safari',
+ };
+
+ const nexusSafariUsers = {
+ browser_type: 'safari',
+ device_model: 'nexus5',
+ };
+
+ expect(audienceEvaluator.evaluate(['0', '1'], audiencesById, getMockUserContext(nexusUsers))).toBe(false);
+ expect(audienceEvaluator.evaluate(['0', '1'], audiencesById, getMockUserContext(safariUsers))).toBe(false);
+ expect(audienceEvaluator.evaluate(['0', '1'], audiencesById, getMockUserContext(nexusSafariUsers))).toBe(
+ false
+ );
+ });
+
+ it('should return true if no attributes are passed and the audience conditions evaluate to true in the absence of attributes', () => {
+ expect(audienceEvaluator.evaluate(['2'], audiencesById, getMockUserContext({}))).toBe(true);
+ });
+
+ describe('complex audience conditions', () => {
+ it('should return true if any of the audiences in an "OR" condition pass', () => {
+ const result = audienceEvaluator.evaluate(
+ ['or', '0', '1'],
+ audiencesById,
+ getMockUserContext({ browser_type: 'chrome' })
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should return true if all of the audiences in an "AND" condition pass', () => {
+ const result = audienceEvaluator.evaluate(
+ ['and', '0', '1'],
+ audiencesById,
+ getMockUserContext({
+ browser_type: 'chrome',
+ device_model: 'iphone',
+ })
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should return true if the audience in a "NOT" condition does not pass', () => {
+ const result = audienceEvaluator.evaluate(
+ ['not', '1'],
+ audiencesById,
+ getMockUserContext({ device_model: 'android' })
+ );
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('integration with dependencies', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ afterAll(() => {
+ vi.resetAllMocks();
+ });
+
+ it('returns true if conditionTreeEvaluator.evaluate returns true', () => {
+ vi.spyOn(conditionTreeEvaluator, 'evaluate').mockReturnValue(true);
+ const result = audienceEvaluator.evaluate(
+ ['or', '0', '1'],
+ audiencesById,
+ getMockUserContext({ browser_type: 'chrome' })
+ );
+ expect(result).toBe(true);
+ });
+
+ it('returns false if conditionTreeEvaluator.evaluate returns false', () => {
+ vi.spyOn(conditionTreeEvaluator, 'evaluate').mockReturnValue(false);
+ const result = audienceEvaluator.evaluate(
+ ['or', '0', '1'],
+ audiencesById,
+ getMockUserContext({ browser_type: 'safari' })
+ );
+ expect(result).toBe(false);
+ });
+
+ it('returns false if conditionTreeEvaluator.evaluate returns null', () => {
+ vi.spyOn(conditionTreeEvaluator, 'evaluate').mockReturnValue(null);
+ const result = audienceEvaluator.evaluate(
+ ['or', '0', '1'],
+ audiencesById,
+ getMockUserContext({ state: 'California' })
+ );
+ expect(result).toBe(false);
+ });
+
+ it('calls customAttributeConditionEvaluator.evaluate in the leaf evaluator for audience conditions', () => {
+ vi.spyOn(conditionTreeEvaluator, 'evaluate').mockImplementation((conditions: any, leafEvaluator) => {
+ return leafEvaluator(conditions[1]);
+ });
+
+ const mockCustomAttributeConditionEvaluator = vi.fn().mockReturnValue(false);
+
+ vi.spyOn(customAttributeConditionEvaluator, 'getEvaluator').mockReturnValue({
+ evaluate: mockCustomAttributeConditionEvaluator,
+ });
+
+ const audienceEvaluator = createAudienceEvaluator({});
+
+ const userAttributes = { device_model: 'android' };
+ const user = getMockUserContext(userAttributes);
+ const result = audienceEvaluator.evaluate(['or', '1'], audiencesById, user);
+
+ expect(mockCustomAttributeConditionEvaluator).toHaveBeenCalledTimes(1);
+ expect(mockCustomAttributeConditionEvaluator).toHaveBeenCalledWith(
+ iphoneUserAudience.conditions[1],
+ user,
+ );
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('Audience evaluation logging', () => {
+ let mockCustomAttributeConditionEvaluator: ReturnType;
+
+ beforeEach(() => {
+ mockCustomAttributeConditionEvaluator = vi.fn();
+ vi.spyOn(conditionTreeEvaluator, 'evaluate');
+ vi.spyOn(customAttributeConditionEvaluator, 'getEvaluator').mockReturnValue({
+ evaluate: mockCustomAttributeConditionEvaluator,
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('logs correctly when conditionTreeEvaluator.evaluate returns null', () => {
+ vi.spyOn(conditionTreeEvaluator, 'evaluate').mockImplementationOnce((conditions: any, leafEvaluator) => {
+ return leafEvaluator(conditions[1]);
+ });
+
+ mockCustomAttributeConditionEvaluator.mockReturnValue(null);
+ const userAttributes = { device_model: 5.5 };
+ const user = getMockUserContext(userAttributes);
+
+ const audienceEvaluator = createAudienceEvaluator({}, mockLogger);
+
+ const result = audienceEvaluator.evaluate(['or', '1'], audiencesById, user);
+
+ expect(mockCustomAttributeConditionEvaluator).toHaveBeenCalledTimes(1);
+ expect(mockCustomAttributeConditionEvaluator).toHaveBeenCalledWith(iphoneUserAudience.conditions[1], user);
+ expect(result).toBe(false);
+ expect(mockLogger.debug).toHaveBeenCalledTimes(2);
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ EVALUATING_AUDIENCE,
+ '1',
+ JSON.stringify(['and', iphoneUserAudience.conditions[1]])
+ );
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(AUDIENCE_EVALUATION_RESULT, '1', 'UNKNOWN');
+ });
+
+ it('logs correctly when conditionTreeEvaluator.evaluate returns true', () => {
+ vi.spyOn(conditionTreeEvaluator, 'evaluate').mockImplementationOnce((conditions: any, leafEvaluator) => {
+ return leafEvaluator(conditions[1]);
+ });
+
+ mockCustomAttributeConditionEvaluator.mockReturnValue(true);
+
+ const userAttributes = { device_model: 'iphone' };
+ const user = getMockUserContext(userAttributes);
+
+ const audienceEvaluator = createAudienceEvaluator({}, mockLogger);
+
+ const result = audienceEvaluator.evaluate(['or', '1'], audiencesById, user);
+ expect(mockCustomAttributeConditionEvaluator).toHaveBeenCalledTimes(1);
+ expect(mockCustomAttributeConditionEvaluator).toHaveBeenCalledWith(iphoneUserAudience.conditions[1], user);
+ expect(result).toBe(true);
+ expect(mockLogger.debug).toHaveBeenCalledTimes(2)
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ EVALUATING_AUDIENCE,
+ '1',
+ JSON.stringify(['and', iphoneUserAudience.conditions[1]])
+ );
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(AUDIENCE_EVALUATION_RESULT, '1', 'TRUE');
+ });
+
+ it('logs correctly when conditionTreeEvaluator.evaluate returns false', () => {
+ vi.spyOn(conditionTreeEvaluator, 'evaluate').mockImplementationOnce((conditions: any, leafEvaluator) => {
+ return leafEvaluator(conditions[1]);
+ });
+
+ mockCustomAttributeConditionEvaluator.mockReturnValue(false);
+
+ const userAttributes = { device_model: 'android' };
+ const user = getMockUserContext(userAttributes);
+
+ const audienceEvaluator = createAudienceEvaluator({}, mockLogger);
+
+ const result = audienceEvaluator.evaluate(['or', '1'], audiencesById, user);
+ expect(mockCustomAttributeConditionEvaluator).toHaveBeenCalledTimes(1);
+ expect(mockCustomAttributeConditionEvaluator).toHaveBeenCalledWith(iphoneUserAudience.conditions[1], user);
+ expect(result).toBe(false);
+ expect(mockLogger.debug).toHaveBeenCalledTimes(2)
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ EVALUATING_AUDIENCE,
+ '1',
+ JSON.stringify(['and', iphoneUserAudience.conditions[1]])
+ );
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(AUDIENCE_EVALUATION_RESULT, '1', 'FALSE');
+ });
+ });
+ });
+ });
+
+ describe('with additional custom condition evaluator', () => {
+ describe('when passing a valid additional evaluator', () => {
+ beforeEach(() => {
+ const mockEnvironment = {
+ special: true,
+ };
+ audienceEvaluator = createAudienceEvaluator({
+ special_condition_type: {
+ evaluate: (condition: any, user: any) => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const result = mockEnvironment[condition.value] && user.getAttributes()[condition.match] > 0;
+ return result;
+ },
+ },
+ });
+ });
+
+ it('should evaluate an audience properly using the custom condition evaluator', () => {
+ expect(audienceEvaluator.evaluate(['3'], audiencesById, getMockUserContext({ interest_level: 0 }))).toBe(
+ false
+ );
+ expect(audienceEvaluator.evaluate(['3'], audiencesById, getMockUserContext({ interest_level: 1 }))).toBe(
+ true
+ );
+ });
+ });
+
+ describe('when passing an invalid additional evaluator', () => {
+ beforeEach(() => {
+ audienceEvaluator = createAudienceEvaluator({
+ custom_attribute: {
+ evaluate: () => {
+ return false;
+ },
+ },
+ });
+ });
+
+ it('should not be able to overwrite built in `custom_attribute` evaluator', () => {
+ expect(
+ audienceEvaluator.evaluate(
+ ['0'],
+ audiencesById,
+ getMockUserContext({
+ browser_type: 'chrome',
+ })
+ )
+ ).toBe(true);
+ });
+ });
+ });
+
+ describe('with odp segment evaluator', () => {
+ describe('Single ODP Audience', () => {
+ const singleAudience = {
+ id: '0',
+ name: 'singleAudience',
+ conditions: [
+ 'and',
+ {
+ value: 'odp-segment-1',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ ],
+ };
+ const audiencesById = {
+ 0: singleAudience,
+ };
+ const audience = new AudienceEvaluator({});
+
+ it('should evaluate to true if segment is found', () => {
+ expect(audience.evaluate(['or', '0'], audiencesById, getMockUserContext({}, ['odp-segment-1']))).toBe(true);
+ });
+
+ it('should evaluate to false if segment is not found', () => {
+ expect(audience.evaluate(['or', '0'], audiencesById, getMockUserContext({}, ['odp-segment-2']))).toBe(false);
+ });
+
+ it('should evaluate to false if not segments are provided', () => {
+ expect(audience.evaluate(['or', '0'], audiencesById, getMockUserContext({}))).toBe(false);
+ });
+ });
+
+ describe('Multiple ODP conditions in one Audience', () => {
+ const singleAudience = {
+ id: '0',
+ name: 'singleAudience',
+ conditions: [
+ 'and',
+ {
+ value: 'odp-segment-1',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ {
+ value: 'odp-segment-2',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ [
+ 'or',
+ {
+ value: 'odp-segment-3',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ {
+ value: 'odp-segment-4',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ ],
+ ],
+ };
+ const audiencesById = {
+ 0: singleAudience,
+ };
+ const audience = new AudienceEvaluator({});
+
+ it('should evaluate correctly based on the given segments', () => {
+ expect(
+ audience.evaluate(
+ ['or', '0'],
+ audiencesById,
+ getMockUserContext({}, ['odp-segment-1', 'odp-segment-2', 'odp-segment-3'])
+ )
+ ).toBe(true);
+ expect(
+ audience.evaluate(
+ ['or', '0'],
+ audiencesById,
+ getMockUserContext({}, ['odp-segment-1', 'odp-segment-2', 'odp-segment-4'])
+ )
+ ).toBe(true);
+ expect(
+ audience.evaluate(
+ ['or', '0'],
+ audiencesById,
+ getMockUserContext({}, ['odp-segment-1', 'odp-segment-2', 'odp-segment-3', 'odp-segment-4'])
+ )
+ ).toBe(true);
+ expect(
+ audience.evaluate(
+ ['or', '0'],
+ audiencesById,
+ getMockUserContext({}, ['odp-segment-1', 'odp-segment-3', 'odp-segment-4'])
+ )
+ ).toBe(false);
+ expect(
+ audience.evaluate(
+ ['or', '0'],
+ audiencesById,
+ getMockUserContext({}, ['odp-segment-2', 'odp-segment-3', 'odp-segment-4'])
+ )
+ ).toBe(false);
+ });
+ });
+
+ describe('Multiple ODP conditions in multiple Audience', () => {
+ const audience1And2 = {
+ id: '0',
+ name: 'audience1And2',
+ conditions: [
+ 'and',
+ {
+ value: 'odp-segment-1',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ {
+ value: 'odp-segment-2',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ ],
+ };
+
+ const audience3And4 = {
+ id: '1',
+ name: 'audience3And4',
+ conditions: [
+ 'and',
+ {
+ value: 'odp-segment-3',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ {
+ value: 'odp-segment-4',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ ],
+ };
+
+ const audience5And6 = {
+ id: '2',
+ name: 'audience5And6',
+ conditions: [
+ 'or',
+ {
+ value: 'odp-segment-5',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ {
+ value: 'odp-segment-6',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ ],
+ };
+ const audiencesById = {
+ 0: audience1And2,
+ 1: audience3And4,
+ 2: audience5And6,
+ };
+ const audience = new AudienceEvaluator({});
+
+ it('should evaluate correctly based on the given segments', () => {
+ expect(
+ audience.evaluate(
+ ['or', '0', '1', '2'],
+ audiencesById,
+ getMockUserContext({}, ['odp-segment-1', 'odp-segment-2'])
+ )
+ ).toBe(true);
+ expect(
+ audience.evaluate(
+ ['and', '0', '1', '2'],
+ audiencesById,
+ getMockUserContext({}, ['odp-segment-1', 'odp-segment-2'])
+ )
+ ).toBe(false);
+ expect(
+ audience.evaluate(
+ ['and', '0', '1', '2'],
+ audiencesById,
+ getMockUserContext({}, [
+ 'odp-segment-1',
+ 'odp-segment-2',
+ 'odp-segment-3',
+ 'odp-segment-4',
+ 'odp-segment-6',
+ ])
+ )
+ ).toBe(true);
+ expect(
+ audience.evaluate(
+ ['and', '0', '1', ['not', '2']],
+ audiencesById,
+ getMockUserContext({}, ['odp-segment-1', 'odp-segment-2', 'odp-segment-3', 'odp-segment-4'])
+ )
+ ).toBe(true);
+ });
+ });
+ });
+
+ describe('with multiple types of evaluators', () => {
+ const audience1And2 = {
+ id: '0',
+ name: 'audience1And2',
+ conditions: [
+ 'and',
+ {
+ value: 'odp-segment-1',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ {
+ value: 'odp-segment-2',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ ],
+ };
+ const audience3Or4 = {
+ id: '',
+ name: 'audience3And4',
+ conditions: [
+ 'or',
+ {
+ value: 'odp-segment-3',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ {
+ value: 'odp-segment-4',
+ type: 'third_party_dimension',
+ name: 'odp.audiences',
+ match: 'qualified',
+ },
+ ],
+ };
+
+ const audiencesById = {
+ 0: audience1And2,
+ 1: audience3Or4,
+ 2: chromeUserAudience,
+ };
+
+ const audience = new AudienceEvaluator({});
+
+ it('should evaluate correctly based on the given segments', () => {
+ expect(
+ audience.evaluate(
+ ['and', '0', '1', '2'],
+ audiencesById,
+ getMockUserContext({ browser_type: 'not_chrome' }, ['odp-segment-1', 'odp-segment-2', 'odp-segment-4'])
+ )
+ ).toBe(false);
+ expect(
+ audience.evaluate(
+ ['and', '0', '1', '2'],
+ audiencesById,
+ getMockUserContext({ browser_type: 'chrome' }, ['odp-segment-1', 'odp-segment-2', 'odp-segment-4'])
+ )
+ ).toBe(true);
+ });
+ });
+ });
+});
diff --git a/packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js b/lib/core/audience_evaluator/index.tests.js
similarity index 84%
rename from packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js
rename to lib/core/audience_evaluator/index.tests.js
index 6fb545f10..1dc5efd30 100644
--- a/packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js
+++ b/lib/core/audience_evaluator/index.tests.js
@@ -16,14 +16,19 @@
import sinon from 'sinon';
import { assert } from 'chai';
import { sprintf } from '../../utils/fns';
-import { getLogger } from '../../modules/logging';
import AudienceEvaluator, { createAudienceEvaluator } from './index';
import * as conditionTreeEvaluator from '../condition_tree_evaluator';
import * as customAttributeConditionEvaluator from '../custom_attribute_condition_evaluator';
+import { AUDIENCE_EVALUATION_RESULT, EVALUATING_AUDIENCE } from 'log_message';
var buildLogMessageFromArgs = args => sprintf(args[1], ...args.splice(2));
-var mockLogger = getLogger();
+var mockLogger = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+}
var getMockUserContext = (attributes, segments) => ({
getAttributes: () => ({ ... (attributes || {})}),
@@ -82,11 +87,17 @@ describe('lib/core/audience_evaluator', function() {
var audienceEvaluator;
beforeEach(function() {
- sinon.stub(mockLogger, 'log');
+ sinon.stub(mockLogger, 'info');
+ sinon.stub(mockLogger, 'debug');
+ sinon.stub(mockLogger, 'warn');
+ sinon.stub(mockLogger, 'error');
});
afterEach(function() {
- mockLogger.log.restore();
+ mockLogger.info.restore();
+ mockLogger.debug.restore();
+ mockLogger.warn.restore();
+ mockLogger.error.restore();
});
describe('APIs', function() {
@@ -170,7 +181,6 @@ describe('lib/core/audience_evaluator', function() {
beforeEach(function() {
sandbox.stub(conditionTreeEvaluator, 'evaluate');
- sandbox.stub(customAttributeConditionEvaluator, 'evaluate');
});
afterEach(function() {
@@ -199,26 +209,40 @@ describe('lib/core/audience_evaluator', function() {
conditionTreeEvaluator.evaluate.callsFake(function(conditions, leafEvaluator) {
return leafEvaluator(conditions[1]);
});
- customAttributeConditionEvaluator.evaluate.returns(false);
+
+ const mockCustomAttributeConditionEvaluator = sinon.stub().returns(false);
+
+ sinon.stub(customAttributeConditionEvaluator, 'getEvaluator').returns({
+ evaluate: mockCustomAttributeConditionEvaluator,
+ });
+
+ const audienceEvaluator = createAudienceEvaluator();
+
var userAttributes = { device_model: 'android' };
var user = getMockUserContext(userAttributes);
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, user);
- sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
+ sinon.assert.calledOnce(mockCustomAttributeConditionEvaluator);
sinon.assert.calledWithExactly(
- customAttributeConditionEvaluator.evaluate,
+ mockCustomAttributeConditionEvaluator,
iphoneUserAudience.conditions[1],
user,
);
assert.isFalse(result);
+
+ customAttributeConditionEvaluator.getEvaluator.restore();
});
});
describe('Audience evaluation logging', function() {
var sandbox = sinon.sandbox.create();
+ var mockCustomAttributeConditionEvaluator;
beforeEach(function() {
+ mockCustomAttributeConditionEvaluator = sinon.stub();
sandbox.stub(conditionTreeEvaluator, 'evaluate');
- sandbox.stub(customAttributeConditionEvaluator, 'evaluate');
+ sandbox.stub(customAttributeConditionEvaluator, 'getEvaluator').returns({
+ evaluate: mockCustomAttributeConditionEvaluator,
+ });
});
afterEach(function() {
@@ -229,69 +253,110 @@ describe('lib/core/audience_evaluator', function() {
conditionTreeEvaluator.evaluate.callsFake(function(conditions, leafEvaluator) {
return leafEvaluator(conditions[1]);
});
- customAttributeConditionEvaluator.evaluate.returns(null);
+
+ mockCustomAttributeConditionEvaluator.returns(null);
var userAttributes = { device_model: 5.5 };
var user = getMockUserContext(userAttributes);
+
+ const audienceEvaluator = createAudienceEvaluator({}, mockLogger);
+
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, user);
- sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
+
+ sinon.assert.calledOnce(mockCustomAttributeConditionEvaluator);
sinon.assert.calledWithExactly(
- customAttributeConditionEvaluator.evaluate,
+ mockCustomAttributeConditionEvaluator,
iphoneUserAudience.conditions[1],
user
);
assert.isFalse(result);
- assert.strictEqual(2, mockLogger.log.callCount);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'AUDIENCE_EVALUATOR: Starting to evaluate audience "1" with conditions: ["and",{"name":"device_model","value":"iphone","type":"custom_attribute"}].'
- );
- assert.strictEqual(buildLogMessageFromArgs(mockLogger.log.args[1]), 'AUDIENCE_EVALUATOR: Audience "1" evaluated to UNKNOWN.');
+ assert.strictEqual(2, mockLogger.debug.callCount);
+
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ EVALUATING_AUDIENCE,
+ '1',
+ JSON.stringify(['and', iphoneUserAudience.conditions[1]])
+ )
+
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ AUDIENCE_EVALUATION_RESULT,
+ '1',
+ 'UNKNOWN'
+ )
});
it('logs correctly when conditionTreeEvaluator.evaluate returns true', function() {
conditionTreeEvaluator.evaluate.callsFake(function(conditions, leafEvaluator) {
return leafEvaluator(conditions[1]);
});
- customAttributeConditionEvaluator.evaluate.returns(true);
+
+ mockCustomAttributeConditionEvaluator.returns(true);
+
var userAttributes = { device_model: 'iphone' };
var user = getMockUserContext(userAttributes);
+
+ const audienceEvaluator = createAudienceEvaluator({}, mockLogger);
+
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, user);
- sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
+ sinon.assert.calledOnce(mockCustomAttributeConditionEvaluator);
sinon.assert.calledWithExactly(
- customAttributeConditionEvaluator.evaluate,
+ mockCustomAttributeConditionEvaluator,
iphoneUserAudience.conditions[1],
user,
);
assert.isTrue(result);
- assert.strictEqual(2, mockLogger.log.callCount);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'AUDIENCE_EVALUATOR: Starting to evaluate audience "1" with conditions: ["and",{"name":"device_model","value":"iphone","type":"custom_attribute"}].'
- );
- assert.strictEqual(buildLogMessageFromArgs(mockLogger.log.args[1]), 'AUDIENCE_EVALUATOR: Audience "1" evaluated to TRUE.');
+ assert.strictEqual(2, mockLogger.debug.callCount);
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ EVALUATING_AUDIENCE,
+ '1',
+ JSON.stringify(['and', iphoneUserAudience.conditions[1]])
+ )
+
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ AUDIENCE_EVALUATION_RESULT,
+ '1',
+ 'TRUE'
+ )
});
it('logs correctly when conditionTreeEvaluator.evaluate returns false', function() {
conditionTreeEvaluator.evaluate.callsFake(function(conditions, leafEvaluator) {
return leafEvaluator(conditions[1]);
});
- customAttributeConditionEvaluator.evaluate.returns(false);
+
+ mockCustomAttributeConditionEvaluator.returns(false);
+
var userAttributes = { device_model: 'android' };
var user = getMockUserContext(userAttributes);
+
+ const audienceEvaluator = createAudienceEvaluator({}, mockLogger);
+
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, user);
- sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
+ sinon.assert.calledOnce(mockCustomAttributeConditionEvaluator);
sinon.assert.calledWithExactly(
- customAttributeConditionEvaluator.evaluate,
+ mockCustomAttributeConditionEvaluator,
iphoneUserAudience.conditions[1],
user,
);
assert.isFalse(result);
- assert.strictEqual(2, mockLogger.log.callCount);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'AUDIENCE_EVALUATOR: Starting to evaluate audience "1" with conditions: ["and",{"name":"device_model","value":"iphone","type":"custom_attribute"}].'
- );
- assert.strictEqual(buildLogMessageFromArgs(mockLogger.log.args[1]), 'AUDIENCE_EVALUATOR: Audience "1" evaluated to FALSE.');
+ assert.strictEqual(2, mockLogger.debug.callCount);
+
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ EVALUATING_AUDIENCE,
+ '1',
+ JSON.stringify(['and', iphoneUserAudience.conditions[1]])
+ )
+
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ AUDIENCE_EVALUATION_RESULT,
+ '1',
+ 'FALSE'
+ )
});
});
});
diff --git a/packages/optimizely-sdk/lib/core/audience_evaluator/index.ts b/lib/core/audience_evaluator/index.ts
similarity index 81%
rename from packages/optimizely-sdk/lib/core/audience_evaluator/index.ts
rename to lib/core/audience_evaluator/index.ts
index 550694610..e2b3bce0a 100644
--- a/packages/optimizely-sdk/lib/core/audience_evaluator/index.ts
+++ b/lib/core/audience_evaluator/index.ts
@@ -13,23 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { getLogger } from '../../modules/logging';
-
-import fns from '../../utils/fns';
-import {
- LOG_LEVEL,
- LOG_MESSAGES,
- ERROR_MESSAGES,
-} from '../../utils/enums';
import * as conditionTreeEvaluator from '../condition_tree_evaluator';
import * as customAttributeConditionEvaluator from '../custom_attribute_condition_evaluator';
import * as odpSegmentsConditionEvaluator from './odp_segment_condition_evaluator';
import { Audience, Condition, OptimizelyUserContext } from '../../shared_types';
-
-const logger = getLogger();
-const MODULE_NAME = 'AUDIENCE_EVALUATOR';
+import { CONDITION_EVALUATOR_ERROR, UNKNOWN_CONDITION_TYPE } from 'error_message';
+import { AUDIENCE_EVALUATION_RESULT, EVALUATING_AUDIENCE} from 'log_message';
+import { LoggerFacade } from '../../logging/logger';
export class AudienceEvaluator {
+ private logger?: LoggerFacade;
+
private typeToEvaluatorMap: {
[key: string]: {
[key: string]: (condition: Condition, user: OptimizelyUserContext) => boolean | null
@@ -43,11 +37,13 @@ export class AudienceEvaluator {
* Optimizely evaluators cannot be overridden.
* @constructor
*/
- constructor(UNSTABLE_conditionEvaluators: unknown) {
- this.typeToEvaluatorMap = fns.assign({}, UNSTABLE_conditionEvaluators, {
- custom_attribute: customAttributeConditionEvaluator,
- third_party_dimension: odpSegmentsConditionEvaluator,
- });
+ constructor(UNSTABLE_conditionEvaluators: unknown, logger?: LoggerFacade) {
+ this.logger = logger;
+ this.typeToEvaluatorMap = {
+ ...UNSTABLE_conditionEvaluators as any,
+ custom_attribute: customAttributeConditionEvaluator.getEvaluator(this.logger),
+ third_party_dimension: odpSegmentsConditionEvaluator.getEvaluator(this.logger),
+ };
}
/**
@@ -76,16 +72,15 @@ export class AudienceEvaluator {
const evaluateAudience = (audienceId: string) => {
const audience = audiencesById[audienceId];
if (audience) {
- logger.log(
- LOG_LEVEL.DEBUG,
- LOG_MESSAGES.EVALUATING_AUDIENCE, MODULE_NAME, audienceId, JSON.stringify(audience.conditions)
+ this.logger?.debug(
+ EVALUATING_AUDIENCE, audienceId, JSON.stringify(audience.conditions)
);
const result = conditionTreeEvaluator.evaluate(
audience.conditions as unknown[] ,
this.evaluateConditionWithUserAttributes.bind(this, user)
);
const resultText = result === null ? 'UNKNOWN' : result.toString().toUpperCase();
- logger.log(LOG_LEVEL.DEBUG, LOG_MESSAGES.AUDIENCE_EVALUATION_RESULT, MODULE_NAME, audienceId, resultText);
+ this.logger?.debug(AUDIENCE_EVALUATION_RESULT, audienceId, resultText);
return result;
}
return null;
@@ -104,15 +99,14 @@ export class AudienceEvaluator {
evaluateConditionWithUserAttributes(user: OptimizelyUserContext, condition: Condition): boolean | null {
const evaluator = this.typeToEvaluatorMap[condition.type];
if (!evaluator) {
- logger.log(LOG_LEVEL.WARNING, LOG_MESSAGES.UNKNOWN_CONDITION_TYPE, MODULE_NAME, JSON.stringify(condition));
+ this.logger?.warn(UNKNOWN_CONDITION_TYPE, JSON.stringify(condition));
return null;
}
try {
return evaluator.evaluate(condition, user);
} catch (err: any) {
- logger.log(
- LOG_LEVEL.ERROR,
- ERROR_MESSAGES.CONDITION_EVALUATOR_ERROR, MODULE_NAME, condition.type, err.message
+ this.logger?.error(
+ CONDITION_EVALUATOR_ERROR, condition.type, err.message
);
}
@@ -122,6 +116,6 @@ export class AudienceEvaluator {
export default AudienceEvaluator;
-export const createAudienceEvaluator = function(UNSTABLE_conditionEvaluators: unknown): AudienceEvaluator {
- return new AudienceEvaluator(UNSTABLE_conditionEvaluators);
+export const createAudienceEvaluator = function(UNSTABLE_conditionEvaluators: unknown, logger?: LoggerFacade): AudienceEvaluator {
+ return new AudienceEvaluator(UNSTABLE_conditionEvaluators, logger);
};
diff --git a/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.spec.ts b/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.spec.ts
new file mode 100644
index 000000000..f42d07cb4
--- /dev/null
+++ b/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.spec.ts
@@ -0,0 +1,74 @@
+/**
+ * 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.
+ */
+import { afterEach, describe, it, vi, expect } from 'vitest';
+import * as odpSegmentEvalutor from '.';
+import { UNKNOWN_MATCH_TYPE } from '../../../message/error_message';
+import { IOptimizelyUserContext } from '../../../optimizely_user_context';
+import { OptimizelyDecideOption, OptimizelyDecision } from '../../../shared_types';
+import { getMockLogger } from '../../../tests/mock/mock_logger';
+
+const odpSegment1Condition = {
+ "value": "odp-segment-1",
+ "type": "third_party_dimension",
+ "name": "odp.audiences",
+ "match": "qualified"
+};
+
+const getMockUserContext = (attributes?: unknown, segments?: string[]): IOptimizelyUserContext => ({
+ getAttributes: () => ({ ...(attributes || {}) }),
+ isQualifiedFor: segment => segments ? segments.indexOf(segment) > -1 : false,
+ qualifiedSegments: segments || [],
+ getUserId: () => 'mockUserId',
+ setAttribute: (key: string, value: any) => {},
+
+ decide: (key: string, options?: OptimizelyDecideOption[]): OptimizelyDecision => ({
+ variationKey: 'mockVariationKey',
+ enabled: true,
+ variables: { mockVariable: 'mockValue' },
+ ruleKey: 'mockRuleKey',
+ reasons: ['mockReason'],
+ flagKey: 'flagKey',
+ userContext: getMockUserContext()
+ }),
+}) as IOptimizelyUserContext;
+
+
+describe('lib/core/audience_evaluator/odp_segment_condition_evaluator', function() {
+ const mockLogger = getMockLogger();
+ const { evaluate } = odpSegmentEvalutor.getEvaluator(mockLogger);
+
+ afterEach(function() {
+ vi.restoreAllMocks();
+ });
+
+ it('should return true when segment qualifies and known match type is provided', () => {
+ expect(evaluate(odpSegment1Condition, getMockUserContext({}, ['odp-segment-1']))).toBe(true);
+ });
+
+ it('should return false when segment does not qualify and known match type is provided', () => {
+ expect(evaluate(odpSegment1Condition, getMockUserContext({}, ['odp-segment-2']))).toBe(false);
+ })
+
+ it('should return null when segment qualifies but unknown match type is provided', () => {
+ const invalidOdpMatchCondition = {
+ ... odpSegment1Condition,
+ "match": 'unknown',
+ };
+ expect(evaluate(invalidOdpMatchCondition, getMockUserContext({}, ['odp-segment-1']))).toBeNull();
+ expect(mockLogger.warn).toHaveBeenCalledTimes(1);
+ expect(mockLogger.warn).toHaveBeenCalledWith(UNKNOWN_MATCH_TYPE, JSON.stringify(invalidOdpMatchCondition));
+ });
+});
diff --git a/packages/optimizely-sdk/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.tests.js b/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.tests.js
similarity index 69%
rename from packages/optimizely-sdk/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.tests.js
rename to lib/core/audience_evaluator/odp_segment_condition_evaluator/index.tests.js
index 503017545..cc9218887 100644
--- a/packages/optimizely-sdk/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.tests.js
+++ b/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.tests.js
@@ -17,12 +17,9 @@ import sinon from 'sinon';
import { assert } from 'chai';
import { sprintf } from '../../../utils/fns';
-import {
- LOG_LEVEL,
- LOG_MESSAGES,
-} from '../../../utils/enums';
-import * as logging from '../../../modules/logging';
+import { LOG_LEVEL } from '../../../utils/enums';
import * as odpSegmentEvalutor from './';
+import { UNKNOWN_MATCH_TYPE } from 'error_message';
var odpSegment1Condition = {
"value": "odp-segment-1",
@@ -36,27 +33,34 @@ var getMockUserContext = (attributes, segments) => ({
isQualifiedFor: segment => segments.indexOf(segment) > -1
});
+var createLogger = () => ({
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => createLogger(),
+})
+
describe('lib/core/audience_evaluator/odp_segment_condition_evaluator', function() {
- var stubLogHandler;
+ const mockLogger = createLogger();
+ const { evaluate } = odpSegmentEvalutor.getEvaluator(mockLogger);
beforeEach(function() {
- stubLogHandler = {
- log: sinon.stub(),
- };
- logging.setLogLevel('notset');
- logging.setLogHandler(stubLogHandler);
+ sinon.stub(mockLogger, 'warn');
+ sinon.stub(mockLogger, 'error');
});
afterEach(function() {
- logging.resetLogger();
+ mockLogger.warn.restore();
+ mockLogger.error.restore();
});
it('should return true when segment qualifies and known match type is provided', () => {
- assert.isTrue(odpSegmentEvalutor.evaluate(odpSegment1Condition, getMockUserContext({}, ['odp-segment-1'])));
+ assert.isTrue(evaluate(odpSegment1Condition, getMockUserContext({}, ['odp-segment-1'])));
});
it('should return false when segment does not qualify and known match type is provided', () => {
- assert.isFalse(odpSegmentEvalutor.evaluate(odpSegment1Condition, getMockUserContext({}, ['odp-segment-2'])));
+ assert.isFalse(evaluate(odpSegment1Condition, getMockUserContext({}, ['odp-segment-2'])));
})
it('should return null when segment qualifies but unknown match type is provided', () => {
@@ -64,10 +68,9 @@ describe('lib/core/audience_evaluator/odp_segment_condition_evaluator', function
... odpSegment1Condition,
"match": 'unknown',
};
- assert.isNull(odpSegmentEvalutor.evaluate(invalidOdpMatchCondition, getMockUserContext({}, ['odp-segment-1'])));
- sinon.assert.calledOnce(stubLogHandler.log);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- var logMessage = stubLogHandler.log.args[0][1];
- assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, 'ODP_SEGMENT_CONDITION_EVALUATOR', JSON.stringify(invalidOdpMatchCondition)));
+ assert.isNull(evaluate(invalidOdpMatchCondition, getMockUserContext({}, ['odp-segment-1'])));
+ sinon.assert.calledOnce(mockLogger.warn);
+ assert.strictEqual(mockLogger.warn.args[0][0], UNKNOWN_MATCH_TYPE);
+ assert.strictEqual(mockLogger.warn.args[0][1], JSON.stringify(invalidOdpMatchCondition));
});
});
diff --git a/packages/optimizely-sdk/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.ts b/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.ts
similarity index 81%
rename from packages/optimizely-sdk/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.ts
rename to lib/core/audience_evaluator/odp_segment_condition_evaluator/index.ts
index 3098ae2b0..7380c9269 100644
--- a/packages/optimizely-sdk/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.ts
+++ b/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.ts
@@ -13,15 +13,10 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
***************************************************************************/
-import { getLogger } from '../../../modules/logging';
+import { UNKNOWN_MATCH_TYPE } from 'error_message';
+import { LoggerFacade } from '../../../logging/logger';
import { Condition, OptimizelyUserContext } from '../../../shared_types';
-import { LOG_MESSAGES } from '../../../utils/enums';
-
-const MODULE_NAME = 'ODP_SEGMENT_CONDITION_EVALUATOR';
-
-const logger = getLogger();
-
const QUALIFIED_MATCH_TYPE = 'qualified';
const MATCH_TYPES = [
@@ -29,10 +24,19 @@ const MATCH_TYPES = [
];
type ConditionEvaluator = (condition: Condition, user: OptimizelyUserContext) => boolean | null;
+type Evaluator = { evaluate: (condition: Condition, user: OptimizelyUserContext) => boolean | null; }
const EVALUATORS_BY_MATCH_TYPE: { [conditionType: string]: ConditionEvaluator | undefined } = {};
EVALUATORS_BY_MATCH_TYPE[QUALIFIED_MATCH_TYPE] = qualifiedEvaluator;
+export const getEvaluator = (logger?: LoggerFacade): Evaluator => {
+ return {
+ evaluate(condition: Condition, user: OptimizelyUserContext): boolean | null {
+ return evaluate(condition, user, logger);
+ }
+ };
+}
+
/**
* Given a custom attribute audience condition and user attributes, evaluate the
* condition against the attributes.
@@ -42,10 +46,10 @@ EVALUATORS_BY_MATCH_TYPE[QUALIFIED_MATCH_TYPE] = qualifiedEvaluator;
* null if the given user attributes and condition can't be evaluated
* TODO: Change to accept and object with named properties
*/
-export function evaluate(condition: Condition, user: OptimizelyUserContext): boolean | null {
+function evaluate(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
const conditionMatch = condition.match;
if (typeof conditionMatch !== 'undefined' && MATCH_TYPES.indexOf(conditionMatch) === -1) {
- logger.warn(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, MODULE_NAME, JSON.stringify(condition));
+ logger?.warn(UNKNOWN_MATCH_TYPE, JSON.stringify(condition));
return null;
}
diff --git a/lib/core/bucketer/bucket_value_generator.spec.ts b/lib/core/bucketer/bucket_value_generator.spec.ts
new file mode 100644
index 000000000..e68db6348
--- /dev/null
+++ b/lib/core/bucketer/bucket_value_generator.spec.ts
@@ -0,0 +1,49 @@
+/**
+ * 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.
+ */
+import { expect, describe, it } from 'vitest';
+import { sprintf } from '../../utils/fns';
+import { generateBucketValue } from './bucket_value_generator';
+import { OptimizelyError } from '../../error/optimizly_error';
+import { INVALID_BUCKETING_ID } from 'error_message';
+
+describe('generateBucketValue', () => {
+ it('should return a bucket value for different inputs', () => {
+ const experimentId = 1886780721;
+ const bucketingKey1 = sprintf('%s%s', 'ppid1', experimentId);
+ const bucketingKey2 = sprintf('%s%s', 'ppid2', experimentId);
+ const bucketingKey3 = sprintf('%s%s', 'ppid2', 1886780722);
+ const bucketingKey4 = sprintf('%s%s', 'ppid3', experimentId);
+
+ expect(generateBucketValue(bucketingKey1)).toBe(5254);
+ expect(generateBucketValue(bucketingKey2)).toBe(4299);
+ expect(generateBucketValue(bucketingKey3)).toBe(2434);
+ expect(generateBucketValue(bucketingKey4)).toBe(5439);
+ });
+
+ it('should return an error if it cannot generate the hash value', () => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ expect(() => generateBucketValue(null)).toThrow(OptimizelyError);
+ try {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ generateBucketValue(null);
+ } catch (err) {
+ expect(err).toBeInstanceOf(OptimizelyError);
+ expect(err.baseMessage).toBe(INVALID_BUCKETING_ID);
+ }
+ });
+});
diff --git a/lib/core/bucketer/bucket_value_generator.ts b/lib/core/bucketer/bucket_value_generator.ts
new file mode 100644
index 000000000..c5f85303b
--- /dev/null
+++ b/lib/core/bucketer/bucket_value_generator.ts
@@ -0,0 +1,40 @@
+/**
+ * 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.
+ */
+import murmurhash from 'murmurhash';
+import { INVALID_BUCKETING_ID } from 'error_message';
+import { OptimizelyError } from '../../error/optimizly_error';
+
+const HASH_SEED = 1;
+const MAX_HASH_VALUE = Math.pow(2, 32);
+const MAX_TRAFFIC_VALUE = 10000;
+
+/**
+ * Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE)
+ * @param {string} bucketingKey String value for bucketing
+ * @return {number} The generated bucket value
+ * @throws If bucketing value is not a valid string
+ */
+export const generateBucketValue = function(bucketingKey: string): number {
+ try {
+ // NOTE: the mmh library already does cast the hash value as an unsigned 32bit int
+ // https://github.com/perezd/node-murmurhash/blob/master/murmurhash.js#L115
+ const hashValue = murmurhash.v3(bucketingKey, HASH_SEED);
+ const ratio = hashValue / MAX_HASH_VALUE;
+ return Math.floor(ratio * MAX_TRAFFIC_VALUE);
+ } catch (ex) {
+ throw new OptimizelyError(INVALID_BUCKETING_ID, bucketingKey, ex.message);
+ }
+};
diff --git a/lib/core/bucketer/index.spec.ts b/lib/core/bucketer/index.spec.ts
new file mode 100644
index 000000000..942295356
--- /dev/null
+++ b/lib/core/bucketer/index.spec.ts
@@ -0,0 +1,402 @@
+/**
+ * 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.
+ */
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { sprintf } from '../../utils/fns';
+import projectConfig, { ProjectConfig } from '../../project_config/project_config';
+import { getTestProjectConfig } from '../../tests/test_data';
+import { INVALID_BUCKETING_ID, INVALID_GROUP_ID } from 'error_message';
+import * as bucketer from './';
+import * as bucketValueGenerator from './bucket_value_generator';
+
+import {
+ USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
+ USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
+ USER_NOT_IN_ANY_EXPERIMENT,
+ USER_ASSIGNED_TO_EXPERIMENT_BUCKET,
+} from '.';
+import { BucketerParams } from '../../shared_types';
+import { OptimizelyError } from '../../error/optimizly_error';
+import { getMockLogger } from '../../tests/mock/mock_logger';
+import { LoggerFacade } from '../../logging/logger';
+
+const testData = getTestProjectConfig();
+
+function cloneDeep(value: T): T {
+ if (value === null || typeof value !== 'object') {
+ return value;
+ }
+
+ if (Array.isArray(value)) {
+ return (value.map(cloneDeep) as unknown) as T;
+ }
+
+ const copy: Record = {};
+
+ for (const key in value) {
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
+ copy[key] = cloneDeep((value as Record)[key]);
+ }
+ }
+
+ return copy as T;
+}
+
+const setLogSpy = (logger: LoggerFacade) => {
+ vi.spyOn(logger, 'info');
+ vi.spyOn(logger, 'debug');
+ vi.spyOn(logger, 'warn');
+ vi.spyOn(logger, 'error');
+};
+
+describe('excluding groups', () => {
+ let configObj;
+ const mockLogger = getMockLogger();
+ let bucketerParams: BucketerParams;
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ configObj = projectConfig.createProjectConfig(cloneDeep(testData));
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ bucketerParams = {
+ experimentId: configObj.experiments[0].id,
+ experimentKey: configObj.experiments[0].key,
+ trafficAllocationConfig: configObj.experiments[0].trafficAllocation,
+ variationIdMap: configObj.variationIdMap,
+ experimentIdMap: configObj.experimentIdMap,
+ groupIdMap: configObj.groupIdMap,
+ logger: mockLogger,
+ validateEntity: true,
+ };
+
+ vi.spyOn(bucketValueGenerator, 'generateBucketValue')
+ .mockReturnValueOnce(50)
+ .mockReturnValueOnce(50000);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return decision response with correct variation ID when provided bucket value', async () => {
+ const bucketerParamsTest1 = cloneDeep(bucketerParams);
+ bucketerParamsTest1.userId = 'ppid1';
+ const decisionResponse = bucketer.bucket(bucketerParamsTest1);
+
+ expect(decisionResponse.result).toBe('111128');
+ expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'ppid1');
+
+ const bucketerParamsTest2 = cloneDeep(bucketerParams);
+ bucketerParamsTest2.userId = 'ppid2';
+ const decisionResponse2 = bucketer.bucket(bucketerParamsTest2);
+
+ expect(decisionResponse2.result).toBeNull();
+ expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'ppid2');
+ });
+});
+
+describe('including groups: random', () => {
+ let configObj: ProjectConfig;
+ const mockLogger = getMockLogger();
+ let bucketerParams: BucketerParams;
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ configObj = projectConfig.createProjectConfig(cloneDeep(testData));
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ bucketerParams = {
+ experimentId: configObj.experiments[4].id,
+ experimentKey: configObj.experiments[4].key,
+ trafficAllocationConfig: configObj.experiments[4].trafficAllocation,
+ variationIdMap: configObj.variationIdMap,
+ experimentIdMap: configObj.experimentIdMap,
+ groupIdMap: configObj.groupIdMap,
+ logger: mockLogger,
+ userId: 'testUser',
+ validateEntity: true,
+ };
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return decision response with the proper variation for a user in a grouped experiment', () => {
+ vi.spyOn(bucketValueGenerator, 'generateBucketValue')
+ .mockReturnValueOnce(50)
+ .mockReturnValueOnce(50);
+
+ const decisionResponse = bucketer.bucket(bucketerParams);
+
+ expect(decisionResponse.result).toBe('551');
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
+ expect(mockLogger.debug).toHaveBeenCalledTimes(2);
+ expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser');
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
+ 'testUser',
+ 'groupExperiment1',
+ '666'
+ );
+ });
+
+ it('should return decision response with variation null when a user is bucketed into a different grouped experiment than the one speicfied', () => {
+ vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValue(5000);
+
+ const decisionResponse = bucketer.bucket(bucketerParams);
+
+ expect(decisionResponse.result).toBeNull();
+ expect(mockLogger.debug).toHaveBeenCalledTimes(1);
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
+ expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser');
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
+ 'testUser',
+ 'groupExperiment1',
+ '666'
+ );
+ });
+
+ it('should return decision response with variation null when a user is not bucketed into any experiments in the random group', () => {
+ vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValue(50000);
+
+ const decisionResponse = bucketer.bucket(bucketerParams);
+
+ expect(decisionResponse.result).toBeNull();
+ expect(mockLogger.debug).toHaveBeenCalledTimes(1);
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
+ expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser');
+ expect(mockLogger.info).toHaveBeenCalledWith(USER_NOT_IN_ANY_EXPERIMENT, 'testUser', '666');
+ });
+
+ it('should return decision response with variation null when a user is bucketed into traffic space of deleted experiment within a random group', () => {
+ vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValueOnce(9000);
+
+ const decisionResponse = bucketer.bucket(bucketerParams);
+
+ expect(decisionResponse.result).toBeNull();
+ expect(mockLogger.debug).toHaveBeenCalledTimes(1);
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
+ expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser');
+ expect(mockLogger.info).toHaveBeenCalledWith(USER_NOT_IN_ANY_EXPERIMENT, 'testUser', '666');
+ });
+
+ it('should throw an error if group ID is not in the datafile', () => {
+ const bucketerParamsWithInvalidGroupId = cloneDeep(bucketerParams);
+ bucketerParamsWithInvalidGroupId.experimentIdMap[configObj.experiments[4].id].groupId = '6969';
+
+ expect(()=> bucketer.bucket(bucketerParamsWithInvalidGroupId)).toThrow(OptimizelyError);
+
+ try {
+ bucketer.bucket(bucketerParamsWithInvalidGroupId);
+ } catch(err) {
+ expect(err).toBeInstanceOf(OptimizelyError);
+ expect(err.baseMessage).toBe(INVALID_GROUP_ID);
+ }
+ });
+});
+
+describe('including groups: overlapping', () => {
+ let configObj: ProjectConfig;
+ const mockLogger = getMockLogger();
+ let bucketerParams: BucketerParams;
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ configObj = projectConfig.createProjectConfig(cloneDeep(testData));
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ bucketerParams = {
+ experimentId: configObj.experiments[6].id,
+ experimentKey: configObj.experiments[6].key,
+ trafficAllocationConfig: configObj.experiments[6].trafficAllocation,
+ variationIdMap: configObj.variationIdMap,
+ experimentIdMap: configObj.experimentIdMap,
+ groupIdMap: configObj.groupIdMap,
+ logger: mockLogger,
+ userId: 'testUser',
+ validateEntity: true,
+ };
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return decision response with variation when a user falls into an experiment within an overlapping group', () => {
+ vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValueOnce(0);
+
+ const decisionResponse = bucketer.bucket(bucketerParams);
+
+ expect(decisionResponse.result).toBe('553');
+ expect(mockLogger.debug).toHaveBeenCalledTimes(1);
+ expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser');
+ });
+
+ it('should return decision response with variation null when a user does not fall into an experiment within an overlapping group', () => {
+ vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValueOnce(3000);
+ const decisionResponse = bucketer.bucket(bucketerParams);
+
+ expect(decisionResponse.result).toBeNull();
+ });
+});
+
+describe('bucket value falls into empty traffic allocation ranges', () => {
+ let configObj: ProjectConfig;
+ const mockLogger = getMockLogger();
+ let bucketerParams: BucketerParams;
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ configObj = projectConfig.createProjectConfig(cloneDeep(testData));
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ bucketerParams = {
+ experimentId: configObj.experiments[0].id,
+ experimentKey: configObj.experiments[0].key,
+ trafficAllocationConfig: [
+ {
+ entityId: '',
+ endOfRange: 5000,
+ },
+ {
+ entityId: '',
+ endOfRange: 10000,
+ },
+ ],
+ variationIdMap: configObj.variationIdMap,
+ experimentIdMap: configObj.experimentIdMap,
+ groupIdMap: configObj.groupIdMap,
+ logger: mockLogger,
+ validateEntity: true,
+ };
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return decision response with variation null', () => {
+ const bucketerParamsTest1 = cloneDeep(bucketerParams);
+ bucketerParamsTest1.userId = 'ppid1';
+ const decisionResponse = bucketer.bucket(bucketerParamsTest1);
+
+ expect(decisionResponse.result).toBeNull();
+ });
+
+ it('should not log an invalid variation ID warning', () => {
+ bucketer.bucket(bucketerParams);
+
+ expect(mockLogger.warn).not.toHaveBeenCalled();
+ });
+});
+
+describe('traffic allocation has invalid variation ids', () => {
+ let configObj: ProjectConfig;
+ const mockLogger = getMockLogger();
+ let bucketerParams: BucketerParams;
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ configObj = projectConfig.createProjectConfig(cloneDeep(testData));
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ //@ts-ignore
+ bucketerParams = {
+ experimentId: configObj.experiments[0].id,
+ experimentKey: configObj.experiments[0].key,
+ trafficAllocationConfig: [
+ {
+ entityId: '-1',
+ endOfRange: 5000,
+ },
+ {
+ entityId: '-2',
+ endOfRange: 10000,
+ },
+ ],
+ variationIdMap: configObj.variationIdMap,
+ experimentIdMap: configObj.experimentIdMap,
+ groupIdMap: configObj.groupIdMap,
+ logger: mockLogger,
+ validateEntity: true,
+ };
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return decision response with variation null', () => {
+ const bucketerParamsTest1 = cloneDeep(bucketerParams);
+ bucketerParamsTest1.userId = 'ppid1';
+ const decisionResponse = bucketer.bucket(bucketerParamsTest1);
+
+ expect(decisionResponse.result).toBeNull();
+ });
+});
+
+
+
+describe('testBucketWithBucketingId', () => {
+ let bucketerParams: BucketerParams;
+
+ beforeEach(() => {
+ const configObj = projectConfig.createProjectConfig(cloneDeep(testData));
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ bucketerParams = {
+ trafficAllocationConfig: configObj.experiments[0].trafficAllocation,
+ variationIdMap: configObj.variationIdMap,
+ experimentIdMap: configObj.experimentIdMap,
+ groupIdMap: configObj.groupIdMap,
+ validateEntity: true,
+ };
+ });
+
+ it('check that a non null bucketingId buckets a variation different than the one expected with userId', () => {
+ const bucketerParams1 = cloneDeep(bucketerParams);
+ bucketerParams1['userId'] = 'testBucketingIdControl';
+ bucketerParams1['bucketingId'] = '123456789';
+ bucketerParams1['experimentKey'] = 'testExperiment';
+ bucketerParams1['experimentId'] = '111127';
+
+ expect(bucketer.bucket(bucketerParams1).result).toBe('111129');
+ });
+
+ it('check that a null bucketing ID defaults to bucketing with the userId', () => {
+ const bucketerParams2 = cloneDeep(bucketerParams);
+ bucketerParams2['userId'] = 'testBucketingIdControl';
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ bucketerParams2['bucketingId'] = null;
+ bucketerParams2['experimentKey'] = 'testExperiment';
+ bucketerParams2['experimentId'] = '111127';
+
+ expect(bucketer.bucket(bucketerParams2).result).toBe('111128');
+ });
+
+ it('check that bucketing works with an experiment in group', () => {
+ const bucketerParams4 = cloneDeep(bucketerParams);
+ bucketerParams4['userId'] = 'testBucketingIdControl';
+ bucketerParams4['bucketingId'] = '123456789';
+ bucketerParams4['experimentKey'] = 'groupExperiment2';
+ bucketerParams4['experimentId'] = '443';
+
+ expect(bucketer.bucket(bucketerParams4).result).toBe('111128');
+ });
+});
diff --git a/packages/optimizely-sdk/lib/core/bucketer/index.tests.js b/lib/core/bucketer/index.tests.js
similarity index 72%
rename from packages/optimizely-sdk/lib/core/bucketer/index.tests.js
rename to lib/core/bucketer/index.tests.js
index 97d261c04..a1e046088 100644
--- a/packages/optimizely-sdk/lib/core/bucketer/index.tests.js
+++ b/lib/core/bucketer/index.tests.js
@@ -1,5 +1,5 @@
/**
- * Copyright 2016-2017, 2019-2022, Optimizely
+ * Copyright 2016-2017, 2019-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.
@@ -15,35 +15,52 @@
*/
import sinon from 'sinon';
import { assert, expect } from 'chai';
-import { cloneDeep } from 'lodash';
+import { cloneDeep, create } from 'lodash';
import { sprintf } from '../../utils/fns';
-
+import * as bucketValueGenerator from './bucket_value_generator'
import * as bucketer from './';
-import {
- ERROR_MESSAGES,
- LOG_MESSAGES,
- LOG_LEVEL,
-} from '../../utils/enums';
-import { createLogger } from '../../plugins/logger';
-import projectConfig from '../project_config';
+import { LOG_LEVEL } from '../../utils/enums';
+import projectConfig from '../../project_config/project_config';
import { getTestProjectConfig } from '../../tests/test_data';
+import { INVALID_BUCKETING_ID, INVALID_GROUP_ID } from 'error_message';
+import {
+ USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
+ USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
+ USER_NOT_IN_ANY_EXPERIMENT,
+ USER_ASSIGNED_TO_EXPERIMENT_BUCKET,
+} from '.';
+import { OptimizelyError } from '../../error/optimizly_error';
var buildLogMessageFromArgs = args => sprintf(args[1], ...args.splice(2));
var testData = getTestProjectConfig();
+var createLogger = () => ({
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => createLogger(),
+})
+
describe('lib/core/bucketer', function () {
describe('APIs', function () {
describe('bucket', function () {
var configObj;
- var createdLogger = createLogger({ logLevel: LOG_LEVEL.INFO });
+ var createdLogger = createLogger();
var bucketerParams;
beforeEach(function () {
- sinon.stub(createdLogger, 'log');
+ sinon.stub(createdLogger, 'info');
+ sinon.stub(createdLogger, 'debug');
+ sinon.stub(createdLogger, 'warn');
+ sinon.stub(createdLogger, 'error');
});
afterEach(function () {
- createdLogger.log.restore();
+ createdLogger.info.restore();
+ createdLogger.debug.restore();
+ createdLogger.warn.restore();
+ createdLogger.error.restore();
});
describe('return values for bucketing (excluding groups)', function () {
@@ -57,9 +74,10 @@ describe('lib/core/bucketer', function () {
experimentIdMap: configObj.experimentIdMap,
groupIdMap: configObj.groupIdMap,
logger: createdLogger,
+ validateEntity: true,
};
sinon
- .stub(bucketer, '_generateBucketValue')
+ .stub(bucketValueGenerator, 'generateBucketValue')
.onFirstCall()
.returns(50)
.onSecondCall()
@@ -67,7 +85,7 @@ describe('lib/core/bucketer', function () {
});
afterEach(function () {
- bucketer._generateBucketValue.restore();
+ bucketValueGenerator.generateBucketValue.restore();
});
it('should return decision response with correct variation ID when provided bucket value', function () {
@@ -76,20 +94,13 @@ describe('lib/core/bucketer', function () {
var decisionResponse = bucketer.bucket(bucketerParamsTest1);
expect(decisionResponse.result).to.equal('111128');
- var bucketedUser_log1 = buildLogMessageFromArgs(createdLogger.log.args[0]);
- expect(bucketedUser_log1).to.equal(
- sprintf(LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 'BUCKETER', '50', 'ppid1')
- );
+ expect(createdLogger.debug.args[0]).to.deep.equal([USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 50, 'ppid1']);
var bucketerParamsTest2 = cloneDeep(bucketerParams);
bucketerParamsTest2.userId = 'ppid2';
expect(bucketer.bucket(bucketerParamsTest2).result).to.equal(null);
- var notBucketedUser_log1 = buildLogMessageFromArgs(createdLogger.log.args[1]);
-
- expect(notBucketedUser_log1).to.equal(
- sprintf(LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 'BUCKETER', '50000', 'ppid2')
- );
+ expect(createdLogger.debug.args[1]).to.deep.equal([USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 50000, 'ppid2']);
});
});
@@ -105,12 +116,13 @@ describe('lib/core/bucketer', function () {
experimentIdMap: configObj.experimentIdMap,
groupIdMap: configObj.groupIdMap,
logger: createdLogger,
+ validateEntity: true,
};
- bucketerStub = sinon.stub(bucketer, '_generateBucketValue');
+ bucketerStub = sinon.stub(bucketValueGenerator, 'generateBucketValue');
});
afterEach(function () {
- bucketer._generateBucketValue.restore();
+ bucketValueGenerator.generateBucketValue.restore();
});
describe('random groups', function () {
@@ -125,6 +137,7 @@ describe('lib/core/bucketer', function () {
groupIdMap: configObj.groupIdMap,
logger: createdLogger,
userId: 'testUser',
+ validateEntity: true,
};
});
@@ -136,28 +149,14 @@ describe('lib/core/bucketer', function () {
expect(decisionResponse.result).to.equal('551');
sinon.assert.calledTwice(bucketerStub);
- sinon.assert.callCount(createdLogger.log, 3);
-
- var log1 = buildLogMessageFromArgs(createdLogger.log.args[0]);
- expect(log1).to.equal(
- sprintf(LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 'BUCKETER', '50', 'testUser')
- );
-
- var log2 = buildLogMessageFromArgs(createdLogger.log.args[1]);
- expect(log2).to.equal(
- sprintf(
- LOG_MESSAGES.USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
- 'BUCKETER',
- 'testUser',
- 'groupExperiment1',
- '666'
- )
- );
-
- var log3 = buildLogMessageFromArgs(createdLogger.log.args[2]);
- expect(log3).to.equal(
- sprintf(LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 'BUCKETER', '50', 'testUser')
- );
+ sinon.assert.callCount(createdLogger.debug, 2);
+ sinon.assert.callCount(createdLogger.info, 1);
+
+ expect(createdLogger.debug.args[0]).to.deep.equal([USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 50, 'testUser']);
+
+ expect(createdLogger.info.args[0]).to.deep.equal([USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP, 'testUser', 'groupExperiment1', '666']);
+
+ expect(createdLogger.debug.args[1]).to.deep.equal([USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 50, 'testUser']);
});
it('should return decision response with variation null when a user is bucketed into a different grouped experiment than the one speicfied', function () {
@@ -167,22 +166,12 @@ describe('lib/core/bucketer', function () {
expect(decisionResponse.result).to.equal(null);
sinon.assert.calledOnce(bucketerStub);
- sinon.assert.calledTwice(createdLogger.log);
-
- var log1 = buildLogMessageFromArgs(createdLogger.log.args[0]);
- expect(log1).to.equal(
- sprintf(LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 'BUCKETER', '5000', 'testUser')
- );
- var log2 = buildLogMessageFromArgs(createdLogger.log.args[1]);
- expect(log2).to.equal(
- sprintf(
- LOG_MESSAGES.USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
- 'BUCKETER',
- 'testUser',
- 'groupExperiment1',
- '666'
- )
- );
+ sinon.assert.calledOnce(createdLogger.debug);
+ sinon.assert.calledOnce(createdLogger.info);
+
+ expect(createdLogger.debug.args[0]).to.deep.equal([USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 5000, 'testUser']);
+
+ expect(createdLogger.info.args[0]).to.deep.equal([USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP, 'testUser', 'groupExperiment1', '666']);
});
it('should return decision response with variation null when a user is not bucketed into any experiments in the random group', function () {
@@ -192,14 +181,12 @@ describe('lib/core/bucketer', function () {
expect(decisionResponse.result).to.equal(null);
sinon.assert.calledOnce(bucketerStub);
- sinon.assert.calledTwice(createdLogger.log);
-
- var log1 = buildLogMessageFromArgs(createdLogger.log.args[0]);
- expect(log1).to.equal(
- sprintf(LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 'BUCKETER', '50000', 'testUser')
- );
- var log2 = buildLogMessageFromArgs(createdLogger.log.args[1]);
- expect(log2).to.equal(sprintf(LOG_MESSAGES.USER_NOT_IN_ANY_EXPERIMENT, 'BUCKETER', 'testUser', '666'));
+ sinon.assert.calledOnce(createdLogger.debug);
+ sinon.assert.calledOnce(createdLogger.info);
+
+ expect(createdLogger.debug.args[0]).to.deep.equal([USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 50000, 'testUser']);
+
+ expect(createdLogger.info.args[0]).to.deep.equal([USER_NOT_IN_ANY_EXPERIMENT, 'testUser', '666']);
});
it('should return decision response with variation null when a user is bucketed into traffic space of deleted experiment within a random group', function () {
@@ -209,23 +196,23 @@ describe('lib/core/bucketer', function () {
expect(decisionResponse.result).to.equal(null);
sinon.assert.calledOnce(bucketerStub);
- sinon.assert.calledTwice(createdLogger.log);
-
- var log1 = buildLogMessageFromArgs(createdLogger.log.args[0]);
- expect(log1).to.equal(
- sprintf(LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 'BUCKETER', '9000', 'testUser')
- );
- var log2 = buildLogMessageFromArgs(createdLogger.log.args[1]);
- expect(log2).to.equal(sprintf(LOG_MESSAGES.USER_NOT_IN_ANY_EXPERIMENT, 'BUCKETER', 'testUser', '666'));
+ sinon.assert.calledOnce(createdLogger.debug);
+ sinon.assert.calledOnce(createdLogger.info);
+
+ expect(createdLogger.debug.args[0]).to.deep.equal([USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 9000, 'testUser']);
+
+ expect(createdLogger.info.args[0]).to.deep.equal([USER_NOT_IN_ANY_EXPERIMENT, 'testUser', '666']);
});
it('should throw an error if group ID is not in the datafile', function () {
var bucketerParamsWithInvalidGroupId = cloneDeep(bucketerParams);
bucketerParamsWithInvalidGroupId.experimentIdMap[configObj.experiments[4].id].groupId = '6969';
- assert.throws(function () {
+ const ex = assert.throws(function () {
bucketer.bucket(bucketerParamsWithInvalidGroupId);
- }, sprintf(ERROR_MESSAGES.INVALID_GROUP_ID, 'BUCKETER', '6969'));
+ });
+ assert.equal(ex.baseMessage, INVALID_GROUP_ID);
+ assert.deepEqual(ex.params, ['6969']);
});
});
@@ -241,6 +228,7 @@ describe('lib/core/bucketer', function () {
groupIdMap: configObj.groupIdMap,
logger: createdLogger,
userId: 'testUser',
+ validateEntity: true,
};
});
@@ -251,10 +239,9 @@ describe('lib/core/bucketer', function () {
expect(decisionResponse.result).to.equal('553');
sinon.assert.calledOnce(bucketerStub);
- sinon.assert.calledOnce(createdLogger.log);
+ sinon.assert.calledOnce(createdLogger.debug);
- var log1 = buildLogMessageFromArgs(createdLogger.log.args[0]);
- expect(log1).to.equal(sprintf(LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 'BUCKETER', '0', 'testUser'));
+ expect(createdLogger.debug.args[0]).to.deep.equal([USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 0, 'testUser']);
});
it('should return decision response with variation null when a user does not fall into an experiment within an overlapping group', function () {
@@ -286,6 +273,7 @@ describe('lib/core/bucketer', function () {
experimentIdMap: configObj.experimentIdMap,
groupIdMap: configObj.groupIdMap,
logger: createdLogger,
+ validateEntity: true,
};
});
@@ -298,8 +286,15 @@ describe('lib/core/bucketer', function () {
it('should not log an invalid variation ID warning', function () {
bucketer.bucket(bucketerParams)
- const foundInvalidVariationWarning = createdLogger.log.getCalls().some((call) => {
- const message = call.args[1];
+ const calls = [
+ ...createdLogger.debug.getCalls(),
+ ...createdLogger.info.getCalls(),
+ ...createdLogger.warn.getCalls(),
+ ...createdLogger.error.getCalls(),
+ ];
+
+ const foundInvalidVariationWarning = calls.some((call) => {
+ const message = call.args[0];
return message.includes('Bucketed into an invalid variation ID')
});
expect(foundInvalidVariationWarning).to.equal(false);
@@ -326,6 +321,7 @@ describe('lib/core/bucketer', function () {
experimentIdMap: configObj.experimentIdMap,
groupIdMap: configObj.groupIdMap,
logger: createdLogger,
+ validateEntity: true,
};
});
@@ -338,7 +334,7 @@ describe('lib/core/bucketer', function () {
});
});
- describe('_generateBucketValue', function () {
+ describe('generateBucketValue', function () {
it('should return a bucket value for different inputs', function () {
var experimentId = 1886780721;
var bucketingKey1 = sprintf('%s%s', 'ppid1', experimentId);
@@ -346,20 +342,17 @@ describe('lib/core/bucketer', function () {
var bucketingKey3 = sprintf('%s%s', 'ppid2', 1886780722);
var bucketingKey4 = sprintf('%s%s', 'ppid3', experimentId);
- expect(bucketer._generateBucketValue(bucketingKey1)).to.equal(5254);
- expect(bucketer._generateBucketValue(bucketingKey2)).to.equal(4299);
- expect(bucketer._generateBucketValue(bucketingKey3)).to.equal(2434);
- expect(bucketer._generateBucketValue(bucketingKey4)).to.equal(5439);
+ expect(bucketValueGenerator.generateBucketValue(bucketingKey1)).to.equal(5254);
+ expect(bucketValueGenerator.generateBucketValue(bucketingKey2)).to.equal(4299);
+ expect(bucketValueGenerator.generateBucketValue(bucketingKey3)).to.equal(2434);
+ expect(bucketValueGenerator.generateBucketValue(bucketingKey4)).to.equal(5439);
});
it('should return an error if it cannot generate the hash value', function() {
const response = assert.throws(function() {
- bucketer._generateBucketValue(null);
+ bucketValueGenerator.generateBucketValue(null);
} );
- expect([
- sprintf(ERROR_MESSAGES.INVALID_BUCKETING_ID, 'BUCKETER', null, "Cannot read property 'length' of null"), // node v14
- sprintf(ERROR_MESSAGES.INVALID_BUCKETING_ID, 'BUCKETER', null, "Cannot read properties of null (reading \'length\')") // node v16
- ]).contain(response.message);
+ expect(response.baseMessage).to.equal(INVALID_BUCKETING_ID);
});
});
@@ -378,6 +371,7 @@ describe('lib/core/bucketer', function () {
experimentIdMap: configObj.experimentIdMap,
groupIdMap: configObj.groupIdMap,
logger: createdLogger,
+ validateEntity: true,
};
});
diff --git a/packages/optimizely-sdk/lib/core/bucketer/index.ts b/lib/core/bucketer/index.ts
similarity index 65%
rename from packages/optimizely-sdk/lib/core/bucketer/index.ts
rename to lib/core/bucketer/index.ts
index c2c6a0235..e31c8df4b 100644
--- a/packages/optimizely-sdk/lib/core/bucketer/index.ts
+++ b/lib/core/bucketer/index.ts
@@ -17,26 +17,23 @@
/**
* Bucketer API for determining the variation id from the specified parameters
*/
-import { sprintf } from '../../utils/fns';
-import murmurhash from 'murmurhash';
-import { LogHandler } from '../../modules/logging';
+import { LoggerFacade } from '../../logging/logger';
import {
DecisionResponse,
BucketerParams,
TrafficAllocation,
Group,
} from '../../shared_types';
+import { INVALID_GROUP_ID } from 'error_message';
+import { OptimizelyError } from '../../error/optimizly_error';
+import { generateBucketValue } from './bucket_value_generator';
+import { DecisionReason } from '../decision_service';
-import {
- ERROR_MESSAGES,
- LOG_LEVEL,
- LOG_MESSAGES,
-} from '../../utils/enums';
-
-const HASH_SEED = 1;
-const MAX_HASH_VALUE = Math.pow(2, 32);
-const MAX_TRAFFIC_VALUE = 10000;
-const MODULE_NAME = 'BUCKETER';
+export const USER_NOT_IN_ANY_EXPERIMENT = 'User %s is not in any experiment of group %s.';
+export const USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP = 'User %s is not in experiment %s of group %s.';
+export const USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP = 'User %s is in experiment %s of group %s.';
+export const USER_ASSIGNED_TO_EXPERIMENT_BUCKET = 'Assigned bucket %s to user with bucketing ID %s.';
+export const INVALID_VARIATION_ID = 'Bucketed into an invalid variation ID. Returning null.';
const RANDOM_POLICY = 'random';
/**
@@ -56,14 +53,15 @@ const RANDOM_POLICY = 'random';
* null if user is not bucketed into any experiment and the decide reasons.
*/
export const bucket = function(bucketerParams: BucketerParams): DecisionResponse {
- const decideReasons: (string | number)[][] = [];
+ const decideReasons: DecisionReason[] = [];
// Check if user is in a random group; if so, check if user is bucketed into a specific experiment
const experiment = bucketerParams.experimentIdMap[bucketerParams.experimentId];
- const groupId = experiment['groupId'];
+ // Optional chaining skips groupId check for holdout experiments; Holdout experimentId is not in experimentIdMap
+ const groupId = experiment?.['groupId'];
if (groupId) {
const group = bucketerParams.groupIdMap[groupId];
if (!group) {
- throw new Error(sprintf(ERROR_MESSAGES.INVALID_GROUP_ID, MODULE_NAME, groupId));
+ throw new OptimizelyError(INVALID_GROUP_ID, groupId);
}
if (group.policy === RANDOM_POLICY) {
const bucketedExperimentId = bucketUserIntoExperiment(
@@ -75,16 +73,13 @@ export const bucket = function(bucketerParams: BucketerParams): DecisionResponse
// Return if user is not bucketed into any experiment
if (bucketedExperimentId === null) {
- bucketerParams.logger.log(
- LOG_LEVEL.INFO,
- LOG_MESSAGES.USER_NOT_IN_ANY_EXPERIMENT,
- MODULE_NAME,
+ bucketerParams.logger?.info(
+ USER_NOT_IN_ANY_EXPERIMENT,
bucketerParams.userId,
groupId,
);
decideReasons.push([
- LOG_MESSAGES.USER_NOT_IN_ANY_EXPERIMENT,
- MODULE_NAME,
+ USER_NOT_IN_ANY_EXPERIMENT,
bucketerParams.userId,
groupId,
]);
@@ -96,17 +91,14 @@ export const bucket = function(bucketerParams: BucketerParams): DecisionResponse
// Return if user is bucketed into a different experiment than the one specified
if (bucketedExperimentId !== bucketerParams.experimentId) {
- bucketerParams.logger.log(
- LOG_LEVEL.INFO,
- LOG_MESSAGES.USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
- MODULE_NAME,
+ bucketerParams.logger?.info(
+ USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
bucketerParams.userId,
bucketerParams.experimentKey,
groupId,
);
decideReasons.push([
- LOG_MESSAGES.USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
- MODULE_NAME,
+ USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
bucketerParams.userId,
bucketerParams.experimentKey,
groupId,
@@ -118,17 +110,14 @@ export const bucket = function(bucketerParams: BucketerParams): DecisionResponse
}
// Continue bucketing if user is bucketed into specified experiment
- bucketerParams.logger.log(
- LOG_LEVEL.INFO,
- LOG_MESSAGES.USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
- MODULE_NAME,
+ bucketerParams.logger?.info(
+ USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
bucketerParams.userId,
bucketerParams.experimentKey,
groupId,
);
decideReasons.push([
- LOG_MESSAGES.USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
- MODULE_NAME,
+ USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
bucketerParams.userId,
bucketerParams.experimentKey,
groupId,
@@ -136,34 +125,30 @@ export const bucket = function(bucketerParams: BucketerParams): DecisionResponse
}
}
const bucketingId = `${bucketerParams.bucketingId}${bucketerParams.experimentId}`;
- const bucketValue = _generateBucketValue(bucketingId);
+ const bucketValue = generateBucketValue(bucketingId);
- bucketerParams.logger.log(
- LOG_LEVEL.DEBUG,
- LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET,
- MODULE_NAME,
+ bucketerParams.logger?.debug(
+ USER_ASSIGNED_TO_EXPERIMENT_BUCKET,
bucketValue,
bucketerParams.userId,
);
decideReasons.push([
- LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET,
- MODULE_NAME,
+ USER_ASSIGNED_TO_EXPERIMENT_BUCKET,
bucketValue,
bucketerParams.userId,
]);
const entityId = _findBucket(bucketValue, bucketerParams.trafficAllocationConfig);
- if (entityId !== null) {
- if (!bucketerParams.variationIdMap[entityId]) {
- if (entityId) {
- bucketerParams.logger.log(LOG_LEVEL.WARNING, LOG_MESSAGES.INVALID_VARIATION_ID, MODULE_NAME);
- decideReasons.push([LOG_MESSAGES.INVALID_VARIATION_ID, MODULE_NAME]);
- }
- return {
- result: null,
- reasons: decideReasons,
- };
+
+ if (bucketerParams.validateEntity && entityId !== null && !bucketerParams.variationIdMap[entityId]) {
+ if (entityId) {
+ bucketerParams.logger?.warn(INVALID_VARIATION_ID);
+ decideReasons.push([INVALID_VARIATION_ID]);
}
+ return {
+ result: null,
+ reasons: decideReasons,
+ };
}
return {
@@ -177,21 +162,19 @@ export const bucket = function(bucketerParams: BucketerParams): DecisionResponse
* @param {Group} group Group that experiment is in
* @param {string} bucketingId Bucketing ID
* @param {string} userId ID of user to be bucketed into experiment
- * @param {LogHandler} logger Logger implementation
+ * @param {LoggerFacade} logger Logger implementation
* @return {string|null} ID of experiment if user is bucketed into experiment within the group, null otherwise
*/
export const bucketUserIntoExperiment = function(
group: Group,
bucketingId: string,
userId: string,
- logger: LogHandler
+ logger?: LoggerFacade
): string | null {
const bucketingKey = `${bucketingId}${group.id}`;
- const bucketValue = _generateBucketValue(bucketingKey);
- logger.log(
- LOG_LEVEL.DEBUG,
- LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET,
- MODULE_NAME,
+ const bucketValue = generateBucketValue(bucketingKey);
+ logger?.debug(
+ USER_ASSIGNED_TO_EXPERIMENT_BUCKET,
bucketValue,
userId,
);
@@ -221,26 +204,7 @@ export const _findBucket = function(
return null;
};
-/**
- * Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE)
- * @param {string} bucketingKey String value for bucketing
- * @return {number} The generated bucket value
- * @throws If bucketing value is not a valid string
- */
-export const _generateBucketValue = function(bucketingKey: string): number {
- try {
- // NOTE: the mmh library already does cast the hash value as an unsigned 32bit int
- // https://github.com/perezd/node-murmurhash/blob/master/murmurhash.js#L115
- const hashValue = murmurhash.v3(bucketingKey, HASH_SEED);
- const ratio = hashValue / MAX_HASH_VALUE;
- return Math.floor(ratio * MAX_TRAFFIC_VALUE);
- } catch (ex: any) {
- throw new Error(sprintf(ERROR_MESSAGES.INVALID_BUCKETING_ID, MODULE_NAME, bucketingKey, ex.message));
- }
-};
-
export default {
bucket: bucket,
bucketUserIntoExperiment: bucketUserIntoExperiment,
- _generateBucketValue: _generateBucketValue,
};
diff --git a/lib/core/condition_tree_evaluator/index.spec.ts b/lib/core/condition_tree_evaluator/index.spec.ts
new file mode 100644
index 000000000..5afdd0d7d
--- /dev/null
+++ b/lib/core/condition_tree_evaluator/index.spec.ts
@@ -0,0 +1,218 @@
+/**
+ * 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.
+ */
+import { describe, it, vi, expect } from 'vitest';
+
+import * as conditionTreeEvaluator from '.';
+
+const conditionA = {
+ name: 'browser_type',
+ value: 'safari',
+ type: 'custom_attribute',
+};
+const conditionB = {
+ name: 'device_model',
+ value: 'iphone6',
+ type: 'custom_attribute',
+};
+const conditionC = {
+ name: 'location',
+ match: 'exact',
+ type: 'custom_attribute',
+ value: 'CA',
+};
+describe('evaluate', function() {
+ it('should return true for a leaf condition when the leaf condition evaluator returns true', function() {
+ expect(
+ conditionTreeEvaluator.evaluate(conditionA, function() {
+ return true;
+ })
+ ).toBe(true);
+ });
+
+ it('should return false for a leaf condition when the leaf condition evaluator returns false', function() {
+ expect(
+ conditionTreeEvaluator.evaluate(conditionA, function() {
+ return false;
+ })
+ ).toBe(false);
+ });
+
+ describe('and evaluation', function() {
+ it('should return true when ALL conditions evaluate to true', function() {
+ expect(
+ conditionTreeEvaluator.evaluate(['and', conditionA, conditionB], function() {
+ return true;
+ })
+ ).toBe(true);
+ });
+
+ it('should return false if one condition evaluates to false', function() {
+ const leafEvaluator = vi.fn();
+ leafEvaluator.mockImplementationOnce(() => true).mockImplementationOnce(() => false);
+ expect(conditionTreeEvaluator.evaluate(['and', conditionA, conditionB], leafEvaluator)).toBe(false);
+ });
+
+ describe('null handling', function() {
+ it('should return null when all operands evaluate to null', function() {
+ expect(
+ conditionTreeEvaluator.evaluate(['and', conditionA, conditionB], function() {
+ return null;
+ })
+ ).toBeNull();
+ });
+
+ it('should return null when operands evaluate to trues and nulls', function() {
+ const leafEvaluator = vi.fn();
+ leafEvaluator.mockImplementationOnce(() => true).mockImplementationOnce(() => null);
+ expect(conditionTreeEvaluator.evaluate(['and', conditionA, conditionB], leafEvaluator)).toBeNull();
+ });
+
+ it('should return false when operands evaluate to falses and nulls', function() {
+ const leafEvaluator = vi.fn();
+ leafEvaluator.mockImplementationOnce(() => false).mockImplementationOnce(() => null);
+ expect(conditionTreeEvaluator.evaluate(['and', conditionA, conditionB], leafEvaluator)).toBe(false);
+
+ leafEvaluator.mockReset();
+ leafEvaluator.mockImplementationOnce(() => null).mockImplementationOnce(() => false);
+ expect(conditionTreeEvaluator.evaluate(['and', conditionA, conditionB], leafEvaluator)).toBe(false);
+ });
+
+ it('should return false when operands evaluate to trues, falses, and nulls', function() {
+ const leafEvaluator = vi.fn();
+ leafEvaluator
+ .mockImplementationOnce(() => true)
+ .mockImplementationOnce(() => false)
+ .mockImplementationOnce(() => null);
+ expect(conditionTreeEvaluator.evaluate(['and', conditionA, conditionB, conditionC], leafEvaluator)).toBe(false);
+ });
+ });
+ });
+
+ describe('or evaluation', function() {
+ it('should return true if any condition evaluates to true', function() {
+ const leafEvaluator = vi.fn();
+ leafEvaluator.mockImplementationOnce(() => false).mockImplementationOnce(() => true);
+ expect(conditionTreeEvaluator.evaluate(['or', conditionA, conditionB], leafEvaluator)).toBe(true);
+ });
+
+ it('should return false if all conditions evaluate to false', function() {
+ expect(
+ conditionTreeEvaluator.evaluate(['or', conditionA, conditionB], function() {
+ return false;
+ })
+ ).toBe(false);
+ });
+
+ describe('null handling', function() {
+ it('should return null when all operands evaluate to null', function() {
+ expect(
+ conditionTreeEvaluator.evaluate(['or', conditionA, conditionB], function() {
+ return null;
+ })
+ ).toBeNull();
+ });
+
+ it('should return true when operands evaluate to trues and nulls', function() {
+ const leafEvaluator = vi.fn();
+ leafEvaluator.mockImplementationOnce(() => true).mockImplementationOnce(() => null);
+ expect(conditionTreeEvaluator.evaluate(['or', conditionA, conditionB], leafEvaluator)).toBe(true);
+ });
+
+ it('should return null when operands evaluate to falses and nulls', function() {
+ const leafEvaluator = vi.fn();
+ leafEvaluator.mockImplementationOnce(() => null).mockImplementationOnce(() => false);
+ expect(conditionTreeEvaluator.evaluate(['or', conditionA, conditionB], leafEvaluator)).toBeNull();
+
+ leafEvaluator.mockReset();
+ leafEvaluator.mockImplementationOnce(() => false).mockImplementationOnce(() => null);
+ expect(conditionTreeEvaluator.evaluate(['or', conditionA, conditionB], leafEvaluator)).toBeNull();
+ });
+
+ it('should return true when operands evaluate to trues, falses, and nulls', function() {
+ const leafEvaluator = vi.fn();
+ leafEvaluator
+ .mockImplementationOnce(() => true)
+ .mockImplementationOnce(() => null)
+ .mockImplementationOnce(() => false);
+ expect(conditionTreeEvaluator.evaluate(['or', conditionA, conditionB, conditionC], leafEvaluator)).toBe(true);
+ });
+ });
+ });
+
+ describe('not evaluation', function() {
+ it('should return true if the condition evaluates to false', function() {
+ expect(
+ conditionTreeEvaluator.evaluate(['not', conditionA], function() {
+ return false;
+ })
+ ).toBe(true);
+ });
+
+ it('should return false if the condition evaluates to true', function() {
+ expect(
+ conditionTreeEvaluator.evaluate(['not', conditionB], function() {
+ return true;
+ })
+ ).toBe(false);
+ });
+
+ it('should return the result of negating the first condition, and ignore any additional conditions', function() {
+ let result = conditionTreeEvaluator.evaluate(['not', '1', '2', '1'], function(id: string) {
+ return id === '1';
+ });
+ expect(result).toBe(false);
+ result = conditionTreeEvaluator.evaluate(['not', '1', '2', '1'], function(id: string) {
+ return id === '2';
+ });
+ expect(result).toBe(true);
+ result = conditionTreeEvaluator.evaluate(['not', '1', '2', '3'], function(id: string) {
+ return id === '1' ? null : id === '3';
+ });
+ expect(result).toBeNull();
+ });
+
+ describe('null handling', function() {
+ it('should return null when operand evaluates to null', function() {
+ expect(
+ conditionTreeEvaluator.evaluate(['not', conditionA], function() {
+ return null;
+ })
+ ).toBeNull();
+ });
+
+ it('should return null when there are no operands', function() {
+ expect(
+ conditionTreeEvaluator.evaluate(['not'], function() {
+ return null;
+ })
+ ).toBeNull();
+ });
+ });
+ });
+
+ describe('implicit operator', function() {
+ it('should behave like an "or" operator when the first item in the array is not a recognized operator', function() {
+ const leafEvaluator = vi.fn();
+ leafEvaluator.mockImplementationOnce(() => true).mockImplementationOnce(() => false);
+ expect(conditionTreeEvaluator.evaluate([conditionA, conditionB], leafEvaluator)).toBe(true);
+ expect(
+ conditionTreeEvaluator.evaluate([conditionA, conditionB], function() {
+ return false;
+ })
+ ).toBe(false);
+ });
+ });
+});
diff --git a/packages/optimizely-sdk/lib/core/condition_tree_evaluator/index.tests.js b/lib/core/condition_tree_evaluator/index.tests.js
similarity index 100%
rename from packages/optimizely-sdk/lib/core/condition_tree_evaluator/index.tests.js
rename to lib/core/condition_tree_evaluator/index.tests.js
diff --git a/packages/optimizely-sdk/lib/core/condition_tree_evaluator/index.ts b/lib/core/condition_tree_evaluator/index.ts
similarity index 100%
rename from packages/optimizely-sdk/lib/core/condition_tree_evaluator/index.ts
rename to lib/core/condition_tree_evaluator/index.ts
diff --git a/lib/core/custom_attribute_condition_evaluator/index.spec.ts b/lib/core/custom_attribute_condition_evaluator/index.spec.ts
new file mode 100644
index 000000000..66f8cae0d
--- /dev/null
+++ b/lib/core/custom_attribute_condition_evaluator/index.spec.ts
@@ -0,0 +1,1411 @@
+/**
+ * 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.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import * as customAttributeEvaluator from './';
+import { MISSING_ATTRIBUTE_VALUE, UNEXPECTED_TYPE_NULL } from 'log_message';
+import { UNKNOWN_MATCH_TYPE, UNEXPECTED_TYPE, OUT_OF_BOUNDS, UNEXPECTED_CONDITION_VALUE } from 'error_message';
+import { Condition } from '../../shared_types';
+import { getMockLogger } from '../../tests/mock/mock_logger';
+import { LoggerFacade } from '../../logging/logger';
+
+const browserConditionSafari = {
+ name: 'browser_type',
+ value: 'safari',
+ type: 'custom_attribute',
+};
+const booleanCondition = {
+ name: 'is_firefox',
+ value: true,
+ type: 'custom_attribute',
+};
+const integerCondition = {
+ name: 'num_users',
+ value: 10,
+ type: 'custom_attribute',
+};
+const doubleCondition = {
+ name: 'pi_value',
+ value: 3.14,
+ type: 'custom_attribute',
+};
+
+const getMockUserContext: any = (attributes: any) => ({
+ getAttributes: () => ({ ...(attributes || {}) }),
+});
+
+const setLogSpy = (logger: LoggerFacade) => {
+ vi.spyOn(logger, 'error');
+ vi.spyOn(logger, 'debug');
+ vi.spyOn(logger, 'info');
+ vi.spyOn(logger, 'warn');
+};
+
+describe('custom_attribute_condition_evaluator', () => {
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return true when the attributes pass the audience conditions and no match type is provided', () => {
+ const userAttributes = {
+ browser_type: 'safari',
+ };
+
+ expect(
+ customAttributeEvaluator.getEvaluator().evaluate(browserConditionSafari, getMockUserContext(userAttributes))
+ ).toBe(true);
+ });
+
+ it('should return false when the attributes do not pass the audience conditions and no match type is provided', () => {
+ const userAttributes = {
+ browser_type: 'firefox',
+ };
+
+ expect(
+ customAttributeEvaluator.getEvaluator().evaluate(browserConditionSafari, getMockUserContext(userAttributes))
+ ).toBe(false);
+ });
+
+ it('should evaluate different typed attributes', () => {
+ const userAttributes = {
+ browser_type: 'safari',
+ is_firefox: true,
+ num_users: 10,
+ pi_value: 3.14,
+ };
+
+ expect(
+ customAttributeEvaluator.getEvaluator().evaluate(browserConditionSafari, getMockUserContext(userAttributes))
+ ).toBe(true);
+ expect(customAttributeEvaluator.getEvaluator().evaluate(booleanCondition, getMockUserContext(userAttributes))).toBe(
+ true
+ );
+ expect(customAttributeEvaluator.getEvaluator().evaluate(integerCondition, getMockUserContext(userAttributes))).toBe(
+ true
+ );
+ expect(customAttributeEvaluator.getEvaluator().evaluate(doubleCondition, getMockUserContext(userAttributes))).toBe(
+ true
+ );
+ });
+
+ it('should log and return null when condition has an invalid match property', () => {
+ const invalidMatchCondition = { match: 'weird', name: 'weird_condition', type: 'custom_attribute', value: 'hi' };
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(invalidMatchCondition, getMockUserContext({ weird_condition: 'bye' }));
+
+ expect(result).toBe(null);
+ expect(mockLogger.warn).toHaveBeenCalledTimes(1);
+ expect(mockLogger.warn).toHaveBeenCalledWith(UNKNOWN_MATCH_TYPE, JSON.stringify(invalidMatchCondition));
+ });
+});
+
+describe('exists match type', () => {
+ const existsCondition = {
+ match: 'exists',
+ name: 'input_value',
+ type: 'custom_attribute',
+ value: '',
+ };
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return false if there is no user-provided value', () => {
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(existsCondition, getMockUserContext({}));
+
+ expect(result).toBe(false);
+ expect(mockLogger.debug).not.toHaveBeenCalled();
+ expect(mockLogger.info).not.toHaveBeenCalled();
+ expect(mockLogger.warn).not.toHaveBeenCalled();
+ expect(mockLogger.error).not.toHaveBeenCalled();
+ });
+
+ it('should return false if the user-provided value is undefined', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator()
+ .evaluate(existsCondition, getMockUserContext({ input_value: undefined }));
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false if the user-provided value is null', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator()
+ .evaluate(existsCondition, getMockUserContext({ input_value: null }));
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true if the user-provided value is a string', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator()
+ .evaluate(existsCondition, getMockUserContext({ input_value: 'hi' }));
+
+ expect(result).toBe(true);
+ });
+
+ it('should return true if the user-provided value is a number', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator()
+ .evaluate(existsCondition, getMockUserContext({ input_value: 10 }));
+
+ expect(result).toBe(true);
+ });
+
+ it('should return true if the user-provided value is a boolean', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator()
+ .evaluate(existsCondition, getMockUserContext({ input_value: true }));
+
+ expect(result).toBe(true);
+ });
+});
+
+describe('exact match type - with a string condition value', () => {
+ const exactStringCondition = {
+ match: 'exact',
+ name: 'favorite_constellation',
+ type: 'custom_attribute',
+ value: 'Lacerta',
+ };
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return true if the user-provided value is equal to the condition value', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator()
+ .evaluate(exactStringCondition, getMockUserContext({ favorite_constellation: 'Lacerta' }));
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false if the user-provided value is not equal to the condition value', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator()
+ .evaluate(exactStringCondition, getMockUserContext({ favorite_constellation: 'The Big Dipper' }));
+
+ expect(result).toBe(false);
+ });
+
+ it('should log and return null if condition value is of an unexpected type', () => {
+ const invalidExactCondition = {
+ match: 'exact',
+ name: 'favorite_constellation',
+ type: 'custom_attribute',
+ value: null,
+ };
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(invalidExactCondition, getMockUserContext({ favorite_constellation: 'Lacerta' }));
+
+ expect(result).toBe(null);
+ expect(mockLogger.warn).toHaveBeenCalledTimes(1);
+ expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidExactCondition));
+ });
+
+ it('should log and return null if the user-provided value is of a different type than the condition value', () => {
+ const unexpectedTypeUserAttributes: Record = { favorite_constellation: false };
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactStringCondition, getMockUserContext(unexpectedTypeUserAttributes));
+ const userValue = unexpectedTypeUserAttributes[exactStringCondition.name];
+ const userValueType = typeof userValue;
+
+ expect(result).toBe(null);
+ expect(mockLogger.warn).toHaveBeenCalledTimes(1);
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(exactStringCondition),
+ userValueType,
+ exactStringCondition.name
+ );
+ });
+
+ it('should log and return null if the user-provided value is null', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactStringCondition, getMockUserContext({ favorite_constellation: null }));
+
+ expect(result).toBe(null);
+ expect(mockLogger.debug).toHaveBeenCalledTimes(1);
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE_NULL,
+ JSON.stringify(exactStringCondition),
+ exactStringCondition.name
+ );
+ });
+
+ it('should log and return null if there is no user-provided value', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactStringCondition, getMockUserContext({}));
+
+ expect(result).toBe(null);
+ expect(mockLogger.debug).toHaveBeenCalledTimes(1);
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ MISSING_ATTRIBUTE_VALUE,
+ JSON.stringify(exactStringCondition),
+ exactStringCondition.name
+ );
+ });
+
+ it('should log and return null if the user-provided value is of an unexpected type', () => {
+ const unexpectedTypeUserAttributes: Record = { favorite_constellation: [] };
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactStringCondition, getMockUserContext(unexpectedTypeUserAttributes));
+ const userValue = unexpectedTypeUserAttributes[exactStringCondition.name];
+ const userValueType = typeof userValue;
+
+ expect(result).toBe(null);
+ expect(mockLogger.warn).toHaveBeenCalledTimes(1);
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(exactStringCondition),
+ userValueType,
+ exactStringCondition.name
+ );
+ });
+});
+
+describe('exact match type - with a number condition value', () => {
+ const exactNumberCondition = {
+ match: 'exact',
+ name: 'lasers_count',
+ type: 'custom_attribute',
+ value: 9000,
+ };
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return true if the user-provided value is equal to the condition value', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactNumberCondition, getMockUserContext({ lasers_count: 9000 }));
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false if the user-provided value is not equal to the condition value', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactNumberCondition, getMockUserContext({ lasers_count: 8000 }));
+
+ expect(result).toBe(false);
+ });
+
+ it('should log and return null if the user-provided value is of a different type than the condition value', () => {
+ const unexpectedTypeUserAttributes1: Record = { lasers_count: 'yes' };
+ let result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactNumberCondition, getMockUserContext(unexpectedTypeUserAttributes1));
+
+ expect(result).toBe(null);
+
+ const unexpectedTypeUserAttributes2: Record = { lasers_count: '1000' };
+ result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactNumberCondition, getMockUserContext(unexpectedTypeUserAttributes2));
+
+ expect(result).toBe(null);
+
+ const userValue1 = unexpectedTypeUserAttributes1[exactNumberCondition.name];
+ const userValueType1 = typeof userValue1;
+ const userValue2 = unexpectedTypeUserAttributes2[exactNumberCondition.name];
+ const userValueType2 = typeof userValue2;
+
+ expect(mockLogger.warn).toHaveBeenCalledTimes(2);
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(exactNumberCondition),
+ userValueType1,
+ exactNumberCondition.name
+ );
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(exactNumberCondition),
+ userValueType2,
+ exactNumberCondition.name
+ );
+ });
+
+ it('should log and return null if the user-provided number value is out of bounds', () => {
+ let result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactNumberCondition, getMockUserContext({ lasers_count: -Infinity }));
+
+ expect(result).toBe(null);
+
+ result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactNumberCondition, getMockUserContext({ lasers_count: -Math.pow(2, 53) - 2 }));
+
+ expect(result).toBe(null);
+ expect(mockLogger.warn).toHaveBeenCalledTimes(2);
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ OUT_OF_BOUNDS,
+ JSON.stringify(exactNumberCondition),
+ exactNumberCondition.name
+ );
+ });
+
+ it('should return null if there is no user-provided value', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactNumberCondition, getMockUserContext({}));
+
+ expect(result).toBe(null);
+ });
+
+ it('should log and return null if the condition value is not finite', () => {
+ const invalidValueCondition1 = {
+ match: 'exact',
+ name: 'lasers_count',
+ type: 'custom_attribute',
+ value: Infinity,
+ };
+ let result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(invalidValueCondition1, getMockUserContext({ lasers_count: 9000 }));
+
+ expect(result).toBe(null);
+
+ const invalidValueCondition2 = {
+ match: 'exact',
+ name: 'lasers_count',
+ type: 'custom_attribute',
+ value: Math.pow(2, 53) + 2,
+ };
+ result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(invalidValueCondition2, getMockUserContext({ lasers_count: 9000 }));
+
+ expect(result).toBe(null);
+ expect(mockLogger.warn).toHaveBeenCalledTimes(2);
+ expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition1));
+ expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition2));
+ });
+});
+
+describe('exact match type - with a boolean condition value', () => {
+ const exactBoolCondition = {
+ match: 'exact',
+ name: 'did_register_user',
+ type: 'custom_attribute',
+ value: false,
+ };
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return true if the user-provided value is equal to the condition value', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactBoolCondition, getMockUserContext({ did_register_user: false }));
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false if the user-provided value is not equal to the condition value', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactBoolCondition, getMockUserContext({ did_register_user: true }));
+
+ expect(result).toBe(false);
+ });
+
+ it('should return null if the user-provided value is of a different type than the condition value', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactBoolCondition, getMockUserContext({ did_register_user: 10 }));
+
+ expect(result).toBe(null);
+ });
+
+ it('should return null if there is no user-provided value', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(exactBoolCondition, getMockUserContext({}));
+
+ expect(result).toBe(null);
+ });
+});
+
+describe('substring match type', () => {
+ const mockLogger = getMockLogger();
+ const substringCondition = {
+ match: 'substring',
+ name: 'headline_text',
+ type: 'custom_attribute',
+ value: 'buy now',
+ };
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return true if the condition value is a substring of the user-provided value', () => {
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ substringCondition,
+ getMockUserContext({
+ headline_text: 'Limited time, buy now!',
+ })
+ );
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false if the user-provided value is not a substring of the condition value', () => {
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ substringCondition,
+ getMockUserContext({
+ headline_text: 'Breaking news!',
+ })
+ );
+
+ expect(result).toBe(false);
+ });
+
+ it('should log and return null if the user-provided value is not a string', () => {
+ const unexpectedTypeUserAttributes: Record = { headline_text: 10 };
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(substringCondition, getMockUserContext(unexpectedTypeUserAttributes));
+ const userValue = unexpectedTypeUserAttributes[substringCondition.name];
+ const userValueType = typeof userValue;
+
+ expect(result).toBe(null);
+ expect(mockLogger.warn).toHaveBeenCalledTimes(1);
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(substringCondition),
+ userValueType,
+ substringCondition.name
+ );
+ });
+
+ it('should log and return null if the condition value is not a string', () => {
+ const nonStringCondition = {
+ match: 'substring',
+ name: 'headline_text',
+ type: 'custom_attribute',
+ value: 10,
+ };
+
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(nonStringCondition, getMockUserContext({ headline_text: 'hello' }));
+
+ expect(result).toBe(null);
+ expect(mockLogger.warn).toHaveBeenCalledTimes(1);
+ expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(nonStringCondition));
+ });
+
+ it('should log and return null if the user-provided value is null', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(substringCondition, getMockUserContext({ headline_text: null }));
+
+ expect(result).toBe(null);
+ expect(mockLogger.debug).toHaveBeenCalledTimes(1);
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE_NULL,
+ JSON.stringify(substringCondition),
+ substringCondition.name
+ );
+ });
+
+ it('should return null if there is no user-provided value', function() {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(substringCondition, getMockUserContext({}));
+
+ expect(result).toBe(null);
+ });
+});
+
+describe('greater than match type', () => {
+ const gtCondition = {
+ match: 'gt',
+ name: 'meters_travelled',
+ type: 'custom_attribute',
+ value: 48.2,
+ };
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return true if the user-provided value is greater than the condition value', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(gtCondition, getMockUserContext({ meters_travelled: 58.4 }));
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false if the user-provided value is not greater than the condition value', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(gtCondition, getMockUserContext({ meters_travelled: 20 }));
+
+ expect(result).toBe(false);
+ });
+
+ it('should log and return null if the user-provided value is not a number', () => {
+ const unexpectedTypeUserAttributes1 = { meters_travelled: 'a long way' };
+ let result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(gtCondition, getMockUserContext(unexpectedTypeUserAttributes1));
+
+ expect(result).toBeNull();
+
+ const unexpectedTypeUserAttributes2 = { meters_travelled: '1000' };
+ result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(gtCondition, getMockUserContext(unexpectedTypeUserAttributes2));
+
+ expect(result).toBeNull();
+ expect(mockLogger.warn).toHaveBeenCalledTimes(2);
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(gtCondition),
+ 'string',
+ gtCondition.name
+ );
+ });
+
+ it('should log and return null if the user-provided number value is out of bounds', () => {
+ let result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(gtCondition, getMockUserContext({ meters_travelled: -Infinity }));
+
+ expect(result).toBeNull();
+
+ result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(gtCondition, getMockUserContext({ meters_travelled: Math.pow(2, 53) + 2 }));
+
+ expect(result).toBeNull();
+ expect(mockLogger.warn).toHaveBeenCalledTimes(2);
+ expect(mockLogger.warn).toHaveBeenCalledWith(OUT_OF_BOUNDS, JSON.stringify(gtCondition), gtCondition.name);
+ });
+
+ it('should log and return null if the user-provided value is null', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(gtCondition, getMockUserContext({ meters_travelled: null }));
+
+ expect(result).toBeNull();
+ expect(mockLogger.debug).toHaveBeenCalledTimes(1);
+ expect(mockLogger.debug).toHaveBeenCalledWith(UNEXPECTED_TYPE_NULL, JSON.stringify(gtCondition), gtCondition.name);
+ });
+
+ it('should return null if there is no user-provided value', () => {
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(gtCondition, getMockUserContext({}));
+
+ expect(result).toBeNull();
+ });
+
+ it('should return null if the condition value is not a finite number', () => {
+ const userAttributes = { meters_travelled: 58.4 };
+ const invalidValueCondition: Condition = {
+ match: 'gt',
+ name: 'meters_travelled',
+ type: 'custom_attribute',
+ value: Infinity,
+ };
+ let result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+
+ expect(result).toBeNull();
+
+ invalidValueCondition.value = null;
+
+ result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+
+ expect(result).toBeNull();
+
+ invalidValueCondition.value = Math.pow(2, 53) + 2;
+ result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+
+ expect(result).toBeNull();
+ expect(mockLogger.warn).toHaveBeenCalledTimes(3);
+ expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition));
+ });
+});
+
+describe('less than match type', () => {
+ const ltCondition = {
+ match: 'lt',
+ name: 'meters_travelled',
+ type: 'custom_attribute',
+ value: 48.2,
+ };
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return true if the user-provided value is less than the condition value', () => {
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ ltCondition,
+ getMockUserContext({
+ meters_travelled: 10,
+ })
+ );
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false if the user-provided value is not less than the condition value', () => {
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ ltCondition,
+ getMockUserContext({
+ meters_travelled: 64.64,
+ })
+ );
+
+ expect(result).toBe(false);
+ });
+
+ it('should log and return null if the user-provided value is not a number', () => {
+ const unexpectedTypeUserAttributes1: Record = { meters_travelled: true };
+ let result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(ltCondition, getMockUserContext(unexpectedTypeUserAttributes1));
+
+ expect(result).toBeNull();
+
+ const unexpectedTypeUserAttributes2: Record = { meters_travelled: '48.2' };
+ result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(ltCondition, getMockUserContext(unexpectedTypeUserAttributes2));
+
+ expect(result).toBeNull();
+
+ const userValue1 = unexpectedTypeUserAttributes1[ltCondition.name];
+ const userValueType1 = typeof userValue1;
+ const userValue2 = unexpectedTypeUserAttributes2[ltCondition.name];
+ const userValueType2 = typeof userValue2;
+
+ expect(mockLogger.warn).toHaveBeenCalledTimes(2);
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(ltCondition),
+ userValueType1,
+ ltCondition.name
+ );
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(ltCondition),
+ userValueType2,
+ ltCondition.name
+ );
+ });
+
+ it('should log and return null if the user-provided number value is out of bounds', () => {
+ let result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(ltCondition, getMockUserContext({ meters_travelled: Infinity }));
+
+ expect(result).toBeNull();
+
+ result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(ltCondition, getMockUserContext({ meters_travelled: Math.pow(2, 53) + 2 }));
+
+ expect(result).toBeNull();
+ expect(mockLogger.warn).toHaveBeenCalledTimes(2);
+ expect(mockLogger.warn).toHaveBeenCalledWith(OUT_OF_BOUNDS, JSON.stringify(ltCondition), ltCondition.name);
+ expect(mockLogger.warn).toHaveBeenCalledWith(OUT_OF_BOUNDS, JSON.stringify(ltCondition), ltCondition.name);
+ });
+
+ it('should log and return null if the user-provided value is null', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(ltCondition, getMockUserContext({ meters_travelled: null }));
+
+ expect(result).toBeNull();
+ expect(mockLogger.debug).toHaveBeenCalledTimes(1);
+ expect(mockLogger.debug).toHaveBeenCalledWith(UNEXPECTED_TYPE_NULL, JSON.stringify(ltCondition), ltCondition.name);
+ });
+
+ it('should return null if there is no user-provided value', () => {
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(ltCondition, getMockUserContext({}));
+
+ expect(result).toBeNull();
+ });
+
+ it('should return null if the condition value is not a finite number', () => {
+ const userAttributes = { meters_travelled: 10 };
+ const invalidValueCondition: Condition = {
+ match: 'lt',
+ name: 'meters_travelled',
+ type: 'custom_attribute',
+ value: Infinity,
+ };
+
+ let result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+
+ expect(result).toBeNull();
+
+ invalidValueCondition.value = null;
+ result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+
+ expect(result).toBeNull();
+
+ invalidValueCondition.value = Math.pow(2, 53) + 2;
+ result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+
+ expect(result).toBeNull();
+ expect(mockLogger.warn).toHaveBeenCalledTimes(3);
+ expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition));
+ });
+});
+
+describe('less than or equal match type', () => {
+ const leCondition = {
+ match: 'le',
+ name: 'meters_travelled',
+ type: 'custom_attribute',
+ value: 48.2,
+ };
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return false if the user-provided value is greater than the condition value', () => {
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ leCondition,
+ getMockUserContext({
+ meters_travelled: 48.3,
+ })
+ );
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true if the user-provided value is less than or equal to the condition value', () => {
+ const versions = [48, 48.2];
+ for (const userValue of versions) {
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ leCondition,
+ getMockUserContext({
+ meters_travelled: userValue,
+ })
+ );
+
+ expect(result).toBe(true);
+ }
+ });
+});
+
+describe('greater than and equal to match type', () => {
+ const geCondition = {
+ match: 'ge',
+ name: 'meters_travelled',
+ type: 'custom_attribute',
+ value: 48.2,
+ };
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return false if the user-provided value is less than the condition value', () => {
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ geCondition,
+ getMockUserContext({
+ meters_travelled: 48,
+ })
+ );
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true if the user-provided value is less than or equal to the condition value', () => {
+ const versions = [100, 48.2];
+ versions.forEach(userValue => {
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ geCondition,
+ getMockUserContext({
+ meters_travelled: userValue,
+ })
+ );
+
+ expect(result).toBe(true);
+ });
+ });
+});
+
+describe('semver greater than match type', () => {
+ const semvergtCondition = {
+ match: 'semver_gt',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: '2.0.0',
+ };
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return true if the user-provided version is greater than the condition version', () => {
+ const versions = [['1.8.1', '1.9']];
+ versions.forEach(([targetVersion, userVersion]) => {
+ const customSemvergtCondition = {
+ match: 'semver_gt',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvergtCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+
+ expect(result).toBe(true);
+ });
+ });
+
+ it('should return false if the user-provided version is not greater than the condition version', function() {
+ const versions = [
+ ['2.0.1', '2.0.1'],
+ ['2.0', '2.0.0'],
+ ['2.0', '2.0.1'],
+ ['2.0.1', '2.0.0'],
+ ];
+ versions.forEach(([targetVersion, userVersion]) => {
+ const customSemvergtCondition = {
+ match: 'semver_gt',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvergtCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+
+ expect(result).toBe(false);
+ });
+ });
+
+ it('should log and return null if the user-provided version is not a string', () => {
+ let result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semvergtCondition,
+ getMockUserContext({
+ app_version: 22,
+ })
+ );
+
+ expect(result).toBe(null);
+
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semvergtCondition,
+ getMockUserContext({
+ app_version: false,
+ })
+ );
+
+ expect(result).toBe(null);
+ expect(mockLogger.warn).toHaveBeenCalledTimes(2);
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(semvergtCondition),
+ 'number',
+ 'app_version'
+ );
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(semvergtCondition),
+ 'boolean',
+ 'app_version'
+ );
+ });
+
+ it('should log and return null if the user-provided value is null', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(semvergtCondition, getMockUserContext({ app_version: null }));
+
+ expect(result).toBe(null);
+ expect(mockLogger.debug).toHaveBeenCalledTimes(1);
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE_NULL,
+ JSON.stringify(semvergtCondition),
+ 'app_version'
+ );
+ });
+
+ it('should return null if there is no user-provided value', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(semvergtCondition, getMockUserContext({}));
+
+ expect(result).toBe(null);
+ });
+});
+
+describe('semver less than match type', () => {
+ const semverltCondition = {
+ match: 'semver_lt',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: '2.0.0',
+ };
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return false if the user-provided version is greater than the condition version', () => {
+ const versions = [
+ ['2.0.0', '2.0.1'],
+ ['1.9', '2.0.0'],
+ ['2.0.0', '2.0.0'],
+ ];
+ versions.forEach(([targetVersion, userVersion]) => {
+ const customSemverltCondition = {
+ match: 'semver_lt',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemverltCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+
+ expect(result).toBe(false);
+ });
+ });
+
+ it('should return true if the user-provided version is less than the condition version', () => {
+ const versions = [
+ ['2.0.1', '2.0.0'],
+ ['2.0.0', '1.9'],
+ ];
+ versions.forEach(([targetVersion, userVersion]) => {
+ const customSemverltCondition = {
+ match: 'semver_lt',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemverltCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+
+ expect(result).toBe(true);
+ });
+ });
+
+ it('should log and return null if the user-provided version is not a string', () => {
+ let result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semverltCondition,
+ getMockUserContext({
+ app_version: 22,
+ })
+ );
+
+ expect(result).toBe(null);
+
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semverltCondition,
+ getMockUserContext({
+ app_version: false,
+ })
+ );
+
+ expect(result).toBe(null);
+ expect(mockLogger.warn).toHaveBeenCalledTimes(2);
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(semverltCondition),
+ 'number',
+ 'app_version'
+ );
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(semverltCondition),
+ 'boolean',
+ 'app_version'
+ );
+ });
+
+ it('should log and return null if the user-provided value is null', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(semverltCondition, getMockUserContext({ app_version: null }));
+
+ expect(result).toBe(null);
+ expect(mockLogger.debug).toHaveBeenCalledTimes(1);
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE_NULL,
+ JSON.stringify(semverltCondition),
+ 'app_version'
+ );
+ });
+
+ it('should return null if there is no user-provided value', function() {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(semverltCondition, getMockUserContext({}));
+
+ expect(result).toBe(null);
+ });
+});
+describe('semver equal to match type', () => {
+ const semvereqCondition = {
+ match: 'semver_eq',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: '2.0',
+ };
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return false if the user-provided version is greater than the condition version', () => {
+ const versions = [
+ ['2.0.0', '2.0.1'],
+ ['2.0.1', '2.0.0'],
+ ['1.9.1', '1.9'],
+ ];
+ versions.forEach(([targetVersion, userVersion]) => {
+ const customSemvereqCondition = {
+ match: 'semver_eq',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvereqCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+
+ expect(result).toBe(false);
+ });
+ });
+
+ it('should return true if the user-provided version is equal to the condition version', () => {
+ const versions = [
+ ['2.0.1', '2.0.1'],
+ ['1.9', '1.9.1'],
+ ];
+ versions.forEach(([targetVersion, userVersion]) => {
+ const customSemvereqCondition = {
+ match: 'semver_eq',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvereqCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+
+ expect(result).toBe(true);
+ });
+ });
+
+ it('should log and return null if the user-provided version is not a string', () => {
+ let result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semvereqCondition,
+ getMockUserContext({
+ app_version: 22,
+ })
+ );
+
+ expect(result).toBe(null);
+
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semvereqCondition,
+ getMockUserContext({
+ app_version: false,
+ })
+ );
+
+ expect(result).toBe(null);
+ expect(mockLogger.warn).toHaveBeenCalledTimes(2);
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(semvereqCondition),
+ 'number',
+ 'app_version'
+ );
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE,
+ JSON.stringify(semvereqCondition),
+ 'boolean',
+ 'app_version'
+ );
+ });
+
+ it('should log and return null if the user-provided value is null', () => {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(semvereqCondition, getMockUserContext({ app_version: null }));
+
+ expect(result).toBe(null);
+ expect(mockLogger.debug).toHaveBeenCalledTimes(1);
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ UNEXPECTED_TYPE_NULL,
+ JSON.stringify(semvereqCondition),
+ 'app_version'
+ );
+ });
+
+ it('should return null if there is no user-provided value', function() {
+ const result = customAttributeEvaluator
+ .getEvaluator(mockLogger)
+ .evaluate(semvereqCondition, getMockUserContext({}));
+
+ expect(result).toBe(null);
+ });
+});
+
+describe('semver less than or equal to match type', () => {
+ const semverleCondition = {
+ match: 'semver_le',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: '2.0.0',
+ };
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return false if the user-provided version is greater than the condition version', () => {
+ const versions = [['2.0.0', '2.0.1']];
+ versions.forEach(([targetVersion, userVersion]) => {
+ const customSemvereqCondition = {
+ match: 'semver_le',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvereqCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+
+ expect(result).toBe(false);
+ });
+ });
+
+ it('should return true if the user-provided version is less than or equal to the condition version', () => {
+ const versions = [
+ ['2.0.1', '2.0.0'],
+ ['2.0.1', '2.0.1'],
+ ['1.9', '1.9.1'],
+ ['1.9.1', '1.9'],
+ ];
+ versions.forEach(([targetVersion, userVersion]) => {
+ const customSemvereqCondition = {
+ match: 'semver_le',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvereqCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+
+ expect(result).toBe(true);
+ });
+ });
+
+ it('should return true if the user-provided version is equal to the condition version', () => {
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semverleCondition,
+ getMockUserContext({
+ app_version: '2.0',
+ })
+ );
+
+ expect(result).toBe(true);
+ });
+});
+
+describe('semver greater than or equal to match type', () => {
+ const mockLogger = getMockLogger();
+
+ beforeEach(() => {
+ setLogSpy(mockLogger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return true if the user-provided version is greater than or equal to the condition version', () => {
+ const versions = [
+ ['2.0.0', '2.0.1'],
+ ['2.0.1', '2.0.1'],
+ ['1.9', '1.9.1'],
+ ];
+ versions.forEach(([targetVersion, userVersion]) => {
+ const customSemvereqCondition = {
+ match: 'semver_ge',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvereqCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+
+ expect(result).toBe(true);
+ });
+ });
+
+ it('should return false if the user-provided version is less than the condition version', () => {
+ const versions = [
+ ['2.0.1', '2.0.0'],
+ ['1.9.1', '1.9'],
+ ];
+ versions.forEach(([targetVersion, userVersion]) => {
+ const customSemvereqCondition = {
+ match: 'semver_ge',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvereqCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.tests.js b/lib/core/custom_attribute_condition_evaluator/index.tests.js
similarity index 57%
rename from packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.tests.js
rename to lib/core/custom_attribute_condition_evaluator/index.tests.js
index b594cf898..12607e001 100644
--- a/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.tests.js
+++ b/lib/core/custom_attribute_condition_evaluator/index.tests.js
@@ -17,12 +17,17 @@ import sinon from 'sinon';
import { assert } from 'chai';
import { sprintf } from '../../utils/fns';
-import {
- LOG_LEVEL,
- LOG_MESSAGES,
-} from '../../utils/enums';
-import * as logging from '../../modules/logging';
import * as customAttributeEvaluator from './';
+import {
+ MISSING_ATTRIBUTE_VALUE,
+ UNEXPECTED_TYPE_NULL,
+} from 'log_message';
+import {
+ UNKNOWN_MATCH_TYPE,
+ UNEXPECTED_TYPE,
+ OUT_OF_BOUNDS,
+ UNEXPECTED_CONDITION_VALUE,
+} from 'error_message';
var browserConditionSafari = {
name: 'browser_type',
@@ -49,19 +54,29 @@ var getMockUserContext = (attributes) => ({
getAttributes: () => ({ ... (attributes || {})})
});
+var createLogger = () => ({
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => createLogger(),
+});
+
describe('lib/core/custom_attribute_condition_evaluator', function() {
- var stubLogHandler;
+ var mockLogger = createLogger();
beforeEach(function() {
- stubLogHandler = {
- log: sinon.stub(),
- };
- logging.setLogLevel('notset');
- logging.setLogHandler(stubLogHandler);
+ sinon.stub(mockLogger, 'error');
+ sinon.stub(mockLogger, 'debug');
+ sinon.stub(mockLogger, 'info');
+ sinon.stub(mockLogger, 'warn');
});
afterEach(function() {
- logging.resetLogger();
+ mockLogger.error.restore();
+ mockLogger.debug.restore();
+ mockLogger.info.restore();
+ mockLogger.warn.restore();
});
it('should return true when the attributes pass the audience conditions and no match type is provided', function() {
@@ -69,7 +84,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
browser_type: 'safari',
};
- assert.isTrue(customAttributeEvaluator.evaluate(browserConditionSafari, getMockUserContext(userAttributes)));
+ assert.isTrue(customAttributeEvaluator.getEvaluator().evaluate(browserConditionSafari, getMockUserContext(userAttributes)));
});
it('should return false when the attributes do not pass the audience conditions and no match type is provided', function() {
@@ -77,7 +92,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
browser_type: 'firefox',
};
- assert.isFalse(customAttributeEvaluator.evaluate(browserConditionSafari, getMockUserContext(userAttributes)));
+ assert.isFalse(customAttributeEvaluator.getEvaluator().evaluate(browserConditionSafari, getMockUserContext(userAttributes)));
});
it('should evaluate different typed attributes', function() {
@@ -88,23 +103,22 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
pi_value: 3.14,
};
- assert.isTrue(customAttributeEvaluator.evaluate(browserConditionSafari, getMockUserContext(userAttributes)));
- assert.isTrue(customAttributeEvaluator.evaluate(booleanCondition, getMockUserContext(userAttributes)));
- assert.isTrue(customAttributeEvaluator.evaluate(integerCondition, getMockUserContext(userAttributes)));
- assert.isTrue(customAttributeEvaluator.evaluate(doubleCondition, getMockUserContext(userAttributes)));
+ assert.isTrue(customAttributeEvaluator.getEvaluator().evaluate(browserConditionSafari, getMockUserContext(userAttributes)));
+ assert.isTrue(customAttributeEvaluator.getEvaluator().evaluate(booleanCondition, getMockUserContext(userAttributes)));
+ assert.isTrue(customAttributeEvaluator.getEvaluator().evaluate(integerCondition, getMockUserContext(userAttributes)));
+ assert.isTrue(customAttributeEvaluator.getEvaluator().evaluate(doubleCondition, getMockUserContext(userAttributes)));
});
it('should log and return null when condition has an invalid match property', function() {
var invalidMatchCondition = { match: 'weird', name: 'weird_condition', type: 'custom_attribute', value: 'hi' };
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
invalidMatchCondition,
getMockUserContext({ weird_condition: 'bye' })
);
assert.isNull(result);
- sinon.assert.calledOnce(stubLogHandler.log);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- var logMessage = stubLogHandler.log.args[0][1];
- assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(invalidMatchCondition)));
+ sinon.assert.calledOnce(mockLogger.warn);
+ assert.strictEqual(mockLogger.warn.args[0][0], UNKNOWN_MATCH_TYPE);
+ assert.strictEqual(mockLogger.warn.args[0][1], JSON.stringify(invalidMatchCondition));
});
describe('exists match type', function() {
@@ -115,33 +129,36 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
};
it('should return false if there is no user-provided value', function() {
- var result = customAttributeEvaluator.evaluate(existsCondition, getMockUserContext({}));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(existsCondition, getMockUserContext({}));
assert.isFalse(result);
- sinon.assert.notCalled(stubLogHandler.log);
+ sinon.assert.notCalled(mockLogger.debug);
+ sinon.assert.notCalled(mockLogger.info);
+ sinon.assert.notCalled(mockLogger.warn);
+ sinon.assert.notCalled(mockLogger.error);
});
it('should return false if the user-provided value is undefined', function() {
- var result = customAttributeEvaluator.evaluate(existsCondition, getMockUserContext({ input_value: undefined }));
+ var result = customAttributeEvaluator.getEvaluator().evaluate(existsCondition, getMockUserContext({ input_value: undefined }));
assert.isFalse(result);
});
it('should return false if the user-provided value is null', function() {
- var result = customAttributeEvaluator.evaluate(existsCondition, getMockUserContext({ input_value: null }));
+ var result = customAttributeEvaluator.getEvaluator().evaluate(existsCondition, getMockUserContext({ input_value: null }));
assert.isFalse(result);
});
it('should return true if the user-provided value is a string', function() {
- var result = customAttributeEvaluator.evaluate(existsCondition, getMockUserContext({ input_value: 'hi' }));
+ var result = customAttributeEvaluator.getEvaluator().evaluate(existsCondition, getMockUserContext({ input_value: 'hi' }));
assert.isTrue(result);
});
it('should return true if the user-provided value is a number', function() {
- var result = customAttributeEvaluator.evaluate(existsCondition, getMockUserContext({ input_value: 10 }));
+ var result = customAttributeEvaluator.getEvaluator().evaluate(existsCondition, getMockUserContext({ input_value: 10 }));
assert.isTrue(result);
});
it('should return true if the user-provided value is a boolean', function() {
- var result = customAttributeEvaluator.evaluate(existsCondition, getMockUserContext({ input_value: true }));
+ var result = customAttributeEvaluator.getEvaluator().evaluate(existsCondition, getMockUserContext({ input_value: true }));
assert.isTrue(result);
});
});
@@ -156,7 +173,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
};
it('should return true if the user-provided value is equal to the condition value', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator().evaluate(
exactStringCondition,
getMockUserContext({ favorite_constellation: 'Lacerta' })
);
@@ -164,7 +181,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
});
it('should return false if the user-provided value is not equal to the condition value', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator().evaluate(
exactStringCondition,
getMockUserContext({ favorite_constellation: 'The Big Dipper' })
);
@@ -178,20 +195,19 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: [],
};
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
invalidExactCondition,
getMockUserContext({ favorite_constellation: 'Lacerta' })
);
assert.isNull(result);
- sinon.assert.calledOnce(stubLogHandler.log);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- var logMessage = stubLogHandler.log.args[0][1];
- assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.UNEXPECTED_CONDITION_VALUE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(invalidExactCondition)));
+ sinon.assert.calledOnce(mockLogger.warn);
+ assert.strictEqual(mockLogger.warn.args[0][0], UNEXPECTED_CONDITION_VALUE);
+ assert.strictEqual(mockLogger.warn.args[0][1], JSON.stringify(invalidExactCondition));
});
it('should log and return null if the user-provided value is of a different type than the condition value', function() {
var unexpectedTypeUserAttributes = { favorite_constellation: false };
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
exactStringCondition,
getMockUserContext(unexpectedTypeUserAttributes)
);
@@ -199,58 +215,42 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
var userValue = unexpectedTypeUserAttributes[exactStringCondition.name];
var userValueType = typeof userValue;
- sinon.assert.calledOnce(stubLogHandler.log);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- var logMessage = stubLogHandler.log.args[0][1];
- assert.strictEqual(
- logMessage,
- sprintf(LOG_MESSAGES.UNEXPECTED_TYPE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(exactStringCondition), userValueType, exactStringCondition.name)
- );
+ sinon.assert.calledOnce(mockLogger.warn);
+
+ assert.deepEqual(mockLogger.warn.args[0], [UNEXPECTED_TYPE, JSON.stringify(exactStringCondition), userValueType, exactStringCondition.name]);
});
it('should log and return null if the user-provided value is null', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
exactStringCondition,
getMockUserContext({ favorite_constellation: null })
);
assert.isNull(result);
- sinon.assert.calledOnce(stubLogHandler.log);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.DEBUG);
- var logMessage = stubLogHandler.log.args[0][1];
- assert.strictEqual(
- logMessage,
- sprintf(LOG_MESSAGES.UNEXPECTED_TYPE_NULL, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(exactStringCondition), exactStringCondition.name)
- );
+ sinon.assert.calledOnce(mockLogger.debug);
+
+ assert.deepEqual(mockLogger.debug.args[0], [UNEXPECTED_TYPE_NULL, JSON.stringify(exactStringCondition), exactStringCondition.name]);
});
it('should log and return null if there is no user-provided value', function() {
- var result = customAttributeEvaluator.evaluate(exactStringCondition, getMockUserContext({}));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(exactStringCondition, getMockUserContext({}));
assert.isNull(result);
- sinon.assert.calledOnce(stubLogHandler.log);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.DEBUG);
- var logMessage = stubLogHandler.log.args[0][1];
- assert.strictEqual(
- logMessage,
- sprintf(LOG_MESSAGES.MISSING_ATTRIBUTE_VALUE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(exactStringCondition), exactStringCondition.name)
- );
+ sinon.assert.calledOnce(mockLogger.debug);
+
+ assert.deepEqual(mockLogger.debug.args[0], [MISSING_ATTRIBUTE_VALUE, JSON.stringify(exactStringCondition), exactStringCondition.name]);
});
it('should log and return null if the user-provided value is of an unexpected type', function() {
var unexpectedTypeUserAttributes = { favorite_constellation: [] };
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
exactStringCondition,
getMockUserContext(unexpectedTypeUserAttributes)
);
assert.isNull(result);
var userValue = unexpectedTypeUserAttributes[exactStringCondition.name];
var userValueType = typeof userValue;
- sinon.assert.calledOnce(stubLogHandler.log);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- var logMessage = stubLogHandler.log.args[0][1];
- assert.strictEqual(
- logMessage,
- sprintf(LOG_MESSAGES.UNEXPECTED_TYPE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(exactStringCondition), userValueType, exactStringCondition.name)
- );
+ sinon.assert.calledOnce(mockLogger.warn);
+
+ assert.deepEqual(mockLogger.warn.args[0], [UNEXPECTED_TYPE, JSON.stringify(exactStringCondition), userValueType, exactStringCondition.name]);
});
});
@@ -263,25 +263,25 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
};
it('should return true if the user-provided value is equal to the condition value', function() {
- var result = customAttributeEvaluator.evaluate(exactNumberCondition, getMockUserContext({ lasers_count: 9000 }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(exactNumberCondition, getMockUserContext({ lasers_count: 9000 }));
assert.isTrue(result);
});
it('should return false if the user-provided value is not equal to the condition value', function() {
- var result = customAttributeEvaluator.evaluate(exactNumberCondition, getMockUserContext({ lasers_count: 8000 }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(exactNumberCondition, getMockUserContext({ lasers_count: 8000 }));
assert.isFalse(result);
});
it('should log and return null if the user-provided value is of a different type than the condition value', function() {
var unexpectedTypeUserAttributes1 = { lasers_count: 'yes' };
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
exactNumberCondition,
getMockUserContext(unexpectedTypeUserAttributes1)
);
assert.isNull(result);
var unexpectedTypeUserAttributes2 = { lasers_count: '1000' };
- result = customAttributeEvaluator.evaluate(
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
exactNumberCondition,
getMockUserContext(unexpectedTypeUserAttributes2)
);
@@ -291,50 +291,31 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
var userValueType1 = typeof userValue1;
var userValue2 = unexpectedTypeUserAttributes2[exactNumberCondition.name];
var userValueType2 = typeof userValue2;
- assert.strictEqual(2, stubLogHandler.log.callCount);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- assert.strictEqual(stubLogHandler.log.args[1][0], LOG_LEVEL.WARNING);
-
- var logMessage1 = stubLogHandler.log.args[0][1];
- var logMessage2 = stubLogHandler.log.args[1][1];
- assert.strictEqual(
- logMessage1,
- sprintf(LOG_MESSAGES.UNEXPECTED_TYPE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(exactNumberCondition), userValueType1, exactNumberCondition.name)
- );
- assert.strictEqual(
- logMessage2,
- sprintf(LOG_MESSAGES.UNEXPECTED_TYPE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(exactNumberCondition), userValueType2, exactNumberCondition.name)
- );
+ assert.strictEqual(2, mockLogger.warn.callCount);
+
+ assert.deepEqual(mockLogger.warn.args[0], [UNEXPECTED_TYPE, JSON.stringify(exactNumberCondition), userValueType1, exactNumberCondition.name]);
+ assert.deepEqual(mockLogger.warn.args[1], [UNEXPECTED_TYPE, JSON.stringify(exactNumberCondition), userValueType2, exactNumberCondition.name]);
});
it('should log and return null if the user-provided number value is out of bounds', function() {
- var result = customAttributeEvaluator.evaluate(exactNumberCondition, getMockUserContext({ lasers_count: -Infinity }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(exactNumberCondition, getMockUserContext({ lasers_count: -Infinity }));
assert.isNull(result);
- result = customAttributeEvaluator.evaluate(
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
exactNumberCondition,
getMockUserContext({ lasers_count: -Math.pow(2, 53) - 2 })
);
assert.isNull(result);
- assert.strictEqual(2, stubLogHandler.log.callCount);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- assert.strictEqual(stubLogHandler.log.args[1][0], LOG_LEVEL.WARNING);
+ assert.strictEqual(2, mockLogger.warn.callCount);
- var logMessage1 = stubLogHandler.log.args[0][1];
- var logMessage2 = stubLogHandler.log.args[1][1];
- assert.strictEqual(
- logMessage1,
- sprintf(LOG_MESSAGES.OUT_OF_BOUNDS, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(exactNumberCondition), exactNumberCondition.name)
- );
- assert.strictEqual(
- logMessage2,
- sprintf(LOG_MESSAGES.OUT_OF_BOUNDS, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(exactNumberCondition), exactNumberCondition.name)
- );
+ assert.deepEqual(mockLogger.warn.args[0], [OUT_OF_BOUNDS, JSON.stringify(exactNumberCondition), exactNumberCondition.name]);
+
+ assert.deepEqual(mockLogger.warn.args[1], [OUT_OF_BOUNDS, JSON.stringify(exactNumberCondition), exactNumberCondition.name]);
});
it('should return null if there is no user-provided value', function() {
- var result = customAttributeEvaluator.evaluate(exactNumberCondition, getMockUserContext({}));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(exactNumberCondition, getMockUserContext({}));
assert.isNull(result);
});
@@ -345,7 +326,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: Infinity,
};
- var result = customAttributeEvaluator.evaluate(invalidValueCondition1, getMockUserContext({ lasers_count: 9000 }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition1, getMockUserContext({ lasers_count: 9000 }));
assert.isNull(result);
var invalidValueCondition2 = {
@@ -354,23 +335,14 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: Math.pow(2, 53) + 2,
};
- result = customAttributeEvaluator.evaluate(invalidValueCondition2, getMockUserContext({ lasers_count: 9000 }));
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition2, getMockUserContext({ lasers_count: 9000 }));
assert.isNull(result);
- assert.strictEqual(2, stubLogHandler.log.callCount);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- assert.strictEqual(stubLogHandler.log.args[1][0], LOG_LEVEL.WARNING);
+ assert.strictEqual(2, mockLogger.warn.callCount);
- var logMessage1 = stubLogHandler.log.args[0][1];
- var logMessage2 = stubLogHandler.log.args[1][1];
- assert.strictEqual(
- logMessage1,
- sprintf(LOG_MESSAGES.UNEXPECTED_CONDITION_VALUE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(invalidValueCondition1))
- );
- assert.strictEqual(
- logMessage2,
- sprintf(LOG_MESSAGES.UNEXPECTED_CONDITION_VALUE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(invalidValueCondition2))
- );
+ assert.deepEqual(mockLogger.warn.args[0], [UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition1)]);
+
+ assert.deepEqual(mockLogger.warn.args[1], [UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition2)]);
});
});
@@ -383,22 +355,22 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
};
it('should return true if the user-provided value is equal to the condition value', function() {
- var result = customAttributeEvaluator.evaluate(exactBoolCondition, getMockUserContext({ did_register_user: false }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(exactBoolCondition, getMockUserContext({ did_register_user: false }));
assert.isTrue(result);
});
it('should return false if the user-provided value is not equal to the condition value', function() {
- var result = customAttributeEvaluator.evaluate(exactBoolCondition, getMockUserContext({ did_register_user: true }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(exactBoolCondition, getMockUserContext({ did_register_user: true }));
assert.isFalse(result);
});
it('should return null if the user-provided value is of a different type than the condition value', function() {
- var result = customAttributeEvaluator.evaluate(exactBoolCondition, getMockUserContext({ did_register_user: 10 }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(exactBoolCondition, getMockUserContext({ did_register_user: 10 }));
assert.isNull(result);
});
it('should return null if there is no user-provided value', function() {
- var result = customAttributeEvaluator.evaluate(exactBoolCondition, getMockUserContext({}));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(exactBoolCondition, getMockUserContext({}));
assert.isNull(result);
});
});
@@ -413,7 +385,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
};
it('should return true if the condition value is a substring of the user-provided value', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
substringCondition,
getMockUserContext({
headline_text: 'Limited time, buy now!',
@@ -423,7 +395,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
});
it('should return false if the user-provided value is not a substring of the condition value', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
substringCondition,
getMockUserContext({
headline_text: 'Breaking news!',
@@ -434,20 +406,16 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
it('should log and return null if the user-provided value is not a string', function() {
var unexpectedTypeUserAttributes = { headline_text: 10 };
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
substringCondition,
getMockUserContext(unexpectedTypeUserAttributes)
);
assert.isNull(result);
var userValue = unexpectedTypeUserAttributes[substringCondition.name];
var userValueType = typeof userValue;
- sinon.assert.calledOnce(stubLogHandler.log);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- var logMessage = stubLogHandler.log.args[0][1];
- assert.strictEqual(
- logMessage,
- sprintf(LOG_MESSAGES.UNEXPECTED_TYPE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(substringCondition), userValueType, substringCondition.name)
- );
+ sinon.assert.calledOnce(mockLogger.warn);
+
+ assert.deepEqual(mockLogger.warn.args[0], [UNEXPECTED_TYPE, JSON.stringify(substringCondition), userValueType, substringCondition.name]);
});
it('should log and return null if the condition value is not a string', function() {
@@ -458,31 +426,23 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
value: 10,
};
- var result = customAttributeEvaluator.evaluate(nonStringCondition, getMockUserContext({ headline_text: 'hello' }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(nonStringCondition, getMockUserContext({ headline_text: 'hello' }));
assert.isNull(result);
- sinon.assert.calledOnce(stubLogHandler.log);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- var logMessage = stubLogHandler.log.args[0][1];
- assert.strictEqual(
- logMessage,
- sprintf(LOG_MESSAGES.UNEXPECTED_CONDITION_VALUE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(nonStringCondition))
- );
+ sinon.assert.calledOnce(mockLogger.warn);
+
+ assert.deepEqual(mockLogger.warn.args[0], [UNEXPECTED_CONDITION_VALUE, JSON.stringify(nonStringCondition)]);
});
it('should log and return null if the user-provided value is null', function() {
- var result = customAttributeEvaluator.evaluate(substringCondition, getMockUserContext({ headline_text: null }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(substringCondition, getMockUserContext({ headline_text: null }));
assert.isNull(result);
- sinon.assert.calledOnce(stubLogHandler.log);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.DEBUG);
- var logMessage = stubLogHandler.log.args[0][1];
- assert.strictEqual(
- logMessage,
- sprintf(LOG_MESSAGES.UNEXPECTED_TYPE_NULL, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(substringCondition), substringCondition.name)
- );
+ sinon.assert.calledOnce(mockLogger.debug);
+
+ assert.deepEqual(mockLogger.debug.args[0], [UNEXPECTED_TYPE_NULL, JSON.stringify(substringCondition), substringCondition.name]);
});
it('should return null if there is no user-provided value', function() {
- var result = customAttributeEvaluator.evaluate(substringCondition, getMockUserContext({}));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(substringCondition, getMockUserContext({}));
assert.isNull(result);
});
});
@@ -496,7 +456,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
};
it('should return true if the user-provided value is greater than the condition value', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
gtCondition,
getMockUserContext({
meters_travelled: 58.4,
@@ -506,7 +466,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
});
it('should return false if the user-provided value is not greater than the condition value', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
gtCondition,
getMockUserContext({
meters_travelled: 20,
@@ -517,14 +477,14 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
it('should log and return null if the user-provided value is not a number', function() {
var unexpectedTypeUserAttributes1 = { meters_travelled: 'a long way' };
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
gtCondition,
getMockUserContext(unexpectedTypeUserAttributes1)
);
assert.isNull(result);
var unexpectedTypeUserAttributes2 = { meters_travelled: '1000' };
- result = customAttributeEvaluator.evaluate(
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
gtCondition,
getMockUserContext(unexpectedTypeUserAttributes2)
);
@@ -534,65 +494,43 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
var userValueType1 = typeof userValue1;
var userValue2 = unexpectedTypeUserAttributes2[gtCondition.name];
var userValueType2 = typeof userValue2;
- assert.strictEqual(2, stubLogHandler.log.callCount);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- assert.strictEqual(stubLogHandler.log.args[1][0], LOG_LEVEL.WARNING);
-
- var logMessage1 = stubLogHandler.log.args[0][1];
- var logMessage2 = stubLogHandler.log.args[1][1];
- assert.strictEqual(
- logMessage1,
- sprintf(LOG_MESSAGES.UNEXPECTED_TYPE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(gtCondition), userValueType1, gtCondition.name)
- );
- assert.strictEqual(
- logMessage2,
- sprintf(LOG_MESSAGES.UNEXPECTED_TYPE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(gtCondition), userValueType2, gtCondition.name)
- );
+ assert.strictEqual(2, mockLogger.warn.callCount);
+
+ assert.deepEqual(mockLogger.warn.args[0], [UNEXPECTED_TYPE, JSON.stringify(gtCondition), userValueType1, gtCondition.name]);
+
+ assert.deepEqual(mockLogger.warn.args[1], [UNEXPECTED_TYPE, JSON.stringify(gtCondition), userValueType2, gtCondition.name]);
});
it('should log and return null if the user-provided number value is out of bounds', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
gtCondition,
getMockUserContext({ meters_travelled: -Infinity })
);
assert.isNull(result);
- result = customAttributeEvaluator.evaluate(
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
gtCondition,
getMockUserContext({ meters_travelled: Math.pow(2, 53) + 2 })
);
assert.isNull(result);
- assert.strictEqual(2, stubLogHandler.log.callCount);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- assert.strictEqual(stubLogHandler.log.args[1][0], LOG_LEVEL.WARNING);
+ assert.strictEqual(2, mockLogger.warn.callCount);
- var logMessage1 = stubLogHandler.log.args[0][1];
- var logMessage2 = stubLogHandler.log.args[1][1];
- assert.strictEqual(
- logMessage1,
- sprintf(LOG_MESSAGES.OUT_OF_BOUNDS, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(gtCondition), gtCondition.name)
- );
- assert.strictEqual(
- logMessage2,
- sprintf(LOG_MESSAGES.OUT_OF_BOUNDS, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(gtCondition), gtCondition.name)
- );
+ assert.deepEqual(mockLogger.warn.args[0], [OUT_OF_BOUNDS, JSON.stringify(gtCondition), gtCondition.name]);
+
+ assert.deepEqual(mockLogger.warn.args[1], [OUT_OF_BOUNDS, JSON.stringify(gtCondition), gtCondition.name]);
});
it('should log and return null if the user-provided value is null', function() {
- var result = customAttributeEvaluator.evaluate(gtCondition, getMockUserContext({ meters_travelled: null }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(gtCondition, getMockUserContext({ meters_travelled: null }));
assert.isNull(result);
- sinon.assert.calledOnce(stubLogHandler.log);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.DEBUG);
- var logMessage = stubLogHandler.log.args[0][1];
- assert.strictEqual(
- logMessage,
- sprintf(LOG_MESSAGES.UNEXPECTED_TYPE_NULL, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(gtCondition), gtCondition.name)
- );
+ sinon.assert.calledOnce(mockLogger.debug);
+
+ assert.deepEqual(mockLogger.debug.args[0], [UNEXPECTED_TYPE_NULL, JSON.stringify(gtCondition), gtCondition.name]);
});
it('should return null if there is no user-provided value', function() {
- var result = customAttributeEvaluator.evaluate(gtCondition, getMockUserContext({}));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(gtCondition, getMockUserContext({}));
assert.isNull(result);
});
@@ -604,23 +542,20 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: Infinity,
};
- var result = customAttributeEvaluator.evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition, getMockUserContext(userAttributes));
assert.isNull(result);
invalidValueCondition.value = null;
- result = customAttributeEvaluator.evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition, getMockUserContext(userAttributes));
assert.isNull(result);
invalidValueCondition.value = Math.pow(2, 53) + 2;
- result = customAttributeEvaluator.evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition, getMockUserContext(userAttributes));
assert.isNull(result);
- sinon.assert.calledThrice(stubLogHandler.log);
- var logMessage = stubLogHandler.log.args[2][1];
- assert.strictEqual(
- logMessage,
- sprintf(LOG_MESSAGES.UNEXPECTED_CONDITION_VALUE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(invalidValueCondition))
- );
+ sinon.assert.calledThrice(mockLogger.warn);
+
+ assert.deepEqual(mockLogger.warn.args[2], [UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition)]);
});
});
@@ -633,7 +568,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
};
it('should return true if the user-provided value is less than the condition value', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
ltCondition,
getMockUserContext({
meters_travelled: 10,
@@ -643,7 +578,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
});
it('should return false if the user-provided value is not less than the condition value', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
ltCondition,
getMockUserContext({
meters_travelled: 64.64,
@@ -654,14 +589,14 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
it('should log and return null if the user-provided value is not a number', function() {
var unexpectedTypeUserAttributes1 = { meters_travelled: true };
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
ltCondition,
getMockUserContext(unexpectedTypeUserAttributes1)
);
assert.isNull(result);
var unexpectedTypeUserAttributes2 = { meters_travelled: '48.2' };
- result = customAttributeEvaluator.evaluate(
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
ltCondition,
getMockUserContext(unexpectedTypeUserAttributes2)
);
@@ -671,24 +606,14 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
var userValueType1 = typeof userValue1;
var userValue2 = unexpectedTypeUserAttributes2[ltCondition.name];
var userValueType2 = typeof userValue2;
- assert.strictEqual(2, stubLogHandler.log.callCount);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- assert.strictEqual(stubLogHandler.log.args[1][0], LOG_LEVEL.WARNING);
-
- var logMessage1 = stubLogHandler.log.args[0][1];
- var logMessage2 = stubLogHandler.log.args[1][1];
- assert.strictEqual(
- logMessage1,
- sprintf(LOG_MESSAGES.UNEXPECTED_TYPE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(ltCondition), userValueType1, ltCondition.name)
- );
- assert.strictEqual(
- logMessage2,
- sprintf(LOG_MESSAGES.UNEXPECTED_TYPE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(ltCondition), userValueType2, ltCondition.name)
- );
+
+ assert.strictEqual(2, mockLogger.warn.callCount);
+ assert.deepEqual(mockLogger.warn.args[0], [UNEXPECTED_TYPE, JSON.stringify(ltCondition), userValueType1, ltCondition.name]);
+ assert.deepEqual(mockLogger.warn.args[1], [UNEXPECTED_TYPE, JSON.stringify(ltCondition), userValueType2, ltCondition.name]);
});
it('should log and return null if the user-provided number value is out of bounds', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
ltCondition,
getMockUserContext({
meters_travelled: Infinity,
@@ -696,7 +621,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
);
assert.isNull(result);
- result = customAttributeEvaluator.evaluate(
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
ltCondition,
getMockUserContext({
meters_travelled: Math.pow(2, 53) + 2,
@@ -704,36 +629,23 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
);
assert.isNull(result);
- assert.strictEqual(2, stubLogHandler.log.callCount);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
- assert.strictEqual(stubLogHandler.log.args[1][0], LOG_LEVEL.WARNING);
+ assert.strictEqual(2, mockLogger.warn.callCount);
- var logMessage1 = stubLogHandler.log.args[0][1];
- var logMessage2 = stubLogHandler.log.args[1][1];
- assert.strictEqual(
- logMessage1,
- sprintf(LOG_MESSAGES.OUT_OF_BOUNDS, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(ltCondition), ltCondition.name)
- );
- assert.strictEqual(
- logMessage2,
- sprintf(LOG_MESSAGES.OUT_OF_BOUNDS, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(ltCondition), ltCondition.name)
- );
+ assert.deepEqual(mockLogger.warn.args[0], [OUT_OF_BOUNDS, JSON.stringify(ltCondition), ltCondition.name]);
+
+ assert.deepEqual(mockLogger.warn.args[1], [OUT_OF_BOUNDS, JSON.stringify(ltCondition), ltCondition.name]);
});
it('should log and return null if the user-provided value is null', function() {
- var result = customAttributeEvaluator.evaluate(ltCondition, getMockUserContext({ meters_travelled: null }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(ltCondition, getMockUserContext({ meters_travelled: null }));
assert.isNull(result);
- sinon.assert.calledOnce(stubLogHandler.log);
- assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.DEBUG);
- var logMessage = stubLogHandler.log.args[0][1];
- assert.strictEqual(
- logMessage,
- sprintf(LOG_MESSAGES.UNEXPECTED_TYPE_NULL, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(ltCondition), ltCondition.name)
- );
+ sinon.assert.calledOnce(mockLogger.debug);
+
+ assert.deepEqual(mockLogger.debug.args[0], [UNEXPECTED_TYPE_NULL, JSON.stringify(ltCondition), ltCondition.name]);
});
it('should return null if there is no user-provided value', function() {
- var result = customAttributeEvaluator.evaluate(ltCondition, getMockUserContext({}));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(ltCondition, getMockUserContext({}));
assert.isNull(result);
});
@@ -745,23 +657,19 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: Infinity,
};
- var result = customAttributeEvaluator.evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition, getMockUserContext(userAttributes));
assert.isNull(result);
invalidValueCondition.value = {};
- result = customAttributeEvaluator.evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition, getMockUserContext(userAttributes));
assert.isNull(result);
invalidValueCondition.value = Math.pow(2, 53) + 2;
- result = customAttributeEvaluator.evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition, getMockUserContext(userAttributes));
assert.isNull(result);
- sinon.assert.calledThrice(stubLogHandler.log);
- var logMessage = stubLogHandler.log.args[2][1];
- assert.strictEqual(
- logMessage,
- sprintf(LOG_MESSAGES.UNEXPECTED_CONDITION_VALUE, 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR', JSON.stringify(invalidValueCondition))
- );
+ sinon.assert.calledThrice(mockLogger.warn);
+ assert.deepEqual(mockLogger.warn.args[2], [UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition)]);
});
});
describe('less than or equal to match type', function() {
@@ -773,7 +681,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
};
it('should return false if the user-provided value is greater than the condition value', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
leCondition,
getMockUserContext({
meters_travelled: 48.3,
@@ -785,7 +693,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
it('should return true if the user-provided value is less than or equal to the condition value', function() {
var versions = [48, 48.2];
for (let userValue of versions) {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
leCondition,
getMockUserContext({
meters_travelled: userValue,
@@ -806,7 +714,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
};
it('should return false if the user-provided value is less than the condition value', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
geCondition,
getMockUserContext({
meters_travelled: 48,
@@ -818,7 +726,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
it('should return true if the user-provided value is less than or equal to the condition value', function() {
var versions = [100, 48.2];
for (let userValue of versions) {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
geCondition,
getMockUserContext({
meters_travelled: userValue,
@@ -848,7 +756,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: targetVersion,
};
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
customSemvergtCondition,
getMockUserContext({
app_version: userVersion,
@@ -872,7 +780,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: targetVersion,
};
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
customSemvergtCondition,
getMockUserContext({
app_version: userVersion,
@@ -883,7 +791,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
});
it('should log and return null if the user-provided version is not a string', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
semvergtCondition,
getMockUserContext({
app_version: 22,
@@ -891,7 +799,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
);
assert.isNull(result);
- result = customAttributeEvaluator.evaluate(
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
semvergtCondition,
getMockUserContext({
app_version: false,
@@ -899,30 +807,22 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
);
assert.isNull(result);
- assert.strictEqual(2, stubLogHandler.log.callCount);
- assert.strictEqual(
- stubLogHandler.log.args[0][1],
- 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_gt","name":"app_version","type":"custom_attribute","value":"2.0.0"} evaluated to UNKNOWN because a value of type "number" was passed for user attribute "app_version".'
- );
- assert.strictEqual(
- stubLogHandler.log.args[1][1],
- 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_gt","name":"app_version","type":"custom_attribute","value":"2.0.0"} evaluated to UNKNOWN because a value of type "boolean" was passed for user attribute "app_version".'
- );
+ assert.strictEqual(2, mockLogger.warn.callCount);
+ assert.deepEqual(mockLogger.warn.args[0], [UNEXPECTED_TYPE, JSON.stringify(semvergtCondition), 'number', 'app_version']);
+
+ assert.deepEqual(mockLogger.warn.args[1], [UNEXPECTED_TYPE, JSON.stringify(semvergtCondition), 'boolean', 'app_version']);
});
it('should log and return null if the user-provided value is null', function() {
- var result = customAttributeEvaluator.evaluate(semvergtCondition, getMockUserContext({ app_version: null }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(semvergtCondition, getMockUserContext({ app_version: null }));
assert.isNull(result);
- sinon.assert.calledOnce(stubLogHandler.log);
- sinon.assert.calledWithExactly(
- stubLogHandler.log,
- LOG_LEVEL.DEBUG,
- 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_gt","name":"app_version","type":"custom_attribute","value":"2.0.0"} evaluated to UNKNOWN because a null value was passed for user attribute "app_version".'
- );
+ sinon.assert.calledOnce(mockLogger.debug);
+
+ assert.deepEqual(mockLogger.debug.args[0], [UNEXPECTED_TYPE_NULL, JSON.stringify(semvergtCondition), 'app_version']);
});
it('should return null if there is no user-provided value', function() {
- var result = customAttributeEvaluator.evaluate(semvergtCondition, getMockUserContext({}));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(semvergtCondition, getMockUserContext({}));
assert.isNull(result);
});
});
@@ -949,7 +849,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: targetVersion,
};
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
customSemverltCondition,
getMockUserContext({
app_version: userVersion,
@@ -971,7 +871,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: targetVersion,
};
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
customSemverltCondition,
getMockUserContext({
app_version: userVersion,
@@ -982,7 +882,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
});
it('should log and return null if the user-provided version is not a string', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
semverltCondition,
getMockUserContext({
app_version: 22,
@@ -990,7 +890,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
);
assert.isNull(result);
- result = customAttributeEvaluator.evaluate(
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
semverltCondition,
getMockUserContext({
app_version: false,
@@ -998,30 +898,21 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
);
assert.isNull(result);
- assert.strictEqual(2, stubLogHandler.log.callCount);
- assert.strictEqual(
- stubLogHandler.log.args[0][1],
- 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_lt","name":"app_version","type":"custom_attribute","value":"2.0.0"} evaluated to UNKNOWN because a value of type "number" was passed for user attribute "app_version".'
- );
- assert.strictEqual(
- stubLogHandler.log.args[1][1],
- 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_lt","name":"app_version","type":"custom_attribute","value":"2.0.0"} evaluated to UNKNOWN because a value of type "boolean" was passed for user attribute "app_version".'
- );
+ assert.strictEqual(2, mockLogger.warn.callCount);
+
+ assert.deepEqual(mockLogger.warn.args[0], [UNEXPECTED_TYPE, JSON.stringify(semverltCondition), 'number', 'app_version']);
+ assert.deepEqual(mockLogger.warn.args[1], [UNEXPECTED_TYPE, JSON.stringify(semverltCondition), 'boolean', 'app_version']);
});
it('should log and return null if the user-provided value is null', function() {
- var result = customAttributeEvaluator.evaluate(semverltCondition, getMockUserContext({ app_version: null }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(semverltCondition, getMockUserContext({ app_version: null }));
assert.isNull(result);
- sinon.assert.calledOnce(stubLogHandler.log);
- sinon.assert.calledWithExactly(
- stubLogHandler.log,
- LOG_LEVEL.DEBUG,
- 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_lt","name":"app_version","type":"custom_attribute","value":"2.0.0"} evaluated to UNKNOWN because a null value was passed for user attribute "app_version".'
- );
+ sinon.assert.calledOnce(mockLogger.debug);
+ assert.deepEqual(mockLogger.debug.args[0], [UNEXPECTED_TYPE_NULL, JSON.stringify(semverltCondition), 'app_version']);
});
it('should return null if there is no user-provided value', function() {
- var result = customAttributeEvaluator.evaluate(semverltCondition, getMockUserContext({}));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(semverltCondition, getMockUserContext({}));
assert.isNull(result);
});
});
@@ -1047,7 +938,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: targetVersion,
};
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
customSemvereqCondition,
getMockUserContext({
app_version: userVersion,
@@ -1069,7 +960,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: targetVersion,
};
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
customSemvereqCondition,
getMockUserContext({
app_version: userVersion,
@@ -1080,7 +971,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
});
it('should log and return null if the user-provided version is not a string', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
semvereqCondition,
getMockUserContext({
app_version: 22,
@@ -1088,7 +979,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
);
assert.isNull(result);
- result = customAttributeEvaluator.evaluate(
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
semvereqCondition,
getMockUserContext({
app_version: false,
@@ -1096,30 +987,22 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
);
assert.isNull(result);
- assert.strictEqual(2, stubLogHandler.log.callCount);
- assert.strictEqual(
- stubLogHandler.log.args[0][1],
- 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_eq","name":"app_version","type":"custom_attribute","value":"2.0"} evaluated to UNKNOWN because a value of type "number" was passed for user attribute "app_version".'
- );
- assert.strictEqual(
- stubLogHandler.log.args[1][1],
- 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_eq","name":"app_version","type":"custom_attribute","value":"2.0"} evaluated to UNKNOWN because a value of type "boolean" was passed for user attribute "app_version".'
- );
+ assert.strictEqual(2, mockLogger.warn.callCount);
+ assert.deepEqual(mockLogger.warn.args[0], [UNEXPECTED_TYPE, JSON.stringify(semvereqCondition), 'number', 'app_version']);
+ assert.deepEqual(mockLogger.warn.args[1], [UNEXPECTED_TYPE, JSON.stringify(semvereqCondition), 'boolean', 'app_version']);
});
it('should log and return null if the user-provided value is null', function() {
- var result = customAttributeEvaluator.evaluate(semvereqCondition, getMockUserContext({ app_version: null }));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(semvereqCondition, getMockUserContext({ app_version: null }));
assert.isNull(result);
- sinon.assert.calledOnce(stubLogHandler.log);
- sinon.assert.calledWithExactly(
- stubLogHandler.log,
- LOG_LEVEL.DEBUG,
- 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_eq","name":"app_version","type":"custom_attribute","value":"2.0"} evaluated to UNKNOWN because a null value was passed for user attribute "app_version".'
- );
+ sinon.assert.calledOnce(mockLogger.debug);
+
+ assert.strictEqual(mockLogger.debug.args[0][0], UNEXPECTED_TYPE_NULL);
+ assert.strictEqual(mockLogger.debug.args[0][1], JSON.stringify(semvereqCondition));
});
it('should return null if there is no user-provided value', function() {
- var result = customAttributeEvaluator.evaluate(semvereqCondition, getMockUserContext({}));
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(semvereqCondition, getMockUserContext({}));
assert.isNull(result);
});
});
@@ -1143,7 +1026,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: targetVersion,
};
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
customSemvereqCondition,
getMockUserContext({
app_version: userVersion,
@@ -1166,7 +1049,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: targetVersion,
};
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
customSemvereqCondition,
getMockUserContext({
app_version: userVersion,
@@ -1177,7 +1060,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
});
it('should return true if the user-provided version is equal to the condition version', function() {
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
semverleCondition,
getMockUserContext({
app_version: '2.0',
@@ -1208,7 +1091,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: targetVersion,
};
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
customSemvereqCondition,
getMockUserContext({
app_version: userVersion,
@@ -1230,7 +1113,7 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
type: 'custom_attribute',
value: targetVersion,
};
- var result = customAttributeEvaluator.evaluate(
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
customSemvereqCondition,
getMockUserContext({
app_version: userVersion,
diff --git a/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.ts b/lib/core/custom_attribute_condition_evaluator/index.ts
similarity index 80%
rename from packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.ts
rename to lib/core/custom_attribute_condition_evaluator/index.ts
index 775be8ad7..797a7d4e0 100644
--- a/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.ts
+++ b/lib/core/custom_attribute_condition_evaluator/index.ts
@@ -13,16 +13,21 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
***************************************************************************/
-import { getLogger } from '../../modules/logging';
import { Condition, OptimizelyUserContext } from '../../shared_types';
import fns from '../../utils/fns';
-import { LOG_MESSAGES } from '../../utils/enums';
import { compareVersion } from '../../utils/semantic_version';
-
-const MODULE_NAME = 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR';
-
-const logger = getLogger();
+import {
+ MISSING_ATTRIBUTE_VALUE,
+ UNEXPECTED_TYPE_NULL,
+} from 'log_message';
+import {
+ OUT_OF_BOUNDS,
+ UNEXPECTED_TYPE,
+ UNEXPECTED_CONDITION_VALUE,
+ UNKNOWN_MATCH_TYPE
+} from 'error_message';
+import { LoggerFacade } from '../../logging/logger';
const EXACT_MATCH_TYPE = 'exact';
const EXISTS_MATCH_TYPE = 'exists';
@@ -52,7 +57,8 @@ const MATCH_TYPES = [
SEMVER_GREATER_OR_EQUAL_THAN_MATCH_TYPE
];
-type ConditionEvaluator = (condition: Condition, user: OptimizelyUserContext) => boolean | null;
+type ConditionEvaluator = (condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade) => boolean | null;
+type Evaluator = { evaluate: (condition: Condition, user: OptimizelyUserContext) => boolean | null; }
const EVALUATORS_BY_MATCH_TYPE: { [conditionType: string]: ConditionEvaluator | undefined } = {};
EVALUATORS_BY_MATCH_TYPE[EXACT_MATCH_TYPE] = exactEvaluator;
@@ -68,6 +74,14 @@ EVALUATORS_BY_MATCH_TYPE[SEMVER_GREATER_OR_EQUAL_THAN_MATCH_TYPE] = semverGreate
EVALUATORS_BY_MATCH_TYPE[SEMVER_LESS_THAN_MATCH_TYPE] = semverLessThanEvaluator;
EVALUATORS_BY_MATCH_TYPE[SEMVER_LESS_OR_EQUAL_THAN_MATCH_TYPE] = semverLessThanOrEqualEvaluator;
+export const getEvaluator = (logger?: LoggerFacade): Evaluator => {
+ return {
+ evaluate(condition: Condition, user: OptimizelyUserContext): boolean | null {
+ return evaluate(condition, user, logger);
+ }
+ };
+}
+
/**
* Given a custom attribute audience condition and user attributes, evaluate the
* condition against the attributes.
@@ -77,18 +91,18 @@ EVALUATORS_BY_MATCH_TYPE[SEMVER_LESS_OR_EQUAL_THAN_MATCH_TYPE] = semverLessThanO
* null if the given user attributes and condition can't be evaluated
* TODO: Change to accept and object with named properties
*/
-export function evaluate(condition: Condition, user: OptimizelyUserContext): boolean | null {
+function evaluate(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
const userAttributes = user.getAttributes();
const conditionMatch = condition.match;
if (typeof conditionMatch !== 'undefined' && MATCH_TYPES.indexOf(conditionMatch) === -1) {
- logger.warn(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, MODULE_NAME, JSON.stringify(condition));
+ logger?.warn(UNKNOWN_MATCH_TYPE, JSON.stringify(condition));
return null;
}
const attributeKey = condition.name;
if (!userAttributes.hasOwnProperty(attributeKey) && conditionMatch != EXISTS_MATCH_TYPE) {
- logger.debug(
- LOG_MESSAGES.MISSING_ATTRIBUTE_VALUE, MODULE_NAME, JSON.stringify(condition), attributeKey
+ logger?.debug(
+ MISSING_ATTRIBUTE_VALUE, JSON.stringify(condition), attributeKey
);
return null;
}
@@ -100,7 +114,7 @@ export function evaluate(condition: Condition, user: OptimizelyUserContext): boo
evaluatorForMatch = EVALUATORS_BY_MATCH_TYPE[conditionMatch] || exactEvaluator;
}
- return evaluatorForMatch(condition, user);
+ return evaluatorForMatch(condition, user, logger);
}
/**
@@ -123,7 +137,7 @@ function isValueTypeValidForExactConditions(value: unknown): boolean {
* if there is a mismatch between the user attribute type and the condition value
* type
*/
-function exactEvaluator(condition: Condition, user: OptimizelyUserContext): boolean | null {
+function exactEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
const userAttributes = user.getAttributes();
const conditionValue = condition.value;
const conditionValueType = typeof conditionValue;
@@ -135,29 +149,29 @@ function exactEvaluator(condition: Condition, user: OptimizelyUserContext): bool
!isValueTypeValidForExactConditions(conditionValue) ||
(fns.isNumber(conditionValue) && !fns.isSafeInteger(conditionValue))
) {
- logger.warn(
- LOG_MESSAGES.UNEXPECTED_CONDITION_VALUE, MODULE_NAME, JSON.stringify(condition)
+ logger?.warn(
+ UNEXPECTED_CONDITION_VALUE, JSON.stringify(condition)
);
return null;
}
if (userValue === null) {
- logger.debug(
- LOG_MESSAGES.UNEXPECTED_TYPE_NULL, MODULE_NAME, JSON.stringify(condition), conditionName
+ logger?.debug(
+ UNEXPECTED_TYPE_NULL, JSON.stringify(condition), conditionName
);
return null;
}
if (!isValueTypeValidForExactConditions(userValue) || conditionValueType !== userValueType) {
- logger.warn(
- LOG_MESSAGES.UNEXPECTED_TYPE, MODULE_NAME, JSON.stringify(condition), userValueType, conditionName
+ logger?.warn(
+ UNEXPECTED_TYPE, JSON.stringify(condition), userValueType, conditionName
);
return null;
}
if (fns.isNumber(userValue) && !fns.isSafeInteger(userValue)) {
- logger.warn(
- LOG_MESSAGES.OUT_OF_BOUNDS, MODULE_NAME, JSON.stringify(condition), conditionName
+ logger?.warn(
+ OUT_OF_BOUNDS, JSON.stringify(condition), conditionName
);
return null;
}
@@ -174,7 +188,7 @@ function exactEvaluator(condition: Condition, user: OptimizelyUserContext): bool
* 2) the user attribute value is neither null nor undefined
* Returns false otherwise
*/
-function existsEvaluator(condition: Condition, user: OptimizelyUserContext): boolean {
+function existsEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean {
const userAttributes = user.getAttributes();
const userValue = userAttributes[condition.name];
return typeof userValue !== 'undefined' && userValue !== null;
@@ -187,7 +201,7 @@ function existsEvaluator(condition: Condition, user: OptimizelyUserContext): boo
* @returns {?boolean} true if values are valid,
* false if values are not valid
*/
-function validateValuesForNumericCondition(condition: Condition, user: OptimizelyUserContext): boolean {
+function validateValuesForNumericCondition(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean {
const userAttributes = user.getAttributes();
const conditionName = condition.name;
const userValue = userAttributes[conditionName];
@@ -195,29 +209,29 @@ function validateValuesForNumericCondition(condition: Condition, user: Optimizel
const conditionValue = condition.value;
if (conditionValue === null || !fns.isSafeInteger(conditionValue)) {
- logger.warn(
- LOG_MESSAGES.UNEXPECTED_CONDITION_VALUE, MODULE_NAME, JSON.stringify(condition)
+ logger?.warn(
+ UNEXPECTED_CONDITION_VALUE, JSON.stringify(condition)
);
return false;
}
if (userValue === null) {
- logger.debug(
- LOG_MESSAGES.UNEXPECTED_TYPE_NULL, MODULE_NAME, JSON.stringify(condition), conditionName
+ logger?.debug(
+ UNEXPECTED_TYPE_NULL, JSON.stringify(condition), conditionName
);
return false;
}
if (!fns.isNumber(userValue)) {
- logger.warn(
- LOG_MESSAGES.UNEXPECTED_TYPE, MODULE_NAME, JSON.stringify(condition), userValueType, conditionName
+ logger?.warn(
+ UNEXPECTED_TYPE, JSON.stringify(condition), userValueType, conditionName
);
return false;
}
if (!fns.isSafeInteger(userValue)) {
- logger.warn(
- LOG_MESSAGES.OUT_OF_BOUNDS, MODULE_NAME, JSON.stringify(condition), conditionName
+ logger?.warn(
+ OUT_OF_BOUNDS, JSON.stringify(condition), conditionName
);
return false;
}
@@ -233,15 +247,15 @@ function validateValuesForNumericCondition(condition: Condition, user: Optimizel
* null if the condition value isn't a number or the user attribute value
* isn't a number
*/
-function greaterThanEvaluator(condition: Condition, user: OptimizelyUserContext): boolean | null {
+function greaterThanEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
const userAttributes = user.getAttributes();
const userValue = userAttributes[condition.name];
const conditionValue = condition.value;
- if (!validateValuesForNumericCondition(condition, user) || conditionValue === null) {
+ if (!validateValuesForNumericCondition(condition, user, logger) || conditionValue === null) {
return null;
}
- return userValue > conditionValue;
+ return userValue! > conditionValue;
}
/**
@@ -253,16 +267,16 @@ function greaterThanEvaluator(condition: Condition, user: OptimizelyUserContext)
* null if the condition value isn't a number or the user attribute value isn't a
* number
*/
-function greaterThanOrEqualEvaluator(condition: Condition, user: OptimizelyUserContext): boolean | null {
+function greaterThanOrEqualEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
const userAttributes = user.getAttributes();
const userValue = userAttributes[condition.name];
const conditionValue = condition.value;
- if (!validateValuesForNumericCondition(condition, user) || conditionValue === null) {
+ if (!validateValuesForNumericCondition(condition, user, logger) || conditionValue === null) {
return null;
}
- return userValue >= conditionValue;
+ return userValue! >= conditionValue;
}
/**
@@ -274,16 +288,16 @@ function greaterThanOrEqualEvaluator(condition: Condition, user: OptimizelyUserC
* null if the condition value isn't a number or the user attribute value isn't a
* number
*/
-function lessThanEvaluator(condition: Condition, user: OptimizelyUserContext): boolean | null {
+function lessThanEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
const userAttributes = user.getAttributes();
const userValue = userAttributes[condition.name];
const conditionValue = condition.value;
- if (!validateValuesForNumericCondition(condition, user) || conditionValue === null) {
+ if (!validateValuesForNumericCondition(condition, user, logger) || conditionValue === null) {
return null;
}
- return userValue < conditionValue;
+ return userValue! < conditionValue;
}
/**
@@ -295,16 +309,16 @@ function lessThanEvaluator(condition: Condition, user: OptimizelyUserContext): b
* null if the condition value isn't a number or the user attribute value isn't a
* number
*/
-function lessThanOrEqualEvaluator(condition: Condition, user: OptimizelyUserContext): boolean | null {
+function lessThanOrEqualEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
const userAttributes = user.getAttributes();
const userValue = userAttributes[condition.name];
const conditionValue = condition.value;
- if (!validateValuesForNumericCondition(condition, user) || conditionValue === null) {
+ if (!validateValuesForNumericCondition(condition, user, logger) || conditionValue === null) {
return null;
}
- return userValue <= conditionValue;
+ return userValue! <= conditionValue;
}
/**
@@ -316,7 +330,7 @@ function lessThanOrEqualEvaluator(condition: Condition, user: OptimizelyUserCont
* null if the condition value isn't a string or the user attribute value
* isn't a string
*/
-function substringEvaluator(condition: Condition, user: OptimizelyUserContext): boolean | null {
+function substringEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
const userAttributes = user.getAttributes();
const conditionName = condition.name;
const userValue = userAttributes[condition.name];
@@ -324,22 +338,22 @@ function substringEvaluator(condition: Condition, user: OptimizelyUserContext):
const conditionValue = condition.value;
if (typeof conditionValue !== 'string') {
- logger.warn(
- LOG_MESSAGES.UNEXPECTED_CONDITION_VALUE, MODULE_NAME, JSON.stringify(condition)
+ logger?.warn(
+ UNEXPECTED_CONDITION_VALUE, JSON.stringify(condition)
);
return null;
}
if (userValue === null) {
- logger.debug(
- LOG_MESSAGES.UNEXPECTED_TYPE_NULL, MODULE_NAME, JSON.stringify(condition), conditionName
+ logger?.debug(
+ UNEXPECTED_TYPE_NULL, JSON.stringify(condition), conditionName
);
return null;
}
if (typeof userValue !== 'string') {
- logger.warn(
- LOG_MESSAGES.UNEXPECTED_TYPE, MODULE_NAME, JSON.stringify(condition), userValueType, conditionName
+ logger?.warn(
+ UNEXPECTED_TYPE, JSON.stringify(condition), userValueType, conditionName
);
return null;
}
@@ -354,7 +368,7 @@ function substringEvaluator(condition: Condition, user: OptimizelyUserContext):
* @returns {?number} returns compareVersion result
* null if the user attribute version has an invalid type
*/
-function evaluateSemanticVersion(condition: Condition, user: OptimizelyUserContext): number | null {
+function evaluateSemanticVersion(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): number | null {
const userAttributes = user.getAttributes();
const conditionName = condition.name;
const userValue = userAttributes[conditionName];
@@ -362,27 +376,27 @@ function evaluateSemanticVersion(condition: Condition, user: OptimizelyUserConte
const conditionValue = condition.value;
if (typeof conditionValue !== 'string') {
- logger.warn(
- LOG_MESSAGES.UNEXPECTED_CONDITION_VALUE, MODULE_NAME, JSON.stringify(condition)
+ logger?.warn(
+ UNEXPECTED_CONDITION_VALUE, JSON.stringify(condition)
);
return null;
}
if (userValue === null) {
- logger.debug(
- LOG_MESSAGES.UNEXPECTED_TYPE_NULL, MODULE_NAME, JSON.stringify(condition), conditionName
+ logger?.debug(
+ UNEXPECTED_TYPE_NULL, JSON.stringify(condition), conditionName
);
return null;
}
if (typeof userValue !== 'string') {
- logger.warn(
- LOG_MESSAGES.UNEXPECTED_TYPE, MODULE_NAME, JSON.stringify(condition), userValueType, conditionName
+ logger?.warn(
+ UNEXPECTED_TYPE, JSON.stringify(condition), userValueType, conditionName
);
return null;
}
- return compareVersion(conditionValue, userValue);
+ return compareVersion(conditionValue, userValue, logger);
}
/**
@@ -393,8 +407,8 @@ function evaluateSemanticVersion(condition: Condition, user: OptimizelyUserConte
* false if the user attribute version is not equal (!==) to the condition version,
* null if the user attribute version has an invalid type
*/
-function semverEqualEvaluator(condition: Condition, user: OptimizelyUserContext): boolean | null {
- const result = evaluateSemanticVersion(condition, user);
+function semverEqualEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
+ const result = evaluateSemanticVersion(condition, user, logger);
if (result === null) {
return null;
}
@@ -409,8 +423,8 @@ function semverEqualEvaluator(condition: Condition, user: OptimizelyUserContext)
* false if the user attribute version is not greater than the condition version,
* null if the user attribute version has an invalid type
*/
-function semverGreaterThanEvaluator(condition: Condition, user: OptimizelyUserContext): boolean | null {
- const result = evaluateSemanticVersion(condition, user);
+function semverGreaterThanEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
+ const result = evaluateSemanticVersion(condition, user, logger);
if (result === null) {
return null;
}
@@ -425,8 +439,8 @@ function semverGreaterThanEvaluator(condition: Condition, user: OptimizelyUserCo
* false if the user attribute version is not less than the condition version,
* null if the user attribute version has an invalid type
*/
-function semverLessThanEvaluator(condition: Condition, user: OptimizelyUserContext): boolean | null {
- const result = evaluateSemanticVersion(condition, user);
+function semverLessThanEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
+ const result = evaluateSemanticVersion(condition, user, logger);
if (result === null) {
return null;
}
@@ -441,8 +455,8 @@ function semverLessThanEvaluator(condition: Condition, user: OptimizelyUserConte
* false if the user attribute version is not greater than or equal to the condition version,
* null if the user attribute version has an invalid type
*/
-function semverGreaterThanOrEqualEvaluator(condition: Condition, user: OptimizelyUserContext): boolean | null {
- const result = evaluateSemanticVersion(condition, user);
+function semverGreaterThanOrEqualEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
+ const result = evaluateSemanticVersion(condition, user, logger);
if (result === null) {
return null;
}
@@ -457,11 +471,10 @@ function semverGreaterThanOrEqualEvaluator(condition: Condition, user: Optimizel
* false if the user attribute version is not less than or equal to the condition version,
* null if the user attribute version has an invalid type
*/
-function semverLessThanOrEqualEvaluator(condition: Condition, user: OptimizelyUserContext): boolean | null {
- const result = evaluateSemanticVersion(condition, user);
+function semverLessThanOrEqualEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
+ const result = evaluateSemanticVersion(condition, user, logger);
if (result === null) {
return null;
}
return result <= 0;
-
}
diff --git a/lib/core/decision/index.spec.ts b/lib/core/decision/index.spec.ts
new file mode 100644
index 000000000..ea98fba39
--- /dev/null
+++ b/lib/core/decision/index.spec.ts
@@ -0,0 +1,128 @@
+/**
+ * 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.
+ */
+import { describe, it, expect } from 'vitest';
+import { rolloutDecisionObj, featureTestDecisionObj } from '../../tests/test_data';
+import * as decision from './';
+
+describe('getExperimentKey method', () => {
+ it('should return empty string when experiment is null', () => {
+ const experimentKey = decision.getExperimentKey(rolloutDecisionObj);
+
+ expect(experimentKey).toEqual('');
+ });
+
+ it('should return empty string when experiment is not defined', () => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const experimentKey = decision.getExperimentKey({});
+
+ expect(experimentKey).toEqual('');
+ });
+
+ it('should return experiment key when experiment is defined', () => {
+ const experimentKey = decision.getExperimentKey(featureTestDecisionObj);
+
+ expect(experimentKey).toEqual('testing_my_feature');
+ });
+});
+
+describe('getExperimentId method', () => {
+ it('should return null when experiment is null', () => {
+ const experimentId = decision.getExperimentId(rolloutDecisionObj);
+
+ expect(experimentId).toEqual(null);
+ });
+
+ it('should return null when experiment is not defined', () => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const experimentId = decision.getExperimentId({});
+
+ expect(experimentId).toEqual(null);
+ });
+
+ it('should return experiment id when experiment is defined', () => {
+ const experimentId = decision.getExperimentId(featureTestDecisionObj);
+
+ expect(experimentId).toEqual('594098');
+ });
+
+ describe('getVariationKey method', ()=> {
+ it('should return empty string when variation is null', () => {
+ const variationKey = decision.getVariationKey(rolloutDecisionObj);
+
+ expect(variationKey).toEqual('');
+ });
+
+ it('should return empty string when variation is not defined', () => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const variationKey = decision.getVariationKey({});
+
+ expect(variationKey).toEqual('');
+ });
+
+ it('should return variation key when variation is defined', () => {
+ const variationKey = decision.getVariationKey(featureTestDecisionObj);
+
+ expect(variationKey).toEqual('variation');
+ });
+ });
+
+ describe('getVariationId method', () => {
+ it('should return null when variation is null', () => {
+ const variationId = decision.getVariationId(rolloutDecisionObj);
+
+ expect(variationId).toEqual(null);
+ });
+
+ it('should return null when variation is not defined', () => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const variationId = decision.getVariationId({});
+
+ expect(variationId).toEqual(null);
+ });
+
+ it('should return variation id when variation is defined', () => {
+ const variationId = decision.getVariationId(featureTestDecisionObj);
+
+ expect(variationId).toEqual('594096');
+ });
+ });
+
+ describe('getFeatureEnabledFromVariation method', () => {
+ it('should return false when variation is null', () => {
+ const featureEnabled = decision.getFeatureEnabledFromVariation(rolloutDecisionObj);
+
+ expect(featureEnabled).toEqual(false);
+ });
+
+ it('should return false when variation is not defined', () => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const featureEnabled = decision.getFeatureEnabledFromVariation({});
+
+ expect(featureEnabled).toEqual(false);
+ });
+
+ it('should return featureEnabled boolean when variation is defined', () => {
+ const featureEnabled = decision.getFeatureEnabledFromVariation(featureTestDecisionObj);
+
+ expect(featureEnabled).toEqual(true);
+ });
+ });
+});
diff --git a/packages/optimizely-sdk/lib/core/decision/index.tests.js b/lib/core/decision/index.tests.js
similarity index 100%
rename from packages/optimizely-sdk/lib/core/decision/index.tests.js
rename to lib/core/decision/index.tests.js
diff --git a/packages/optimizely-sdk/lib/core/decision/index.ts b/lib/core/decision/index.ts
similarity index 100%
rename from packages/optimizely-sdk/lib/core/decision/index.ts
rename to lib/core/decision/index.ts
diff --git a/lib/core/decision_service/cmab/cmab_client.spec.ts b/lib/core/decision_service/cmab/cmab_client.spec.ts
new file mode 100644
index 000000000..88d258892
--- /dev/null
+++ b/lib/core/decision_service/cmab/cmab_client.spec.ts
@@ -0,0 +1,403 @@
+/**
+ * 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.
+ */
+
+import { describe, it, expect, vi, Mocked, Mock, MockInstance, beforeEach, afterEach } from 'vitest';
+
+import { DefaultCmabClient } from './cmab_client';
+import { getMockAbortableRequest, getMockRequestHandler } from '../../../tests/mock/mock_request_handler';
+import { RequestHandler } from '../../../utils/http_request_handler/http';
+import { advanceTimersByTime, exhaustMicrotasks } from '../../../tests/testUtils';
+import { OptimizelyError } from '../../../error/optimizly_error';
+
+const mockSuccessResponse = (variation: string) => Promise.resolve({
+ statusCode: 200,
+ body: JSON.stringify({
+ predictions: [
+ {
+ variation_id: variation,
+ },
+ ],
+ }),
+ headers: {}
+});
+
+const mockErrorResponse = (statusCode: number) => Promise.resolve({
+ statusCode,
+ body: '',
+ headers: {},
+});
+
+const assertRequest = (
+ call: number,
+ mockRequestHandler: MockInstance,
+ ruleId: string,
+ userId: string,
+ attributes: Record,
+ cmabUuid: string,
+) => {
+ const [requestUrl, headers, method, data] = mockRequestHandler.mock.calls[call];
+ expect(requestUrl).toBe(`https://prediction.cmab.optimizely.com/predict/${ruleId}`);
+ expect(method).toBe('POST');
+ expect(headers).toEqual({
+ 'Content-Type': 'application/json',
+ });
+
+ const parsedData = JSON.parse(data!);
+ expect(parsedData.instances).toEqual([
+ {
+ visitorId: userId,
+ experimentId: ruleId,
+ attributes: Object.keys(attributes).map((key) => ({
+ id: key,
+ value: attributes[key],
+ type: 'custom_attribute',
+ })),
+ cmabUUID: cmabUuid,
+ }
+ ]);
+};
+
+describe('DefaultCmabClient', () => {
+ it('should fetch variation using correct parameters', async () => {
+ const requestHandler = getMockRequestHandler();
+
+ const mockMakeRequest: MockInstance = requestHandler.makeRequest;
+ mockMakeRequest.mockReturnValue(getMockAbortableRequest(mockSuccessResponse('var123')));
+
+ const cmabClient = new DefaultCmabClient({
+ requestHandler,
+ });
+
+ const ruleId = '123';
+ const userId = 'user123';
+ const attributes = {
+ browser: 'chrome',
+ isMobile: true,
+ };
+ const cmabUuid = 'uuid123';
+
+ const variation = await cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid);
+
+ expect(variation).toBe('var123');
+ assertRequest(0, mockMakeRequest, ruleId, userId, attributes, cmabUuid);
+ });
+
+ it('should retry fetch if retryConfig is provided', async () => {
+ const requestHandler = getMockRequestHandler();
+
+ const mockMakeRequest: MockInstance = requestHandler.makeRequest;
+ mockMakeRequest.mockReturnValueOnce(getMockAbortableRequest(Promise.reject('error')))
+ .mockReturnValueOnce(getMockAbortableRequest(mockErrorResponse(500)))
+ .mockReturnValueOnce(getMockAbortableRequest(mockSuccessResponse('var123')));
+
+ const cmabClient = new DefaultCmabClient({
+ requestHandler,
+ retryConfig: {
+ maxRetries: 5,
+ },
+ });
+
+ const ruleId = '123';
+ const userId = 'user123';
+ const attributes = {
+ browser: 'chrome',
+ isMobile: true,
+ };
+ const cmabUuid = 'uuid123';
+
+ const variation = await cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid);
+
+ expect(variation).toBe('var123');
+ expect(mockMakeRequest.mock.calls.length).toBe(3);
+ for(let i = 0; i < 3; i++) {
+ assertRequest(i, mockMakeRequest, ruleId, userId, attributes, cmabUuid);
+ }
+ });
+
+ it('should use backoff provider if provided', async () => {
+ vi.useFakeTimers();
+
+ const requestHandler = getMockRequestHandler();
+
+ const mockMakeRequest: MockInstance = requestHandler.makeRequest;
+ mockMakeRequest.mockReturnValueOnce(getMockAbortableRequest(Promise.reject('error')))
+ .mockReturnValueOnce(getMockAbortableRequest(mockErrorResponse(500)))
+ .mockReturnValueOnce(getMockAbortableRequest(mockErrorResponse(500)))
+ .mockReturnValueOnce(getMockAbortableRequest(mockSuccessResponse('var123')));
+
+ const backoffProvider = () => {
+ let call = 0;
+ const values = [100, 200, 300];
+ return {
+ reset: () => {},
+ backoff: () => {
+ return values[call++];
+ },
+ };
+ }
+
+ const cmabClient = new DefaultCmabClient({
+ requestHandler,
+ retryConfig: {
+ maxRetries: 5,
+ backoffProvider,
+ },
+ });
+
+ const ruleId = '123';
+ const userId = 'user123';
+ const attributes = {
+ browser: 'chrome',
+ isMobile: true,
+ };
+ const cmabUuid = 'uuid123';
+
+ const fetchPromise = cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid);
+
+ await exhaustMicrotasks();
+ expect(mockMakeRequest.mock.calls.length).toBe(1);
+
+ // first backoff is 100ms, should not retry yet
+ await advanceTimersByTime(90);
+ await exhaustMicrotasks();
+ expect(mockMakeRequest.mock.calls.length).toBe(1);
+
+ // first backoff is 100ms, should retry now
+ await advanceTimersByTime(10);
+ await exhaustMicrotasks();
+ expect(mockMakeRequest.mock.calls.length).toBe(2);
+
+ // second backoff is 200ms, should not retry 2nd time yet
+ await advanceTimersByTime(150);
+ await exhaustMicrotasks();
+ expect(mockMakeRequest.mock.calls.length).toBe(2);
+
+ // second backoff is 200ms, should retry 2nd time now
+ await advanceTimersByTime(50);
+ await exhaustMicrotasks();
+ expect(mockMakeRequest.mock.calls.length).toBe(3);
+
+ // third backoff is 300ms, should not retry 3rd time yet
+ await advanceTimersByTime(280);
+ await exhaustMicrotasks();
+ expect(mockMakeRequest.mock.calls.length).toBe(3);
+
+ // third backoff is 300ms, should retry 3rd time now
+ await advanceTimersByTime(20);
+ await exhaustMicrotasks();
+ expect(mockMakeRequest.mock.calls.length).toBe(4);
+
+ const variation = await fetchPromise;
+
+ expect(variation).toBe('var123');
+ expect(mockMakeRequest.mock.calls.length).toBe(4);
+ for(let i = 0; i < 4; i++) {
+ assertRequest(i, mockMakeRequest, ruleId, userId, attributes, cmabUuid);
+ }
+ vi.useRealTimers();
+ });
+
+ it('should reject the promise after retries are exhausted', async () => {
+ const requestHandler = getMockRequestHandler();
+
+ const mockMakeRequest: MockInstance = requestHandler.makeRequest;
+ mockMakeRequest.mockReturnValue(getMockAbortableRequest(Promise.reject('error')));
+
+ const cmabClient = new DefaultCmabClient({
+ requestHandler,
+ retryConfig: {
+ maxRetries: 5,
+ },
+ });
+
+ const ruleId = '123';
+ const userId = 'user123';
+ const attributes = {
+ browser: 'chrome',
+ isMobile: true,
+ };
+ const cmabUuid = 'uuid123';
+
+ await expect(cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid)).rejects.toThrow();
+ expect(mockMakeRequest.mock.calls.length).toBe(6);
+ });
+
+ it('should reject the promise after retries are exhausted with error status', async () => {
+ const requestHandler = getMockRequestHandler();
+
+ const mockMakeRequest: MockInstance = requestHandler.makeRequest;
+ mockMakeRequest.mockReturnValue(getMockAbortableRequest(mockErrorResponse(500)));
+
+ const cmabClient = new DefaultCmabClient({
+ requestHandler,
+ retryConfig: {
+ maxRetries: 5,
+ },
+ });
+
+ const ruleId = '123';
+ const userId = 'user123';
+ const attributes = {
+ browser: 'chrome',
+ isMobile: true,
+ };
+ const cmabUuid = 'uuid123';
+
+ await expect(cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid)).rejects.toThrow();
+ expect(mockMakeRequest.mock.calls.length).toBe(6);
+ });
+
+ it('should not retry if retryConfig is not provided', async () => {
+ const requestHandler = getMockRequestHandler();
+
+ const mockMakeRequest: MockInstance = requestHandler.makeRequest;
+ mockMakeRequest.mockReturnValueOnce(getMockAbortableRequest(Promise.reject('error')));
+
+ const cmabClient = new DefaultCmabClient({
+ requestHandler,
+ });
+
+ const ruleId = '123';
+ const userId = 'user123';
+ const attributes = {
+ browser: 'chrome',
+ isMobile: true,
+ };
+ const cmabUuid = 'uuid123';
+
+ await expect(cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid)).rejects.toThrow();
+ expect(mockMakeRequest.mock.calls.length).toBe(1);
+ });
+
+ it('should reject the promise if response status code is not 200', async () => {
+ const requestHandler = getMockRequestHandler();
+
+ const mockMakeRequest: MockInstance = requestHandler.makeRequest;
+ mockMakeRequest.mockReturnValue(getMockAbortableRequest(mockErrorResponse(500)));
+
+ const cmabClient = new DefaultCmabClient({
+ requestHandler,
+ });
+
+ const ruleId = '123';
+ const userId = 'user123';
+ const attributes = {
+ browser: 'chrome',
+ isMobile: true,
+ };
+ const cmabUuid = 'uuid123';
+
+ await expect(cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid)).rejects.toMatchObject(
+ new OptimizelyError('CMAB_FETCH_FAILED', 500),
+ );
+ });
+
+ it('should reject the promise if api response is not valid', async () => {
+ const requestHandler = getMockRequestHandler();
+
+ const mockMakeRequest: MockInstance = requestHandler.makeRequest;
+ mockMakeRequest.mockReturnValue(getMockAbortableRequest(Promise.resolve({
+ statusCode: 200,
+ body: JSON.stringify({
+ predictions: [],
+ }),
+ headers: {},
+ })));
+
+ const cmabClient = new DefaultCmabClient({
+ requestHandler,
+ });
+
+ const ruleId = '123';
+ const userId = 'user123';
+ const attributes = {
+ browser: 'chrome',
+ isMobile: true,
+ };
+ const cmabUuid = 'uuid123';
+
+ await expect(cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid)).rejects.toMatchObject(
+ new OptimizelyError('INVALID_CMAB_RESPONSE'),
+ );
+ });
+
+ it('should reject the promise if requestHandler.makeRequest rejects', async () => {
+ const requestHandler = getMockRequestHandler();
+
+ const mockMakeRequest: MockInstance = requestHandler.makeRequest;
+ mockMakeRequest.mockReturnValue(getMockAbortableRequest(Promise.reject('error')));
+
+ const cmabClient = new DefaultCmabClient({
+ requestHandler,
+ });
+
+ const ruleId = '123';
+ const userId = 'user123';
+ const attributes = {
+ browser: 'chrome',
+ isMobile: true,
+ };
+ const cmabUuid = 'uuid123';
+
+ await expect(cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid)).rejects.toThrow('error');
+ });
+
+ it('should use custom prediction endpoint template when provided', async () => {
+ const requestHandler = getMockRequestHandler();
+
+ const mockMakeRequest: MockInstance = requestHandler.makeRequest;
+ mockMakeRequest.mockReturnValue(getMockAbortableRequest(mockSuccessResponse('var456')));
+
+ const customEndpoint = 'https://custom.example.com/predict/%s';
+ const cmabClient = new DefaultCmabClient({
+ requestHandler,
+ predictionEndpointTemplate: customEndpoint,
+ });
+ const ruleId = '789';
+ const userId = 'user789';
+ const attributes = {
+ browser: 'firefox',
+ };
+ const cmabUuid = 'uuid789';
+ const variation = await cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid);
+ const [requestUrl] = mockMakeRequest.mock.calls[0];
+
+ expect(variation).toBe('var456');
+ expect(mockMakeRequest.mock.calls.length).toBe(1);
+ expect(requestUrl).toBe('https://custom.example.com/predict/789');
+ });
+
+ it('should use default prediction endpoint template when not provided', async () => {
+ const requestHandler = getMockRequestHandler();
+ const mockMakeRequest: MockInstance = requestHandler.makeRequest;
+ mockMakeRequest.mockReturnValue(getMockAbortableRequest(mockSuccessResponse('var999')));
+ const cmabClient = new DefaultCmabClient({
+ requestHandler,
+ });
+ const ruleId = '555';
+ const userId = 'user555';
+ const attributes = {
+ browser: 'safari',
+ };
+ const cmabUuid = 'uuid555';
+ const variation = await cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid);
+ const [requestUrl] = mockMakeRequest.mock.calls[0];
+
+ expect(variation).toBe('var999');
+ expect(mockMakeRequest.mock.calls.length).toBe(1);
+ expect(requestUrl).toBe('https://prediction.cmab.optimizely.com/predict/555');
+ });
+});
diff --git a/lib/core/decision_service/cmab/cmab_client.ts b/lib/core/decision_service/cmab/cmab_client.ts
new file mode 100644
index 000000000..a6925713a
--- /dev/null
+++ b/lib/core/decision_service/cmab/cmab_client.ts
@@ -0,0 +1,121 @@
+/**
+ * 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.
+ */
+
+import { OptimizelyError } from "../../../error/optimizly_error";
+import { CMAB_FETCH_FAILED, INVALID_CMAB_FETCH_RESPONSE } from "../../../message/error_message";
+import { UserAttributes } from "../../../shared_types";
+import { runWithRetry } from "../../../utils/executor/backoff_retry_runner";
+import { sprintf } from "../../../utils/fns";
+import { RequestHandler } from "../../../utils/http_request_handler/http";
+import { isSuccessStatusCode } from "../../../utils/http_request_handler/http_util";
+import { BackoffController } from "../../../utils/repeater/repeater";
+import { Producer } from "../../../utils/type";
+
+export interface CmabClient {
+ fetchDecision(
+ ruleId: string,
+ userId: string,
+ attributes: UserAttributes,
+ cmabUuid: string,
+ ): Promise
+}
+
+const DEFAULT_CMAB_PREDICTION_ENDPOINT = 'https://prediction.cmab.optimizely.com/predict/%s';
+
+export type RetryConfig = {
+ maxRetries: number,
+ backoffProvider?: Producer;
+}
+
+export type CmabClientConfig = {
+ requestHandler: RequestHandler;
+ retryConfig?: RetryConfig;
+ predictionEndpointTemplate?: string;
+}
+
+export class DefaultCmabClient implements CmabClient {
+ private requestHandler: RequestHandler;
+ private retryConfig?: RetryConfig;
+ private predictionEndpointTemplate: string = DEFAULT_CMAB_PREDICTION_ENDPOINT;
+
+ constructor(config: CmabClientConfig) {
+ this.requestHandler = config.requestHandler;
+ this.retryConfig = config.retryConfig;
+ if (config.predictionEndpointTemplate) {
+ this.predictionEndpointTemplate = config.predictionEndpointTemplate;
+ }
+ }
+
+ async fetchDecision(
+ ruleId: string,
+ userId: string,
+ attributes: UserAttributes,
+ cmabUuid: string,
+ ): Promise {
+ const url = sprintf(this.predictionEndpointTemplate, ruleId);
+
+ const cmabAttributes = Object.keys(attributes).map((key) => ({
+ id: key,
+ value: attributes[key],
+ type: 'custom_attribute',
+ }));
+
+ const body = {
+ instances: [
+ {
+ visitorId: userId,
+ experimentId: ruleId,
+ attributes: cmabAttributes,
+ cmabUUID: cmabUuid,
+ }
+ ]
+ }
+
+ const variation = await (this.retryConfig ?
+ runWithRetry(
+ () => this.doFetch(url, JSON.stringify(body)),
+ this.retryConfig.backoffProvider?.(),
+ this.retryConfig.maxRetries,
+ ).result : this.doFetch(url, JSON.stringify(body))
+ );
+
+ return variation;
+ }
+
+ private async doFetch(url: string, data: string): Promise {
+ const response = await this.requestHandler.makeRequest(
+ url,
+ { 'Content-Type': 'application/json' },
+ 'POST',
+ data,
+ ).responsePromise;
+
+ if (!isSuccessStatusCode(response.statusCode)) {
+ return Promise.reject(new OptimizelyError(CMAB_FETCH_FAILED, response.statusCode));
+ }
+
+ const body = JSON.parse(response.body);
+ if (!this.validateResponse(body)) {
+ return Promise.reject(new OptimizelyError(INVALID_CMAB_FETCH_RESPONSE));
+ }
+
+ return String(body.predictions[0].variation_id);
+ }
+
+ private validateResponse(body: any): boolean {
+ return body.predictions && body.predictions.length > 0 && body.predictions[0].variation_id;
+ }
+}
diff --git a/lib/core/decision_service/cmab/cmab_service.spec.ts b/lib/core/decision_service/cmab/cmab_service.spec.ts
new file mode 100644
index 000000000..38ee205e4
--- /dev/null
+++ b/lib/core/decision_service/cmab/cmab_service.spec.ts
@@ -0,0 +1,510 @@
+/**
+ * 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.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+
+import { DefaultCmabService } from './cmab_service';
+import { getMockSyncCache } from '../../../tests/mock/mock_cache';
+import { ProjectConfig } from '../../../project_config/project_config';
+import { OptimizelyDecideOption, UserAttributes } from '../../../shared_types';
+import OptimizelyUserContext from '../../../optimizely_user_context';
+import { validate as uuidValidate } from 'uuid';
+import { resolvablePromise } from '../../../utils/promise/resolvablePromise';
+import { exhaustMicrotasks } from '../../../tests/testUtils';
+
+const mockProjectConfig = (): ProjectConfig => ({
+ experimentIdMap: {
+ '1234': {
+ id: '1234',
+ key: 'cmab_1',
+ cmab: {
+ attributeIds: ['66', '77', '88'],
+ }
+ },
+ '5678': {
+ id: '5678',
+ key: 'cmab_2',
+ cmab: {
+ attributeIds: ['66', '99'],
+ }
+ },
+ },
+ attributeIdMap: {
+ '66': {
+ id: '66',
+ key: 'country',
+ },
+ '77': {
+ id: '77',
+ key: 'age',
+ },
+ '88': {
+ id: '88',
+ key: 'language',
+ },
+ '99': {
+ id: '99',
+ key: 'gender',
+ },
+ }
+} as any);
+
+const mockUserContext = (userId: string, attributes: UserAttributes): OptimizelyUserContext => new OptimizelyUserContext({
+ userId,
+ attributes,
+} as any);
+
+describe('DefaultCmabService', () => {
+ it('should fetch and return the variation from cmabClient using correct parameters', async () => {
+ const mockCmabClient = {
+ fetchDecision: vi.fn().mockResolvedValue('123'),
+ };
+
+ const cmabService = new DefaultCmabService({
+ cmabCache: getMockSyncCache(),
+ cmabClient: mockCmabClient,
+ });
+
+ const projectConfig = mockProjectConfig();
+ const userContext = mockUserContext('user123', {
+ country: 'US',
+ age: '25',
+ gender: 'male',
+ });
+
+ const ruleId = '1234';
+ const variation = await cmabService.getDecision(projectConfig, userContext, ruleId, {});
+
+ expect(variation.variationId).toEqual('123');
+ expect(uuidValidate(variation.cmabUuid)).toBe(true);
+
+ expect(mockCmabClient.fetchDecision).toHaveBeenCalledOnce();
+ const [ruleIdArg, userIdArg, attributesArg, cmabUuidArg] = mockCmabClient.fetchDecision.mock.calls[0];
+ expect(ruleIdArg).toEqual(ruleId);
+ expect(userIdArg).toEqual(userContext.getUserId());
+ expect(attributesArg).toEqual({
+ country: 'US',
+ age: '25',
+ });
+ });
+
+ it('should filter attributes based on experiment cmab attributeIds before fetching variation', async () => {
+ const mockCmabClient = {
+ fetchDecision: vi.fn().mockResolvedValue('123'),
+ };
+
+ const cmabService = new DefaultCmabService({
+ cmabCache: getMockSyncCache(),
+ cmabClient: mockCmabClient,
+ });
+
+ const projectConfig = mockProjectConfig();
+ const userContext = mockUserContext('user123', {
+ country: 'US',
+ age: '25',
+ language: 'en',
+ gender: 'male'
+ });
+
+ await cmabService.getDecision(projectConfig, userContext, '1234', {});
+ await cmabService.getDecision(projectConfig, userContext, '5678', {});
+
+ expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2);
+ expect(mockCmabClient.fetchDecision.mock.calls[0][2]).toEqual({
+ country: 'US',
+ age: '25',
+ language: 'en',
+ });
+ expect(mockCmabClient.fetchDecision.mock.calls[1][2]).toEqual({
+ country: 'US',
+ gender: 'male'
+ });
+ });
+
+ it('should cache the variation and return the same variation if relevant attributes have not changed', async () => {
+ const mockCmabClient = {
+ fetchDecision: vi.fn().mockResolvedValueOnce('123')
+ .mockResolvedValueOnce('456')
+ .mockResolvedValueOnce('789'),
+ };
+
+ const cmabService = new DefaultCmabService({
+ cmabCache: getMockSyncCache(),
+ cmabClient: mockCmabClient,
+ });
+
+ const projectConfig = mockProjectConfig();
+ const userContext11 = mockUserContext('user123', {
+ country: 'US',
+ age: '25',
+ language: 'en',
+ gender: 'male'
+ });
+
+ const variation11 = await cmabService.getDecision(projectConfig, userContext11, '1234', {});
+
+ const userContext12 = mockUserContext('user123', {
+ country: 'US',
+ age: '25',
+ language: 'en',
+ gender: 'female'
+ });
+
+ const variation12 = await cmabService.getDecision(projectConfig, userContext12, '1234', {});
+ expect(variation11.variationId).toEqual('123');
+ expect(variation12.variationId).toEqual('123');
+ expect(variation11.cmabUuid).toEqual(variation12.cmabUuid);
+
+ expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(1);
+
+ const userContext21 = mockUserContext('user456', {
+ country: 'BD',
+ age: '30',
+ });
+
+ const variation21 = await cmabService.getDecision(projectConfig, userContext21, '5678', {});
+
+ const userContext22 = mockUserContext('user456', {
+ country: 'BD',
+ age: '35',
+ });
+
+ const variation22 = await cmabService.getDecision(projectConfig, userContext22, '5678', {});
+ expect(variation21.variationId).toEqual('456');
+ expect(variation22.variationId).toEqual('456');
+ expect(variation21.cmabUuid).toEqual(variation22.cmabUuid);
+
+ expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2);
+ });
+
+ it('should cache the variation and return the same variation if relevant attributes value have not changed but order changed', async () => {
+ const mockCmabClient = {
+ fetchDecision: vi.fn().mockResolvedValueOnce('123')
+ .mockResolvedValueOnce('456')
+ .mockResolvedValueOnce('789'),
+ };
+
+ const cmabService = new DefaultCmabService({
+ cmabCache: getMockSyncCache(),
+ cmabClient: mockCmabClient,
+ });
+
+ const projectConfig = mockProjectConfig();
+ const userContext11 = mockUserContext('user123', {
+ country: 'US',
+ age: '25',
+ language: 'en',
+ gender: 'male'
+ });
+
+ const variation11 = await cmabService.getDecision(projectConfig, userContext11, '1234', {});
+
+ const userContext12 = mockUserContext('user123', {
+ gender: 'female',
+ language: 'en',
+ country: 'US',
+ age: '25',
+ });
+
+ const variation12 = await cmabService.getDecision(projectConfig, userContext12, '1234', {});
+ expect(variation11.variationId).toEqual('123');
+ expect(variation12.variationId).toEqual('123');
+ expect(variation11.cmabUuid).toEqual(variation12.cmabUuid);
+
+ expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not mix up the cache between different experiments', async () => {
+ const mockCmabClient = {
+ fetchDecision: vi.fn().mockResolvedValueOnce('123')
+ .mockResolvedValueOnce('456')
+ .mockResolvedValueOnce('789'),
+ };
+
+ const cmabService = new DefaultCmabService({
+ cmabCache: getMockSyncCache(),
+ cmabClient: mockCmabClient,
+ });
+
+ const projectConfig = mockProjectConfig();
+ const userContext = mockUserContext('user123', {
+ country: 'US',
+ age: '25',
+ });
+
+ const variation1 = await cmabService.getDecision(projectConfig, userContext, '1234', {});
+
+ const variation2 = await cmabService.getDecision(projectConfig, userContext, '5678', {});
+
+ expect(variation1.variationId).toEqual('123');
+ expect(variation2.variationId).toEqual('456');
+ expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid);
+ });
+
+ it('should not mix up the cache between different users', async () => {
+ const mockCmabClient = {
+ fetchDecision: vi.fn().mockResolvedValueOnce('123')
+ .mockResolvedValueOnce('456')
+ .mockResolvedValueOnce('789'),
+ };
+
+ const cmabService = new DefaultCmabService({
+ cmabCache: getMockSyncCache(),
+ cmabClient: mockCmabClient,
+ });
+
+ const projectConfig = mockProjectConfig();
+
+ const userContext1 = mockUserContext('user123', {
+ country: 'US',
+ age: '25',
+ });
+
+ const userContext2 = mockUserContext('user456', {
+ country: 'US',
+ age: '25',
+ });
+
+ const variation1 = await cmabService.getDecision(projectConfig, userContext1, '1234', {});
+
+ const variation2 = await cmabService.getDecision(projectConfig, userContext2, '1234', {});
+ expect(variation1.variationId).toEqual('123');
+ expect(variation2.variationId).toEqual('456');
+ expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid);
+
+ expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2);
+ });
+
+ it('should invalidate the cache and fetch a new variation if relevant attributes have changed', async () => {
+ const mockCmabClient = {
+ fetchDecision: vi.fn().mockResolvedValueOnce('123')
+ .mockResolvedValueOnce('456'),
+ };
+
+ const cmabService = new DefaultCmabService({
+ cmabCache: getMockSyncCache(),
+ cmabClient: mockCmabClient,
+ });
+
+ const projectConfig = mockProjectConfig();
+ const userContext1 = mockUserContext('user123', {
+ country: 'US',
+ age: '25',
+ language: 'en',
+ gender: 'male'
+ });
+
+ const variation1 = await cmabService.getDecision(projectConfig, userContext1, '1234', {});
+
+ const userContext2 = mockUserContext('user123', {
+ country: 'US',
+ age: '50',
+ language: 'en',
+ gender: 'male'
+ });
+
+ const variation2 = await cmabService.getDecision(projectConfig, userContext2, '1234', {});
+ expect(variation1.variationId).toEqual('123');
+ expect(variation2.variationId).toEqual('456');
+ expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid);
+
+ expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2);
+ });
+
+ it('should ignore the cache and fetch variation if IGNORE_CMAB_CACHE option is provided', async () => {
+ const mockCmabClient = {
+ fetchDecision: vi.fn().mockResolvedValueOnce('123')
+ .mockResolvedValueOnce('456'),
+ };
+
+ const cmabService = new DefaultCmabService({
+ cmabCache: getMockSyncCache(),
+ cmabClient: mockCmabClient,
+ });
+
+ const projectConfig = mockProjectConfig();
+ const userContext = mockUserContext('user123', {
+ country: 'US',
+ age: '25',
+ language: 'en',
+ gender: 'male'
+ });
+
+ const variation1 = await cmabService.getDecision(projectConfig, userContext, '1234', {});
+
+ const variation2 = await cmabService.getDecision(projectConfig, userContext, '1234', {
+ [OptimizelyDecideOption.IGNORE_CMAB_CACHE]: true,
+ });
+
+ const variation3 = await cmabService.getDecision(projectConfig, userContext, '1234', {});
+
+ expect(variation1.variationId).toEqual('123');
+ expect(variation2.variationId).toEqual('456');
+ expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid);
+
+ expect(variation3.variationId).toEqual('123');
+ expect(variation3.cmabUuid).toEqual(variation1.cmabUuid);
+
+ expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2);
+ });
+
+ it('should reset the cache before fetching variation if RESET_CMAB_CACHE option is provided', async () => {
+ const mockCmabClient = {
+ fetchDecision: vi.fn().mockResolvedValueOnce('123')
+ .mockResolvedValueOnce('456')
+ .mockResolvedValueOnce('789')
+ .mockResolvedValueOnce('101112'),
+ };
+
+ const cmabService = new DefaultCmabService({
+ cmabCache: getMockSyncCache(),
+ cmabClient: mockCmabClient,
+ });
+
+ const projectConfig = mockProjectConfig();
+ const userContext1 = mockUserContext('user123', {
+ country: 'US',
+ age: '25'
+ });
+
+ const userContext2 = mockUserContext('user456', {
+ country: 'US',
+ age: '50'
+ });
+
+ const variation1 = await cmabService.getDecision(projectConfig, userContext1, '1234', {});
+ expect(variation1.variationId).toEqual('123');
+
+ const variation2 = await cmabService.getDecision(projectConfig, userContext2, '1234', {});
+ expect(variation2.variationId).toEqual('456');
+
+ const variation3 = await cmabService.getDecision(projectConfig, userContext1, '1234', {
+ [OptimizelyDecideOption.RESET_CMAB_CACHE]: true,
+ });
+
+ expect(variation3.variationId).toEqual('789');
+
+ const variation4 = await cmabService.getDecision(projectConfig, userContext2, '1234', {});
+ expect(variation4.variationId).toEqual('101112');
+ });
+
+ it('should invalidate the cache and fetch a new variation if INVALIDATE_USER_CMAB_CACHE option is provided', async () => {
+ const mockCmabClient = {
+ fetchDecision: vi.fn().mockResolvedValueOnce('123')
+ .mockResolvedValueOnce('456'),
+ };
+
+ const cmabService = new DefaultCmabService({
+ cmabCache: getMockSyncCache(),
+ cmabClient: mockCmabClient,
+ });
+
+ const projectConfig = mockProjectConfig();
+ const userContext = mockUserContext('user123', {
+ country: 'US',
+ age: '25',
+ language: 'en',
+ gender: 'male'
+ });
+
+ const variation1 = await cmabService.getDecision(projectConfig, userContext, '1234', {});
+
+ const variation2 = await cmabService.getDecision(projectConfig, userContext, '1234', {
+ [OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE]: true,
+ });
+
+ const variation3 = await cmabService.getDecision(projectConfig, userContext, '1234', {});
+
+ expect(variation1.variationId).toEqual('123');
+ expect(variation2.variationId).toEqual('456');
+ expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid);
+ expect(variation3.variationId).toEqual('456');
+ expect(variation2.cmabUuid).toEqual(variation3.cmabUuid);
+
+ expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2);
+ });
+
+ it('should serialize concurrent calls to getDecision with the same userId and ruleId', async () => {
+ const nCall = 10;
+ let currentVar = 123;
+ const fetchPromises = Array.from({ length: nCall }, () => resolvablePromise());
+
+ let callCount = 0;
+ const mockCmabClient = {
+ fetchDecision: vi.fn().mockImplementation(async () => {
+ const variation = `${currentVar++}`;
+ await fetchPromises[callCount++];
+ return variation;
+ }),
+ };
+
+ const cmabService = new DefaultCmabService({
+ cmabCache: getMockSyncCache(),
+ cmabClient: mockCmabClient,
+ });
+
+ const projectConfig = mockProjectConfig();
+ const userContext = mockUserContext('user123', {});
+
+ const resultPromises = [];
+ for (let i = 0; i < nCall; i++) {
+ resultPromises.push(cmabService.getDecision(projectConfig, userContext, '1234', {}));
+ }
+
+ await exhaustMicrotasks();
+
+ expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(1);
+
+ for(let i = 0; i < nCall; i++) {
+ fetchPromises[i].resolve('');
+ await exhaustMicrotasks();
+ const result = await resultPromises[i];
+ expect(result.variationId).toBe('123');
+ expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(1);
+ }
+ });
+
+ it('should not serialize calls to getDecision with different userId or ruleId', async () => {
+ let currentVar = 123;
+ const mockCmabClient = {
+ fetchDecision: vi.fn().mockImplementation(() => Promise.resolve(`${currentVar++}`)),
+ };
+
+ const cmabService = new DefaultCmabService({
+ cmabCache: getMockSyncCache(),
+ cmabClient: mockCmabClient,
+ });
+
+ const projectConfig = mockProjectConfig();
+ const userContext1 = mockUserContext('user123', {});
+ const userContext2 = mockUserContext('user456', {});
+
+ const resultPromises = [];
+ resultPromises.push(cmabService.getDecision(projectConfig, userContext1, '1234', {}));
+ resultPromises.push(cmabService.getDecision(projectConfig, userContext1, '5678', {}));
+ resultPromises.push(cmabService.getDecision(projectConfig, userContext2, '1234', {}));
+ resultPromises.push(cmabService.getDecision(projectConfig, userContext2, '5678', {}));
+
+ await exhaustMicrotasks();
+
+ expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(4);
+
+ for(let i = 0; i < resultPromises.length; i++) {
+ const result = await resultPromises[i];
+ expect(result.variationId).toBe(`${123 + i}`);
+ }
+ });
+});
diff --git a/lib/core/decision_service/cmab/cmab_service.ts b/lib/core/decision_service/cmab/cmab_service.ts
new file mode 100644
index 000000000..1963df613
--- /dev/null
+++ b/lib/core/decision_service/cmab/cmab_service.ts
@@ -0,0 +1,198 @@
+/**
+ * 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.
+ */
+
+import { LoggerFacade } from "../../../logging/logger";
+import { IOptimizelyUserContext } from "../../../optimizely_user_context";
+import { ProjectConfig } from "../../../project_config/project_config"
+import { OptimizelyDecideOption, UserAttributes } from "../../../shared_types"
+import { CacheWithRemove } from "../../../utils/cache/cache";
+import { CmabClient } from "./cmab_client";
+import { v4 as uuidV4 } from 'uuid';
+import murmurhash from "murmurhash";
+import { DecideOptionsMap } from "..";
+import { SerialRunner } from "../../../utils/executor/serial_runner";
+import {
+ CMAB_CACHE_ATTRIBUTES_MISMATCH,
+ CMAB_CACHE_HIT,
+ CMAB_CACHE_MISS,
+ IGNORE_CMAB_CACHE,
+ INVALIDATE_CMAB_CACHE,
+ RESET_CMAB_CACHE,
+} from 'log_message';
+
+export type CmabDecision = {
+ variationId: string,
+ cmabUuid: string,
+}
+
+export interface CmabService {
+ /**
+ * Get variation id for the user
+ * @param {IOptimizelyUserContext} userContext
+ * @param {string} ruleId
+ * @param {OptimizelyDecideOption[]} options
+ * @return {Promise}
+ */
+ getDecision(
+ projectConfig: ProjectConfig,
+ userContext: IOptimizelyUserContext,
+ ruleId: string,
+ options: DecideOptionsMap,
+ ): Promise
+}
+
+export type CmabCacheValue = {
+ attributesHash: string,
+ variationId: string,
+ cmabUuid: string,
+}
+
+export type CmabServiceOptions = {
+ logger?: LoggerFacade;
+ cmabCache: CacheWithRemove;
+ cmabClient: CmabClient;
+}
+
+const SERIALIZER_BUCKETS = 1000;
+const LOGGER_NAME = 'CmabService';
+
+export class DefaultCmabService implements CmabService {
+ private cmabCache: CacheWithRemove;
+ private cmabClient: CmabClient;
+ private logger?: LoggerFacade;
+ private serializers: SerialRunner[] = Array.from(
+ { length: SERIALIZER_BUCKETS }, () => new SerialRunner()
+ );
+
+ constructor(options: CmabServiceOptions) {
+ this.cmabCache = options.cmabCache;
+ this.cmabClient = options.cmabClient;
+ this.logger = options.logger;
+ this.logger?.setName(LOGGER_NAME);
+ }
+
+ private getSerializerIndex(userId: string, experimentId: string): number {
+ const key = this.getCacheKey(userId, experimentId);
+ const hash = murmurhash.v3(key);
+ return Math.abs(hash) % SERIALIZER_BUCKETS;
+ }
+
+ async getDecision(
+ projectConfig: ProjectConfig,
+ userContext: IOptimizelyUserContext,
+ ruleId: string,
+ options: DecideOptionsMap,
+ ): Promise {
+ const serializerIndex = this.getSerializerIndex(userContext.getUserId(), ruleId);
+ return this.serializers[serializerIndex].run(() =>
+ this.getDecisionInternal(projectConfig, userContext, ruleId, options)
+ );
+ }
+
+ private async getDecisionInternal(
+ projectConfig: ProjectConfig,
+ userContext: IOptimizelyUserContext,
+ ruleId: string,
+ options: DecideOptionsMap,
+ ): Promise {
+ const userId = userContext.getUserId();
+ const filteredAttributes = this.filterAttributes(projectConfig, userContext, ruleId);
+
+ if (options[OptimizelyDecideOption.IGNORE_CMAB_CACHE]) {
+ this.logger?.debug(IGNORE_CMAB_CACHE, userId, ruleId);
+ return this.fetchDecision(ruleId, userId, filteredAttributes);
+ }
+
+ if (options[OptimizelyDecideOption.RESET_CMAB_CACHE]) {
+ this.logger?.debug(RESET_CMAB_CACHE, userId, ruleId);
+ this.cmabCache.reset();
+ }
+
+ const cacheKey = this.getCacheKey(userId, ruleId);
+
+ if (options[OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE]) {
+ this.logger?.debug(INVALIDATE_CMAB_CACHE, userId, ruleId);
+ this.cmabCache.remove(cacheKey);
+ }
+
+ const cachedValue = await this.cmabCache.lookup(cacheKey);
+
+ const attributesJson = JSON.stringify(filteredAttributes, Object.keys(filteredAttributes).sort());
+ const attributesHash = String(murmurhash.v3(attributesJson));
+
+ if (cachedValue) {
+ if (cachedValue.attributesHash === attributesHash) {
+ this.logger?.debug(CMAB_CACHE_HIT, userId, ruleId);
+ return { variationId: cachedValue.variationId, cmabUuid: cachedValue.cmabUuid };
+ } else {
+ this.logger?.debug(CMAB_CACHE_ATTRIBUTES_MISMATCH, userId, ruleId);
+ this.cmabCache.remove(cacheKey);
+ }
+ } else {
+ this.logger?.debug(CMAB_CACHE_MISS, userId, ruleId);
+ }
+
+ const variation = await this.fetchDecision(ruleId, userId, filteredAttributes);
+ this.cmabCache.save(cacheKey, {
+ attributesHash,
+ variationId: variation.variationId,
+ cmabUuid: variation.cmabUuid,
+ });
+
+ return variation;
+ }
+
+ private async fetchDecision(
+ ruleId: string,
+ userId: string,
+ attributes: UserAttributes,
+ ): Promise {
+ const cmabUuid = uuidV4();
+ const variationId = await this.cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid);
+ return { variationId, cmabUuid };
+ }
+
+ private filterAttributes(
+ projectConfig: ProjectConfig,
+ userContext: IOptimizelyUserContext,
+ ruleId: string
+ ): UserAttributes {
+ const filteredAttributes: UserAttributes = {};
+ const userAttributes = userContext.getAttributes();
+
+ const experiment = projectConfig.experimentIdMap[ruleId];
+ if (!experiment || !experiment.cmab) {
+ return filteredAttributes;
+ }
+
+ const cmabAttributeIds = experiment.cmab.attributeIds;
+
+ cmabAttributeIds.forEach((aid) => {
+ const attribute = projectConfig.attributeIdMap[aid];
+
+ if (userAttributes.hasOwnProperty(attribute.key)) {
+ filteredAttributes[attribute.key] = userAttributes[attribute.key];
+ }
+ });
+
+ return filteredAttributes;
+ }
+
+ private getCacheKey(userId: string, ruleId: string): string {
+ const len = userId.length;
+ return `${len}-${userId}-${ruleId}`;
+ }
+}
diff --git a/lib/core/decision_service/index.spec.ts b/lib/core/decision_service/index.spec.ts
new file mode 100644
index 000000000..9fc9d89a1
--- /dev/null
+++ b/lib/core/decision_service/index.spec.ts
@@ -0,0 +1,2939 @@
+/**
+ * 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.
+ */
+import { describe, it, expect, vi, MockInstance, beforeEach, afterEach } from 'vitest';
+import { CMAB_DUMMY_ENTITY_ID, CMAB_FETCH_FAILED, DecisionService } from '.';
+import { getMockLogger } from '../../tests/mock/mock_logger';
+import OptimizelyUserContext from '../../optimizely_user_context';
+import { bucket } from '../bucketer';
+import { getTestProjectConfig, getTestProjectConfigWithFeatures } from '../../tests/test_data';
+import { createProjectConfig, ProjectConfig } from '../../project_config/project_config';
+import { BucketerParams, Experiment, Holdout, OptimizelyDecideOption, UserAttributes, UserProfile } from '../../shared_types';
+import { CONTROL_ATTRIBUTES, DECISION_SOURCES } from '../../utils/enums';
+import { getDecisionTestDatafile } from '../../tests/decision_test_datafile';
+import { Value } from '../../utils/promise/operation_value';
+import {
+ USER_HAS_NO_FORCED_VARIATION,
+ VALID_BUCKETING_ID,
+ SAVED_USER_VARIATION,
+ SAVED_VARIATION_NOT_FOUND,
+} from 'log_message';
+import {
+ EXPERIMENT_NOT_RUNNING,
+ RETURNING_STORED_VARIATION,
+ USER_NOT_IN_EXPERIMENT,
+ USER_FORCED_IN_VARIATION,
+ EVALUATING_AUDIENCES_COMBINED,
+ AUDIENCE_EVALUATION_RESULT_COMBINED,
+ USER_IN_ROLLOUT,
+ USER_NOT_IN_ROLLOUT,
+ FEATURE_HAS_NO_EXPERIMENTS,
+ USER_DOESNT_MEET_CONDITIONS_FOR_TARGETING_RULE,
+ USER_NOT_BUCKETED_INTO_TARGETING_RULE,
+ USER_BUCKETED_INTO_TARGETING_RULE,
+ NO_ROLLOUT_EXISTS,
+ USER_MEETS_CONDITIONS_FOR_TARGETING_RULE,
+} from '../decision_service/index';
+import { BUCKETING_ID_NOT_STRING, USER_PROFILE_LOOKUP_ERROR, USER_PROFILE_SAVE_ERROR } from 'error_message';
+
+type MockLogger = ReturnType;
+
+type MockFnType = ReturnType;
+
+type MockUserProfileService = {
+ lookup: MockFnType;
+ save: MockFnType;
+};
+
+type MockCmabService = {
+ getDecision: MockFnType;
+}
+
+type DecisionServiceInstanceOpt = {
+ logger?: boolean;
+ userProfileService?: boolean;
+ userProfileServiceAsync?: boolean;
+}
+
+type DecisionServiceInstance = {
+ logger?: MockLogger;
+ userProfileService?: MockUserProfileService;
+ userProfileServiceAsync?: MockUserProfileService;
+ cmabService: MockCmabService;
+ decisionService: DecisionService;
+}
+
+const getDecisionService = (opt: DecisionServiceInstanceOpt = {}): DecisionServiceInstance => {
+ const logger = opt.logger ? getMockLogger() : undefined;
+ const userProfileService = opt.userProfileService ? {
+ lookup: vi.fn(),
+ save: vi.fn(),
+ } : undefined;
+
+ const userProfileServiceAsync = opt.userProfileServiceAsync ? {
+ lookup: vi.fn(),
+ save: vi.fn(),
+ } : undefined;
+
+ const cmabService = {
+ getDecision: vi.fn(),
+ };
+
+ const decisionService = new DecisionService({
+ logger,
+ userProfileService,
+ userProfileServiceAsync,
+ UNSTABLE_conditionEvaluators: {},
+ cmabService,
+ });
+
+ return {
+ logger,
+ userProfileService,
+ userProfileServiceAsync,
+ decisionService,
+ cmabService,
+ };
+};
+
+const mockBucket: MockInstance = vi.hoisted(() => vi.fn());
+
+vi.mock('../bucketer', () => ({
+ bucket: mockBucket,
+}));
+
+// Mock the feature toggle for holdout tests
+const mockHoldoutToggle = vi.hoisted(() => vi.fn());
+
+vi.mock('../../feature_toggle', () => ({
+ holdout: mockHoldoutToggle,
+}));
+
+
+const cloneDeep = (d: any) => JSON.parse(JSON.stringify(d));
+
+const testData = getTestProjectConfig();
+const testDataWithFeatures = getTestProjectConfigWithFeatures();
+
+// Utility function to create test datafile with holdout configurations
+const getHoldoutTestDatafile = () => {
+ const datafile = getDecisionTestDatafile();
+
+ // Add holdouts to the datafile
+ datafile.holdouts = [
+ {
+ id: 'holdout_running_id',
+ key: 'holdout_running',
+ status: 'Running',
+ includedFlags: [],
+ excludedFlags: [],
+ audienceIds: ['4001'], // age_22 audience
+ audienceConditions: ['or', '4001'],
+ variations: [
+ {
+ id: 'holdout_variation_running_id',
+ key: 'holdout_variation_running',
+ variables: []
+ }
+ ],
+ trafficAllocation: [
+ {
+ entityId: 'holdout_variation_running_id',
+ endOfRange: 5000
+ }
+ ]
+ },
+ {
+ id: "holdout_not_bucketed_id",
+ key: "holdout_not_bucketed",
+ status: "Running",
+ includedFlags: [],
+ excludedFlags: [],
+ audienceIds: ['4002'],
+ audienceConditions: ['or', '4002'],
+ variations: [
+ {
+ id: 'holdout_not_bucketed_variation_id',
+ key: 'holdout_not_bucketed_variation',
+ variables: []
+ }
+ ],
+ trafficAllocation: [
+ {
+ entityId: 'holdout_not_bucketed_variation_id',
+ endOfRange: 0,
+ }
+ ]
+ },
+ ];
+
+ return datafile;
+};
+
+const verifyBucketCall = (
+ call: number,
+ projectConfig: ProjectConfig,
+ experiment: Experiment,
+ user: OptimizelyUserContext,
+ bucketIdFromAttribute = false,
+) => {
+ const {
+ experimentId,
+ experimentKey,
+ userId,
+ trafficAllocationConfig,
+ experimentKeyMap,
+ experimentIdMap,
+ groupIdMap,
+ variationIdMap,
+ bucketingId,
+ } = mockBucket.mock.calls[call][0];
+ let expectedTrafficAllocation = experiment.trafficAllocation;
+ if (experiment.cmab) {
+ expectedTrafficAllocation = [{
+ endOfRange: experiment.cmab.trafficAllocation,
+ entityId: CMAB_DUMMY_ENTITY_ID,
+ }];
+ }
+
+ expect(experimentId).toBe(experiment.id);
+ expect(experimentKey).toBe(experiment.key);
+ expect(userId).toBe(user.getUserId());
+ expect(trafficAllocationConfig).toEqual(expectedTrafficAllocation);
+ expect(experimentKeyMap).toBe(projectConfig.experimentKeyMap);
+ expect(experimentIdMap).toBe(projectConfig.experimentIdMap);
+ expect(groupIdMap).toBe(projectConfig.groupIdMap);
+ expect(variationIdMap).toBe(projectConfig.variationIdMap);
+ expect(bucketingId).toBe(bucketIdFromAttribute ? user.getAttributes()[CONTROL_ATTRIBUTES.BUCKETING_ID] : user.getUserId());
+};
+
+describe('DecisionService', () => {
+ describe('getVariation', function() {
+ beforeEach(() => {
+ mockBucket.mockClear();
+ });
+
+ it('should return the correct variation from bucketer for the given experiment key and user ID for a running experiment', () => {
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester'
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+
+ const fakeDecisionResponse = {
+ result: '111128',
+ reasons: [],
+ };
+
+ const experiment = config.experimentIdMap['111127'];
+
+ mockBucket.mockReturnValue(fakeDecisionResponse); // contains variation ID of the 'control' variation from `test_data`
+
+ const { decisionService } = getDecisionService();
+
+ const variation = decisionService.getVariation(config, experiment, user);
+
+ expect(variation.result).toBe('control');
+
+ expect(mockBucket).toHaveBeenCalledTimes(1);
+ verifyBucketCall(0, config, experiment, user);
+ });
+
+ it('should use $opt_bucketing_id attribute as bucketing id if provided', () => {
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ $opt_bucketing_id: 'test_bucketing_id',
+ },
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+
+ const fakeDecisionResponse = {
+ result: '111128',
+ reasons: [],
+ };
+
+ const experiment = config.experimentIdMap['111127'];
+
+ mockBucket.mockReturnValue(fakeDecisionResponse); // contains variation ID of the 'control' variation from `test_data`
+
+ const { decisionService } = getDecisionService();
+
+ const variation = decisionService.getVariation(config, experiment, user);
+
+ expect(variation.result).toBe('control');
+
+ expect(mockBucket).toHaveBeenCalledTimes(1);
+ verifyBucketCall(0, config, experiment, user, true);
+ });
+
+ it('should return the whitelisted variation if the user is whitelisted', function() {
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'user2'
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+
+ const experiment = config.experimentIdMap['122227'];
+
+ const { decisionService, logger } = getDecisionService({ logger: true });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+
+ expect(variation.result).toBe('variationWithAudience');
+ expect(mockBucket).not.toHaveBeenCalled();
+ expect(logger?.debug).toHaveBeenCalledTimes(1);
+ expect(logger?.info).toHaveBeenCalledTimes(1);
+
+ expect(logger?.debug).toHaveBeenNthCalledWith(1, USER_HAS_NO_FORCED_VARIATION, 'user2');
+ expect(logger?.info).toHaveBeenNthCalledWith(1, USER_FORCED_IN_VARIATION, 'user2', 'variationWithAudience');
+ });
+
+ it('should return null if the user does not meet audience conditions', () => {
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'user3'
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+
+ const experiment = config.experimentIdMap['122227'];
+
+ const { decisionService, logger } = getDecisionService({ logger: true });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+
+ expect(variation.result).toBe(null);
+ expect(mockBucket).not.toHaveBeenCalled();
+
+ expect(logger?.debug).toHaveBeenNthCalledWith(1, USER_HAS_NO_FORCED_VARIATION, 'user3');
+ expect(logger?.debug).toHaveBeenNthCalledWith(2, EVALUATING_AUDIENCES_COMBINED, 'experiment', 'testExperimentWithAudiences', JSON.stringify(["11154"]));
+
+ expect(logger?.info).toHaveBeenNthCalledWith(1, AUDIENCE_EVALUATION_RESULT_COMBINED, 'experiment', 'testExperimentWithAudiences', 'FALSE');
+ expect(logger?.info).toHaveBeenNthCalledWith(2, USER_NOT_IN_EXPERIMENT, 'user3', 'testExperimentWithAudiences');
+ });
+
+ it('should return the forced variation set using setForcedVariation \
+ in presence of a whitelisted variation', function() {
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'user2'
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+
+ const experiment = config.experimentIdMap['122227'];
+
+ const { decisionService } = getDecisionService();
+
+ const forcedVariation = 'controlWithAudience';
+
+ decisionService.setForcedVariation(config, experiment.key, user.getUserId(), forcedVariation);
+
+ const variation = decisionService.getVariation(config, experiment, user);
+ expect(variation.result).toBe(forcedVariation);
+
+ const whitelistedVariation = experiment.forcedVariations?.[user.getUserId()];
+ expect(whitelistedVariation).toBeDefined();
+ expect(whitelistedVariation).not.toEqual(forcedVariation);
+ });
+
+ it('should return the forced variation set using setForcedVariation \
+ even if user does not satisfy audience condition', function() {
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'user3', // no attributes are set, should not satisfy audience condition 11154
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+
+ const experiment = config.experimentIdMap['122227'];
+
+ const { decisionService } = getDecisionService();
+
+ const forcedVariation = 'controlWithAudience';
+
+ decisionService.setForcedVariation(config, experiment.key, user.getUserId(), forcedVariation);
+
+ const variation = decisionService.getVariation(config, experiment, user);
+ expect(variation.result).toBe(forcedVariation);
+ });
+
+ it('should return null if the experiment is not running', function() {
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'user1'
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+
+ const experiment = config.experimentIdMap['133337'];
+
+ const { decisionService, logger } = getDecisionService({ logger: true });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+
+ expect(variation.result).toBe(null);
+ expect(mockBucket).not.toHaveBeenCalled();
+ expect(logger?.info).toHaveBeenCalledTimes(1);
+ expect(logger?.info).toHaveBeenNthCalledWith(1, EXPERIMENT_NOT_RUNNING, 'testExperimentNotRunning');
+ });
+
+ it('should respect the sticky bucketing information for attributes when attributes.$opt_experiment_bucket_map is supplied', () => {
+ const fakeDecisionResponse = {
+ result: '111128',
+ reasons: [],
+ };
+
+ const config = createProjectConfig(cloneDeep(testData));
+ const experiment = config.experimentIdMap['111127'];
+
+ const attributes: UserAttributes = {
+ $opt_experiment_bucket_map: {
+ '111127': {
+ variation_id: '111129', // ID of the 'variation' variation
+ },
+ },
+ };
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'decision_service_user',
+ attributes,
+ });
+
+ mockBucket.mockReturnValue(fakeDecisionResponse); // contains variation ID of the 'control' variation from `test_data`
+
+ const { decisionService } = getDecisionService();
+
+ const variation = decisionService.getVariation(config, experiment, user);
+
+ expect(variation.result).toBe('variation');
+ expect(mockBucket).not.toHaveBeenCalled();
+ });
+
+ describe('when a user profile service is provided', function() {
+ beforeEach(() => {
+ mockBucket.mockClear();
+ });
+
+ it('should return the previously bucketed variation', () => {
+ const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true });
+
+ userProfileService?.lookup.mockReturnValue({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111129', // ID of the 'variation' variation
+ },
+ },
+ });
+
+ mockBucket.mockReturnValue({
+ result: '111128', // ID of the 'control' variation
+ reasons: [],
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+ const experiment = config.experimentIdMap['111127'];
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'decision_service_user',
+ });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+ expect(variation.result).toBe('variation');
+
+ expect(userProfileService?.lookup).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user');
+ expect(mockBucket).not.toHaveBeenCalled();
+
+ expect(logger?.debug).toHaveBeenCalledTimes(1);
+ expect(logger?.info).toHaveBeenCalledTimes(1);
+
+ expect(logger?.debug).toHaveBeenNthCalledWith(1, USER_HAS_NO_FORCED_VARIATION, 'decision_service_user');
+ expect(logger?.info).toHaveBeenNthCalledWith(1, RETURNING_STORED_VARIATION, 'variation', 'testExperiment', 'decision_service_user');
+ });
+
+ it('should bucket and save user profile if there was no prevously bucketed variation', function() {
+ mockBucket.mockReturnValue({
+ result: '111128', // ID of the 'control' variation
+ reasons: [],
+ });
+
+ const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true });
+
+ userProfileService?.lookup.mockReturnValue({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {},
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+ const experiment = config.experimentIdMap['111127'];
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'decision_service_user',
+ });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+ expect(variation.result).toBe('control');
+
+ expect(userProfileService?.lookup).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user');
+
+ expect(mockBucket).toHaveBeenCalledTimes(1);
+ verifyBucketCall(0, config, experiment, user);
+
+ expect(userProfileService?.save).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.save).toHaveBeenCalledWith({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128',
+ },
+ },
+ });
+ });
+
+ it('should bucket if the user profile service returns null', function() {
+ mockBucket.mockReturnValue({
+ result: '111128', // ID of the 'control' variation
+ reasons: [],
+ });
+
+ const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true });
+
+ userProfileService?.lookup.mockReturnValue(null);
+
+ const config = createProjectConfig(cloneDeep(testData));
+ const experiment = config.experimentIdMap['111127'];
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'decision_service_user',
+ });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+ expect(variation.result).toBe('control');
+
+ expect(userProfileService?.lookup).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user');
+
+ expect(mockBucket).toHaveBeenCalledTimes(1);
+ verifyBucketCall(0, config, experiment, user);
+
+ expect(userProfileService?.save).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.save).toHaveBeenCalledWith({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128',
+ },
+ },
+ });
+ });
+
+ it('should re-bucket if the stored variation is no longer valid', function() {
+ mockBucket.mockReturnValue({
+ result: '111128', // ID of the 'control' variation
+ reasons: [],
+ });
+
+ const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true });
+
+ userProfileService?.lookup.mockReturnValue({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: 'not valid variation',
+ },
+ },
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+ const experiment = config.experimentIdMap['111127'];
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'decision_service_user',
+ });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+ expect(variation.result).toBe('control');
+
+ expect(userProfileService?.lookup).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user');
+
+ expect(mockBucket).toHaveBeenCalledTimes(1);
+ verifyBucketCall(0, config, experiment, user);
+
+ expect(logger?.debug).toHaveBeenCalledWith(USER_HAS_NO_FORCED_VARIATION, 'decision_service_user');
+ expect(logger?.info).toHaveBeenCalledWith(SAVED_VARIATION_NOT_FOUND, 'decision_service_user', 'not valid variation', 'testExperiment');
+
+ expect(userProfileService?.save).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.save).toHaveBeenCalledWith({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128',
+ },
+ },
+ });
+ });
+
+ it('should store the bucketed variation for the user', function() {
+ mockBucket.mockReturnValue({
+ result: '111128', // ID of the 'control' variation
+ reasons: [],
+ });
+
+ const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true });
+
+ userProfileService?.lookup.mockReturnValue({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {},
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+ const experiment = config.experimentIdMap['111127'];
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'decision_service_user',
+ });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+ expect(variation.result).toBe('control');
+
+ expect(userProfileService?.lookup).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user');
+
+ expect(mockBucket).toHaveBeenCalledTimes(1);
+ verifyBucketCall(0, config, experiment, user);
+
+ expect(userProfileService?.save).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.save).toHaveBeenCalledWith({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128',
+ },
+ },
+ });
+ });
+
+
+ it('should log an error message and bucket if "lookup" throws an error', () => {
+ mockBucket.mockReturnValue({
+ result: '111128', // ID of the 'control' variation
+ reasons: [],
+ });
+
+ const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true });
+
+ userProfileService?.lookup.mockImplementation(() => {
+ throw new Error('I am an error');
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+ const experiment = config.experimentIdMap['111127'];
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'decision_service_user',
+ });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+ expect(variation.result).toBe('control');
+
+ expect(userProfileService?.lookup).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user');
+
+ expect(mockBucket).toHaveBeenCalledTimes(1);
+ verifyBucketCall(0, config, experiment, user);
+
+ expect(logger?.debug).toHaveBeenCalledWith(USER_HAS_NO_FORCED_VARIATION, 'decision_service_user');
+ expect(logger?.error).toHaveBeenCalledWith(USER_PROFILE_LOOKUP_ERROR, 'decision_service_user', 'I am an error');
+
+ expect(userProfileService?.save).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.save).toHaveBeenCalledWith({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128',
+ },
+ },
+ });
+ });
+
+ it('should log an error message if "save" throws an error', () => {
+ mockBucket.mockReturnValue({
+ result: '111128', // ID of the 'control' variation
+ reasons: [],
+ });
+
+ const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true });
+
+ userProfileService?.lookup.mockReturnValue(null);
+ userProfileService?.save.mockImplementation(() => {
+ throw new Error('I am an error');
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+ const experiment = config.experimentIdMap['111127'];
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'decision_service_user',
+ });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+ expect(variation.result).toBe('control');
+
+ expect(userProfileService?.lookup).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user');
+
+ expect(mockBucket).toHaveBeenCalledTimes(1);
+ verifyBucketCall(0, config, experiment, user);
+
+ expect(userProfileService?.save).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.save).toHaveBeenCalledWith({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128',
+ },
+ },
+ });
+
+ expect(logger?.error).toHaveBeenCalledWith(USER_PROFILE_SAVE_ERROR, 'decision_service_user', 'I am an error');
+ });
+
+ it('should respect $opt_experiment_bucket_map attribute over the userProfileService for the matching experiment id', function() {
+ const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true });
+
+ userProfileService?.lookup.mockReturnValue({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128', // ID of the 'control' variation
+ },
+ },
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+ const experiment = config.experimentIdMap['111127'];
+
+ const attributes: UserAttributes = {
+ $opt_experiment_bucket_map: {
+ '111127': {
+ variation_id: '111129', // ID of the 'variation' variation
+ },
+ },
+ };
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'decision_service_user',
+ attributes,
+ });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+ expect(variation.result).toBe('variation');
+ });
+
+ it('should ignore attributes for a different experiment id', function() {
+ const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true });
+
+ userProfileService?.lookup.mockReturnValue({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128', // ID of the 'control' variation
+ },
+ },
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+ const experiment = config.experimentIdMap['111127'];
+
+ const attributes: UserAttributes = {
+ $opt_experiment_bucket_map: {
+ '122227': {
+ variation_id: '111129', // ID of the 'variation' variation
+ },
+ },
+ };
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'decision_service_user',
+ attributes,
+ });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+ expect(variation.result).toBe('control');
+ });
+
+ it('should use $ opt_experiment_bucket_map attribute when the userProfile contains variations for other experiments', function() {
+ const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true });
+
+ userProfileService?.lookup.mockReturnValue({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '122227': {
+ variation_id: '122229', // ID of the 'variationWithAudience' variation
+ },
+ },
+ });
+
+ const config = createProjectConfig(cloneDeep(testData));
+ const experiment = config.experimentIdMap['111127'];
+
+ const attributes: UserAttributes = {
+ $opt_experiment_bucket_map: {
+ '111127': {
+ variation_id: '111129', // ID of the 'variation' variation
+ },
+ },
+ };
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'decision_service_user',
+ attributes,
+ });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+ expect(variation.result).toBe('variation');
+ });
+
+ it('should use attributes when the userProfileLookup returns null', function() {
+ const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true });
+
+ userProfileService?.lookup.mockReturnValue(null);
+
+ const config = createProjectConfig(cloneDeep(testData));
+ const experiment = config.experimentIdMap['111127'];
+
+ const attributes: UserAttributes = {
+ $opt_experiment_bucket_map: {
+ '111127': {
+ variation_id: '111129', // ID of the 'variation' variation
+ },
+ },
+ };
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'decision_service_user',
+ attributes,
+ });
+
+ const variation = decisionService.getVariation(config, experiment, user);
+ expect(variation.result).toBe('variation');
+ });
+ });
+ });
+
+ describe('getVariationForFeature - sync', () => {
+ beforeEach(() => {
+ mockBucket.mockReset();
+ });
+
+ it('should return variation from the first experiment for which a variation is available', () => {
+ const { decisionService } = getDecisionService();
+
+ const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation')
+ .mockImplementation((
+ op,
+ config,
+ experiment: any,
+ user,
+ decideOptions,
+ userProfileTracker: any,
+ ) => {
+ if (experiment.key === 'exp_2') {
+ return Value.of('sync', {
+ result: { variationKey: 'variation_2' },
+ reasons: [],
+ });
+ }
+ return Value.of('sync', {
+ result: {},
+ reasons: [],
+ });
+ });
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 40,
+ },
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const variation = decisionService.getVariationForFeature(config, feature, user);
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['exp_2'],
+ variation: config.variationIdMap['5002'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(resolveVariationSpy).toHaveBeenCalledTimes(2);
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(1,
+ 'sync', config, config.experimentKeyMap['exp_1'], user, expect.anything(), expect.anything());
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(2,
+ 'sync', config, config.experimentKeyMap['exp_2'], user, expect.anything(), expect.anything());
+ });
+
+ it('should return the variation forced for an experiment in the userContext if available', () => {
+ const { decisionService } = getDecisionService();
+
+ const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation')
+ .mockImplementation((
+ op,
+ config,
+ experiment: any,
+ user,
+ decideOptions,
+ userProfileTracker: any,
+ ) => {
+ if (experiment.key === 'exp_2') {
+ return Value.of('sync', {
+ result: { varationKey: 'variation_2' },
+ reasons: [],
+ });
+ }
+ return Value.of('sync', {
+ result: {},
+ reasons: [],
+ });
+ });
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 40,
+ },
+ });
+
+ user.setForcedDecision(
+ { flagKey: 'flag_1', ruleKey: 'exp_2' },
+ { variationKey: 'variation_5' }
+ );
+
+ const feature = config.featureKeyMap['flag_1'];
+ const variation = decisionService.getVariationForFeature(config, feature, user);
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['exp_2'],
+ variation: config.variationIdMap['5005'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+ });
+
+ it('should save the variation found for an experiment in the user profile', () => {
+ const { decisionService, userProfileService } = getDecisionService({ userProfileService: true });
+
+ const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation')
+ .mockImplementation((
+ op,
+ config,
+ experiment: any,
+ user,
+ decideOptions,
+ userProfileTracker: any,
+ ) => {
+ if (experiment.key === 'exp_2') {
+ const variation = 'variation_2';
+
+ userProfileTracker.userProfile[experiment.id] = {
+ variation_id: '5002',
+ };
+ userProfileTracker.isProfileUpdated = true;
+
+ return Value.of('sync', {
+ result: { variationKey: 'variation_2' },
+ reasons: [],
+ });
+ }
+ return Value.of('sync', {
+ result: {},
+ reasons: [],
+ });
+ });
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 40,
+ },
+ });
+
+ userProfileService?.lookup.mockImplementation((userId: string) => {
+ if (userId === 'tester') {
+ return {
+ user_id: 'tester',
+ experiment_bucket_map: {
+ '2001': {
+ variation_id: '5001',
+ },
+ },
+ };
+ }
+ return null;
+ });
+
+
+ const feature = config.featureKeyMap['flag_1'];
+ const variation = decisionService.getVariationForFeature(config, feature, user);
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['exp_2'],
+ variation: config.variationIdMap['5002'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(userProfileService?.lookup).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.lookup).toHaveBeenCalledWith('tester');
+
+ expect(userProfileService?.save).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.save).toHaveBeenCalledWith({
+ user_id: 'tester',
+ experiment_bucket_map: {
+ '2001': {
+ variation_id: '5001',
+ },
+ '2002': {
+ variation_id: '5002',
+ },
+ },
+ });
+ });
+
+ describe('when no variation is found for any experiment and a targeted delivery \
+ audience condition is satisfied', () => {
+ beforeEach(() => {
+ mockBucket.mockReset();
+ });
+
+ it('should return variation from the target delivery for which audience condition \
+ is satisfied if the user is bucketed into it', () => {
+ const { decisionService } = getDecisionService();
+
+ const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation')
+ .mockReturnValue(Value.of('sync', {
+ result: {},
+ reasons: [],
+ }));
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 55, // this should satisfy the audience condition for the targeted delivery with key delivery_2 and delivery_3
+ },
+ });
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey === 'delivery_2') {
+ return {
+ result: '5005',
+ reasons: [],
+ };
+ }
+ return {
+ result: null,
+ reasons: [],
+ };
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const variation = decisionService.getVariationForFeature(config, feature, user);
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['delivery_2'],
+ variation: config.variationIdMap['5005'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ });
+
+ expect(resolveVariationSpy).toHaveBeenCalledTimes(3);
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(1,
+ 'sync', config, config.experimentKeyMap['exp_1'], user, expect.anything(), expect.anything());
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(2,
+ 'sync', config, config.experimentKeyMap['exp_2'], user, expect.anything(), expect.anything());
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(3,
+ 'sync', config, config.experimentKeyMap['exp_3'], user, expect.anything(), expect.anything());
+
+ expect(mockBucket).toHaveBeenCalledTimes(1);
+ verifyBucketCall(0, config, config.experimentIdMap['3002'], user);
+ });
+
+ it('should return variation from the target delivery and use $opt_bucketing_id attribute as bucketing id', () => {
+ const { decisionService } = getDecisionService();
+
+ const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation')
+ .mockReturnValue(Value.of('sync', {
+ result: {},
+ reasons: [],
+ }));
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 55, // this should satisfy the audience condition for the targeted delivery with key delivery_2 and delivery_3
+ $opt_bucketing_id: 'test_bucketing_id',
+ },
+ });
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey === 'delivery_2') {
+ return {
+ result: '5005',
+ reasons: [],
+ };
+ }
+ return {
+ result: null,
+ reasons: [],
+ };
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const variation = decisionService.getVariationForFeature(config, feature, user);
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['delivery_2'],
+ variation: config.variationIdMap['5005'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ });
+
+ expect(resolveVariationSpy).toHaveBeenCalledTimes(3);
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(1,
+ 'sync', config, config.experimentKeyMap['exp_1'], user, expect.anything(), expect.anything());
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(2,
+ 'sync', config, config.experimentKeyMap['exp_2'], user, expect.anything(), expect.anything());
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(3,
+ 'sync', config, config.experimentKeyMap['exp_3'], user, expect.anything(), expect.anything());
+
+ expect(mockBucket).toHaveBeenCalledTimes(1);
+ verifyBucketCall(0, config, config.experimentIdMap['3002'], user, true);
+ });
+
+ it('should skip to everyone else targeting rule if the user is not bucketed \
+ into the targeted delivery for which audience condition is satisfied', () => {
+ const { decisionService } = getDecisionService();
+
+ const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation')
+ .mockReturnValue(Value.of('sync', {
+ result: {},
+ reasons: [],
+ }));
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 55, // this should satisfy the audience condition for the targeted delivery with key delivery_2 and delivery_3
+ },
+ });
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey === 'default-rollout-key') {
+ return {
+ result: '5007',
+ reasons: [],
+ };
+ }
+ return {
+ result: null,
+ reasons: [],
+ };
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const variation = decisionService.getVariationForFeature(config, feature, user);
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentIdMap['default-rollout-id'],
+ variation: config.variationIdMap['5007'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ });
+
+ expect(resolveVariationSpy).toHaveBeenCalledTimes(3);
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(1,
+ 'sync', config, config.experimentKeyMap['exp_1'], user, expect.anything(), expect.anything());
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(2,
+ 'sync', config, config.experimentKeyMap['exp_2'], user, expect.anything(), expect.anything());
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(3,
+ 'sync', config, config.experimentKeyMap['exp_3'], user, expect.anything(), expect.anything());
+
+ expect(mockBucket).toHaveBeenCalledTimes(2);
+ verifyBucketCall(0, config, config.experimentIdMap['3002'], user);
+ verifyBucketCall(1, config, config.experimentIdMap['default-rollout-id'], user);
+ });
+ });
+
+ it('should return the forced variation for targeted delivery rule when no variation \
+ is found for any experiment and a there is a forced decision \
+ for a targeted delivery in the userContext', () => {
+ const { decisionService } = getDecisionService();
+
+ const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation')
+ .mockImplementation((
+ op,
+ config,
+ experiment: any,
+ user,
+ decideOptions,
+ userProfileTracker: any,
+ ) => {
+ return Value.of('sync', {
+ result: {},
+ reasons: [],
+ });
+ });
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ });
+
+ user.setForcedDecision(
+ { flagKey: 'flag_1', ruleKey: 'delivery_2' },
+ { variationKey: 'variation_1' }
+ );
+
+ const feature = config.featureKeyMap['flag_1'];
+ const variation = decisionService.getVariationForFeature(config, feature, user);
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['delivery_2'],
+ variation: config.variationIdMap['5001'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ });
+ });
+
+ it('should return variation from the everyone else targeting rule if no variation \
+ is found for any experiment or targeted delivery', () => {
+ const { decisionService } = getDecisionService();
+
+ const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation')
+ .mockReturnValue(Value.of('sync', {
+ result: {},
+ reasons: [],
+ }));
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 100, // this should not satisfy any audience condition for any targeted delivery
+ },
+ });
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey === 'default-rollout-key') {
+ return {
+ result: '5007',
+ reasons: [],
+ };
+ }
+ return {
+ result: null,
+ reasons: [],
+ };
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const variation = decisionService.getVariationForFeature(config, feature, user);
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentIdMap['default-rollout-id'],
+ variation: config.variationIdMap['5007'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ });
+
+ expect(resolveVariationSpy).toHaveBeenCalledTimes(3);
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(1,
+ 'sync', config, config.experimentKeyMap['exp_1'], user, expect.anything(), expect.anything());
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(2,
+ 'sync', config, config.experimentKeyMap['exp_2'], user, expect.anything(), expect.anything());
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(3,
+ 'sync', config, config.experimentKeyMap['exp_3'], user, expect.anything(), expect.anything());
+
+ expect(mockBucket).toHaveBeenCalledTimes(1);
+ verifyBucketCall(0, config, config.experimentIdMap['default-rollout-id'], user);
+ });
+
+ it('should return null if no variation is found for any experiment, targeted delivery, or everyone else targeting rule', () => {
+ const { decisionService } = getDecisionService();
+
+ const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation')
+ .mockReturnValue(Value.of('sync', {
+ result: {},
+ reasons: [],
+ }));
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+ const rolloutId = config.featureKeyMap['flag_1'].rolloutId;
+ config.rolloutIdMap[rolloutId].experiments = []; // remove the experiments from the rollout
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 10,
+ },
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const variation = decisionService.getVariationForFeature(config, feature, user);
+
+ expect(variation.result).toEqual({
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ });
+
+ expect(resolveVariationSpy).toHaveBeenCalledTimes(3);
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(1,
+ 'sync', config, config.experimentKeyMap['exp_1'], user, expect.anything(), expect.anything());
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(2,
+ 'sync', config, config.experimentKeyMap['exp_2'], user, expect.anything(), expect.anything());
+ expect(resolveVariationSpy).toHaveBeenNthCalledWith(3,
+ 'sync', config, config.experimentKeyMap['exp_3'], user, expect.anything(), expect.anything());
+
+ expect(mockBucket).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ describe('resolveVariationForFeatureList - async', () => {
+ beforeEach(() => {
+ mockBucket.mockReset();
+ });
+
+ it('should return variation from the first experiment for which a variation is available', async () => {
+ const { decisionService } = getDecisionService();
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 15, // should satisfy audience condition for all experiments
+ },
+ });
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey === 'exp_2') {
+ return {
+ result: '5002',
+ reasons: [],
+ };
+ }
+ return {
+ result: null,
+ reasons: [],
+ };
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['exp_2'],
+ variation: config.variationIdMap['5002'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+ });
+
+ it('should not return variation and should not call cmab service \
+ for cmab experiment if user is not bucketed into it', async () => {
+ const { decisionService, cmabService } = getDecisionService();
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ country: 'BD',
+ age: 80, // should satisfy audience condition for exp_3 which is cmab and not others
+ },
+ });
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey == 'default-rollout-key') {
+ return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
+ }
+ return {
+ result: null,
+ reasons: [],
+ }
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['default-rollout-key'],
+ variation: config.variationIdMap['5007'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ });
+
+ verifyBucketCall(0, config, config.experimentKeyMap['exp_3'], user);
+ expect(cmabService.getDecision).not.toHaveBeenCalled();
+ });
+
+ it('should get decision from the cmab service if the experiment is a cmab experiment \
+ and user is bucketed into it', async () => {
+ const { decisionService, cmabService } = getDecisionService();
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ country: 'BD',
+ age: 80, // should satisfy audience condition for exp_3 which is cmab and not others
+ },
+ });
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey == 'exp_3') {
+ return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
+ }
+ return {
+ result: null,
+ reasons: [],
+ }
+ });
+
+ cmabService.getDecision.mockResolvedValue({
+ variationId: '5003',
+ cmabUuid: 'uuid-test',
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+ expect(variation.result).toEqual({
+ cmabUuid: 'uuid-test',
+ experiment: config.experimentKeyMap['exp_3'],
+ variation: config.variationIdMap['5003'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ verifyBucketCall(0, config, config.experimentKeyMap['exp_3'], user);
+
+ expect(cmabService.getDecision).toHaveBeenCalledTimes(1);
+ expect(cmabService.getDecision).toHaveBeenCalledWith(
+ config,
+ user,
+ '2003', // id of exp_3
+ {},
+ );
+ });
+
+ it('should pass the correct DecideOptionMap to cmabService', async () => {
+ const { decisionService, cmabService } = getDecisionService();
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ country: 'BD',
+ age: 80, // should satisfy audience condition for exp_3 which is cmab and not others
+ },
+ });
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey == 'exp_3') {
+ return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
+ }
+ return {
+ result: null,
+ reasons: [],
+ }
+ });
+
+ cmabService.getDecision.mockResolvedValue({
+ variationId: '5003',
+ cmabUuid: 'uuid-test',
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {
+ [OptimizelyDecideOption.RESET_CMAB_CACHE]: true,
+ [OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE]: true,
+ }).get();
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+ expect(variation.result).toEqual({
+ cmabUuid: 'uuid-test',
+ experiment: config.experimentKeyMap['exp_3'],
+ variation: config.variationIdMap['5003'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(cmabService.getDecision).toHaveBeenCalledTimes(1);
+ expect(cmabService.getDecision).toHaveBeenCalledWith(
+ config,
+ user,
+ '2003', // id of exp_3
+ {
+ [OptimizelyDecideOption.RESET_CMAB_CACHE]: true,
+ [OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE]: true,
+ },
+ );
+ });
+
+ it('should return error if cmab getDecision fails', async () => {
+ const { decisionService, cmabService } = getDecisionService();
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ country: 'BD',
+ age: 80, // should satisfy audience condition for exp_3 which is cmab and not others
+ },
+ });
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey == 'exp_3') {
+ return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
+ }
+ return {
+ result: null,
+ reasons: [],
+ }
+ });
+
+ cmabService.getDecision.mockRejectedValue(new Error('I am an error'));
+
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.error).toBe(true);
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['exp_3'],
+ variation: null,
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(variation.reasons).toContainEqual(
+ [CMAB_FETCH_FAILED, 'exp_3'],
+ );
+
+ expect(cmabService.getDecision).toHaveBeenCalledTimes(1);
+ expect(cmabService.getDecision).toHaveBeenCalledWith(
+ config,
+ user,
+ '2003', // id of exp_3
+ {},
+ );
+ });
+
+ it('should use userProfileServiceAsync if available and sync user profile service is unavialable', async () => {
+ const { decisionService, cmabService, userProfileServiceAsync } = getDecisionService({
+ userProfileService: false,
+ userProfileServiceAsync: true,
+ });
+
+ userProfileServiceAsync?.lookup.mockImplementation((userId: string) => {
+ if (userId === 'tester-1') {
+ return Promise.resolve({
+ user_id: 'tester-1',
+ experiment_bucket_map: {
+ '2003': {
+ variation_id: '5001',
+ },
+ },
+ });
+ }
+ return Promise.resolve(null);
+ });
+
+ userProfileServiceAsync?.save.mockImplementation(() => Promise.resolve());
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey == 'exp_3') {
+ return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
+ }
+ return {
+ result: null,
+ reasons: [],
+ }
+ });
+
+ cmabService.getDecision.mockResolvedValue({
+ variationId: '5003',
+ cmabUuid: 'uuid-test',
+ });
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user1 = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester-1',
+ attributes: {
+ country: 'BD',
+ age: 80, // should satisfy audience condition for exp_3 which is cmab and not others
+ },
+ });
+
+ const user2 = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester-2',
+ attributes: {
+ country: 'BD',
+ age: 80, // should satisfy audience condition for exp_3 which is cmab and not others
+ },
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user1, {}).get();
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['exp_3'],
+ variation: config.variationIdMap['5001'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(cmabService.getDecision).not.toHaveBeenCalled();
+ expect(userProfileServiceAsync?.lookup).toHaveBeenCalledTimes(1);
+ expect(userProfileServiceAsync?.lookup).toHaveBeenCalledWith('tester-1');
+
+ const value2 = decisionService.resolveVariationsForFeatureList('async', config, [feature], user2, {}).get();
+ expect(value2).toBeInstanceOf(Promise);
+
+ const variation2 = (await value2)[0];
+ expect(variation2.result).toEqual({
+ cmabUuid: 'uuid-test',
+ experiment: config.experimentKeyMap['exp_3'],
+ variation: config.variationIdMap['5003'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(userProfileServiceAsync?.lookup).toHaveBeenCalledTimes(2);
+ expect(userProfileServiceAsync?.lookup).toHaveBeenNthCalledWith(2, 'tester-2');
+ expect(userProfileServiceAsync?.save).toHaveBeenCalledTimes(1);
+ expect(userProfileServiceAsync?.save).toHaveBeenCalledWith({
+ user_id: 'tester-2',
+ experiment_bucket_map: {
+ '2003': {
+ variation_id: '5003',
+ },
+ },
+ });
+ });
+
+ it('should log error and perform normal decision fetch if async userProfile lookup fails', async () => {
+ const { decisionService, cmabService, userProfileServiceAsync, logger } = getDecisionService({
+ userProfileService: false,
+ userProfileServiceAsync: true,
+ logger: true,
+ });
+
+ userProfileServiceAsync?.lookup.mockImplementation((userId: string) => {
+ return Promise.reject(new Error('I am an error'));
+ });
+
+ userProfileServiceAsync?.save.mockImplementation(() => Promise.resolve());
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey == 'exp_3') {
+ return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
+ }
+ return {
+ result: null,
+ reasons: [],
+ }
+ });
+
+ cmabService.getDecision.mockResolvedValue({
+ variationId: '5003',
+ cmabUuid: 'uuid-test',
+ });
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ country: 'BD',
+ age: 80, // should satisfy audience condition for exp_3 which is cmab and not others
+ },
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.result).toEqual({
+ cmabUuid: 'uuid-test',
+ experiment: config.experimentKeyMap['exp_3'],
+ variation: config.variationIdMap['5003'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(userProfileServiceAsync?.lookup).toHaveBeenCalledWith('tester');
+ expect(cmabService.getDecision).toHaveBeenCalledTimes(1);
+ expect(cmabService.getDecision).toHaveBeenCalledWith(
+ config,
+ user,
+ '2003', // id of exp_3
+ {},
+ );
+
+ expect(logger?.error).toHaveBeenCalledTimes(1);
+ expect(logger?.error).toHaveBeenCalledWith(USER_PROFILE_LOOKUP_ERROR, 'tester', 'I am an error');
+ });
+
+ it('should log error async userProfile save fails', async () => {
+ const { decisionService, cmabService, userProfileServiceAsync, logger } = getDecisionService({
+ userProfileService: false,
+ userProfileServiceAsync: true,
+ logger: true,
+ });
+
+ userProfileServiceAsync?.lookup.mockResolvedValue(null);
+
+ userProfileServiceAsync?.save.mockRejectedValue(new Error('I am an error'));
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey == 'exp_3') {
+ return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
+ }
+ return {
+ result: null,
+ reasons: [],
+ }
+ });
+
+ cmabService.getDecision.mockResolvedValue({
+ variationId: '5003',
+ cmabUuid: 'uuid-test',
+ });
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ country: 'BD',
+ age: 80, // should satisfy audience condition for exp_3 which is cmab and not others
+ },
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.result).toEqual({
+ cmabUuid: 'uuid-test',
+ experiment: config.experimentKeyMap['exp_3'],
+ variation: config.variationIdMap['5003'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(userProfileServiceAsync?.lookup).toHaveBeenCalledWith('tester');
+ expect(cmabService.getDecision).toHaveBeenCalledTimes(1);
+ expect(cmabService.getDecision).toHaveBeenCalledWith(
+ config,
+ user,
+ '2003', // id of exp_3
+ {},
+ );
+
+ expect(userProfileServiceAsync?.save).toHaveBeenCalledTimes(1);
+ expect(userProfileServiceAsync?.save).toHaveBeenCalledWith({
+ user_id: 'tester',
+ experiment_bucket_map: {
+ '2003': {
+ variation_id: '5003',
+ },
+ },
+ });
+ expect(logger?.error).toHaveBeenCalledTimes(1);
+ expect(logger?.error).toHaveBeenCalledWith(USER_PROFILE_SAVE_ERROR, 'tester', 'I am an error');
+ });
+
+ it('should use the sync user profile service if both sync and async ups are provided', async () => {
+ const { decisionService, userProfileService, userProfileServiceAsync, cmabService } = getDecisionService({
+ userProfileService: true,
+ userProfileServiceAsync: true,
+ });
+
+ userProfileService?.lookup.mockReturnValue(null);
+ userProfileService?.save.mockReturnValue(null);
+
+ userProfileServiceAsync?.lookup.mockResolvedValue(null);
+ userProfileServiceAsync?.save.mockResolvedValue(null);
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey == 'exp_3') {
+ return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
+ }
+ return {
+ result: null,
+ reasons: [],
+ }
+ });
+
+ cmabService.getDecision.mockResolvedValue({
+ variationId: '5003',
+ cmabUuid: 'uuid-test',
+ });
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ country: 'BD',
+ age: 80, // should satisfy audience condition for exp_3 which is cmab and not others
+ },
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.result).toEqual({
+ cmabUuid: 'uuid-test',
+ experiment: config.experimentKeyMap['exp_3'],
+ variation: config.variationIdMap['5003'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(userProfileService?.lookup).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.lookup).toHaveBeenCalledWith('tester');
+
+ expect(userProfileService?.save).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.save).toHaveBeenCalledWith({
+ user_id: 'tester',
+ experiment_bucket_map: {
+ '2003': {
+ variation_id: '5003',
+ },
+ },
+ });
+
+ expect(userProfileServiceAsync?.lookup).not.toHaveBeenCalled();
+ expect(userProfileServiceAsync?.save).not.toHaveBeenCalled();
+ });
+
+ describe('holdout', () => {
+ beforeEach(async() => {
+ mockHoldoutToggle.mockReturnValue(true);
+ const actualBucketModule = (await vi.importActual('../bucketer')) as { bucket: typeof bucket };
+ mockBucket.mockImplementation(actualBucketModule.bucket);
+ });
+
+ it('should return holdout variation when user is bucketed into running holdout', async () => {
+ const { decisionService } = getDecisionService();
+ const config = createProjectConfig(getHoldoutTestDatafile());
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 20,
+ },
+ });
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.result).toEqual({
+ experiment: config.holdoutIdMap && config.holdoutIdMap['holdout_running_id'],
+ variation: config.variationIdMap['holdout_variation_running_id'],
+ decisionSource: DECISION_SOURCES.HOLDOUT,
+ });
+ });
+
+ it("should consider global holdout even if local holdout is present", async () => {
+ const { decisionService } = getDecisionService();
+ const datafile = getHoldoutTestDatafile();
+ const newEntry = {
+ id: 'holdout_included_id',
+ key: 'holdout_included',
+ status: 'Running',
+ includedFlags: ['1001'],
+ excludedFlags: [],
+ audienceIds: ['4002'], // age_40 audience
+ audienceConditions: ['or', '4002'],
+ variations: [
+ {
+ id: 'holdout_variation_included_id',
+ key: 'holdout_variation_included',
+ variables: [],
+ },
+ ],
+ trafficAllocation: [
+ {
+ entityId: 'holdout_variation_included_id',
+ endOfRange: 5000,
+ },
+ ],
+ };
+ datafile.holdouts = [newEntry, ...datafile.holdouts];
+ const config = createProjectConfig(datafile);
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 20, // satisfies both global holdout (age_22) and included holdout (age_40) audiences
+ },
+ });
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.result).toEqual({
+ experiment: config.holdoutIdMap && config.holdoutIdMap['holdout_running_id'],
+ variation: config.variationIdMap['holdout_variation_running_id'],
+ decisionSource: DECISION_SOURCES.HOLDOUT,
+ });
+ });
+
+ it("should consider local holdout if misses global holdout", async () => {
+ const { decisionService } = getDecisionService();
+ const datafile = getHoldoutTestDatafile();
+
+ datafile.holdouts.push({
+ id: 'holdout_included_specific_id',
+ key: 'holdout_included_specific',
+ status: 'Running',
+ includedFlags: ['1001'],
+ excludedFlags: [],
+ audienceIds: ['4002'], // age_60 audience (age <= 60)
+ audienceConditions: ['or', '4002'],
+ variations: [
+ {
+ id: 'holdout_variation_included_specific_id',
+ key: 'holdout_variation_included_specific',
+ variables: []
+ }
+ ],
+ trafficAllocation: [
+ {
+ entityId: 'holdout_variation_included_specific_id',
+ endOfRange: 5000
+ }
+ ]
+ });
+ const config = createProjectConfig(datafile);
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'test_holdout_user',
+ attributes: {
+ age: 50, // Does not satisfy global holdout (age_22, age <= 22) but satisfies included holdout (age_60, age <= 60)
+ },
+ });
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.result).toEqual({
+ experiment: config.holdoutIdMap && config.holdoutIdMap['holdout_included_specific_id'],
+ variation: config.variationIdMap['holdout_variation_included_specific_id'],
+ decisionSource: DECISION_SOURCES.HOLDOUT,
+ });
+ });
+
+ it('should fallback to experiment when holdout status is not running', async () => {
+ const { decisionService } = getDecisionService();
+ const datafile = getHoldoutTestDatafile();
+
+ datafile.holdouts = datafile.holdouts.map((holdout: Holdout) => {
+ if(holdout.id === 'holdout_running_id') {
+ return {
+ ...holdout,
+ status: "Draft"
+ }
+ }
+ return holdout;
+ });
+
+ const config = createProjectConfig(datafile);
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 15,
+ },
+ });
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['exp_1'],
+ variation: config.variationIdMap['5001'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+ });
+
+ it('should fallback to experiment when user does not meet holdout audience conditions', async () => {
+ const { decisionService } = getDecisionService();
+ const config = createProjectConfig(getHoldoutTestDatafile());
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 30, // does not satisfy age_22 audience condition for holdout_running
+ },
+ });
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['exp_2'],
+ variation: config.variationIdMap['5002'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+ });
+
+ it('should fallback to experiment when user is not bucketed into holdout traffic', async () => {
+ const { decisionService } = getDecisionService();
+ const config = createProjectConfig(getHoldoutTestDatafile());
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 50,
+ },
+ });
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['exp_2'],
+ variation: config.variationIdMap['5002'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+ });
+
+ it('should fallback to rollout when no holdout or experiment matches', async () => {
+ const { decisionService } = getDecisionService();
+ const datafile = getHoldoutTestDatafile();
+ // Modify the datafile to create proper audience conditions for this test
+ // Make exp_1 and exp_2 use age conditions that won't match our test user
+ datafile.audiences = datafile.audiences.map((audience: any) => {
+ if (audience.id === '4001') { // age_22
+ return {
+ ...audience,
+ conditions: JSON.stringify(["or", {"match": "exact", "name": "age", "type": "custom_attribute", "value": 22}])
+ };
+ }
+ if (audience.id === '4002') { // age_60
+ return {
+ ...audience,
+ conditions: JSON.stringify(["or", {"match": "exact", "name": "age", "type": "custom_attribute", "value": 60}])
+ };
+ }
+ return audience;
+ });
+
+ // Make exp_2 use a different audience so it won't conflict with delivery_2
+ datafile.experiments = datafile.experiments.map((experiment: any) => {
+ if (experiment.key === 'exp_2') {
+ return {
+ ...experiment,
+ audienceIds: ['4001'], // Change from 4002 to 4001 (age_22)
+ audienceConditions: ['or', '4001']
+ };
+ }
+ return experiment;
+ });
+
+ const config = createProjectConfig(datafile);
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 60, // matches audience 4002 (age_60) used by delivery_2, but not experiments
+ },
+ });
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['delivery_2'],
+ variation: config.variationIdMap['5005'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ });
+ });
+
+ it('should skip holdouts excluded for specific flags', async () => {
+ const { decisionService } = getDecisionService();
+ const datafile = getHoldoutTestDatafile();
+
+ datafile.holdouts = datafile.holdouts.map((holdout: any) => {
+ if(holdout.id === 'holdout_running_id') {
+ return {
+ ...holdout,
+ excludedFlags: ['1001']
+ }
+ }
+ return holdout;
+ });
+
+ const config = createProjectConfig(datafile);
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 15, // satisfies age_22 audience condition (age <= 22) for global holdout, but holdout excludes flag_1
+ },
+ });
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['exp_1'],
+ variation: config.variationIdMap['5001'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+ });
+
+ it('should handle multiple holdouts and use first matching one', async () => {
+ const { decisionService } = getDecisionService();
+ const datafile = getHoldoutTestDatafile();
+
+ datafile.holdouts.push({
+ id: 'holdout_second_id',
+ key: 'holdout_second',
+ status: 'Running',
+ includedFlags: [],
+ excludedFlags: [],
+ audienceIds: [], // no audience requirements
+ audienceConditions: [],
+ variations: [
+ {
+ id: 'holdout_variation_second_id',
+ key: 'holdout_variation_second',
+ variables: []
+ }
+ ],
+ trafficAllocation: [
+ {
+ entityId: 'holdout_variation_second_id',
+ endOfRange: 5000
+ }
+ ]
+ });
+
+ const config = createProjectConfig(datafile);
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 20, // satisfies audience for holdout_running
+ },
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
+
+ expect(value).toBeInstanceOf(Promise);
+
+ const variation = (await value)[0];
+
+ expect(variation.result).toEqual({
+ experiment: config.holdoutIdMap && config.holdoutIdMap['holdout_running_id'],
+ variation: config.variationIdMap['holdout_variation_running_id'],
+ decisionSource: DECISION_SOURCES.HOLDOUT,
+ });
+ });
+ });
+ });
+
+ describe('resolveVariationForFeatureList - sync', () => {
+ beforeEach(() => {
+ mockBucket.mockReset();
+ });
+
+ it('should skip cmab experiments', async () => {
+ const { decisionService, cmabService } = getDecisionService();
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 15, // should satisfy audience condition for all experiments and targeted delivery
+ },
+ });
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey === 'delivery_1') {
+ return {
+ result: '5004',
+ reasons: [],
+ };
+ }
+ return {
+ result: null,
+ reasons: [],
+ };
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('sync', config, [feature], user, {}).get();
+
+ const variation = value[0];
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['delivery_1'],
+ variation: config.variationIdMap['5004'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ });
+
+ expect(mockBucket).toHaveBeenCalledTimes(3);
+ verifyBucketCall(0, config, config.experimentKeyMap['exp_1'], user);
+ verifyBucketCall(1, config, config.experimentKeyMap['exp_2'], user);
+ verifyBucketCall(2, config, config.experimentKeyMap['delivery_1'], user);
+
+ expect(cmabService.getDecision).not.toHaveBeenCalled();
+ });
+
+ it('should ignore async user profile service', async () => {
+ const { decisionService, userProfileServiceAsync } = getDecisionService({
+ userProfileService: false,
+ userProfileServiceAsync: true,
+ });
+
+ userProfileServiceAsync?.lookup.mockResolvedValue({
+ user_id: 'tester',
+ experiment_bucket_map: {
+ '2002': {
+ variation_id: '5001',
+ },
+ },
+ });
+ userProfileServiceAsync?.save.mockResolvedValue(null);
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey === 'exp_2') {
+ return {
+ result: '5002',
+ reasons: [],
+ };
+ }
+ return {
+ result: null,
+ reasons: [],
+ };
+ });
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 55, // should satisfy audience condition for exp_2 and exp_3
+ },
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('sync', config, [feature], user, {}).get();
+
+ const variation = value[0];
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['exp_2'],
+ variation: config.variationIdMap['5002'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(userProfileServiceAsync?.lookup).not.toHaveBeenCalled();
+ expect(userProfileServiceAsync?.save).not.toHaveBeenCalled();
+ });
+
+ it('should use sync user profile service', async () => {
+ const { decisionService, userProfileService, userProfileServiceAsync } = getDecisionService({
+ userProfileService: true,
+ userProfileServiceAsync: true,
+ });
+
+ userProfileService?.lookup.mockImplementation((userId: string) => {
+ if (userId === 'tester-1') {
+ return {
+ user_id: 'tester-1',
+ experiment_bucket_map: {
+ '2002': {
+ variation_id: '5001',
+ },
+ },
+ };
+ }
+ return null;
+ });
+
+ userProfileServiceAsync?.lookup.mockResolvedValue(null);
+ userProfileServiceAsync?.save.mockResolvedValue(null);
+
+ mockBucket.mockImplementation((param: BucketerParams) => {
+ const ruleKey = param.experimentKey;
+ if (ruleKey === 'exp_2') {
+ return {
+ result: '5002',
+ reasons: [],
+ };
+ }
+ return {
+ result: null,
+ reasons: [],
+ };
+ });
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user1 = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester-1',
+ attributes: {
+ age: 55, // should satisfy audience condition for exp_2 and exp_3
+ },
+ });
+
+ const feature = config.featureKeyMap['flag_1'];
+ const value = decisionService.resolveVariationsForFeatureList('sync', config, [feature], user1, {}).get();
+
+ const variation = value[0];
+
+ expect(variation.result).toEqual({
+ experiment: config.experimentKeyMap['exp_2'],
+ variation: config.variationIdMap['5001'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(userProfileService?.lookup).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.lookup).toHaveBeenCalledWith('tester-1');
+
+ const user2 = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester-2',
+ attributes: {
+ age: 55, // should satisfy audience condition for exp_2 and exp_3
+ },
+ });
+
+ const value2 = decisionService.resolveVariationsForFeatureList('sync', config, [feature], user2, {}).get();
+ const variation2 = value2[0];
+ expect(variation2.result).toEqual({
+ experiment: config.experimentKeyMap['exp_2'],
+ variation: config.variationIdMap['5002'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(userProfileService?.lookup).toHaveBeenCalledTimes(2);
+ expect(userProfileService?.lookup).toHaveBeenNthCalledWith(2, 'tester-2');
+ expect(userProfileService?.save).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.save).toHaveBeenCalledWith({
+ user_id: 'tester-2',
+ experiment_bucket_map: {
+ '2002': {
+ variation_id: '5002',
+ },
+ },
+ });
+
+ expect(userProfileServiceAsync?.lookup).not.toHaveBeenCalled();
+ expect(userProfileServiceAsync?.save).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('getVariationsForFeatureList', () => {
+ beforeEach(() => {
+ mockBucket.mockReset();
+ });
+
+ it('should return correct results for all features in the feature list', () => {
+ const { decisionService } = getDecisionService();
+
+ const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation')
+ .mockImplementation((
+ op,
+ config,
+ experiment: any,
+ user,
+ decideOptions,
+ userProfileTracker: any,
+ ) => {
+ if (experiment.key === 'exp_2') {
+ return Value.of('sync', {
+ result: { variationKey: 'variation_2' },
+ reasons: [],
+ });
+ } else if (experiment.key === 'exp_4') {
+ return Value.of('sync', {
+ result: { variationKey: 'variation_flag_2' },
+ reasons: [],
+ });
+ }
+ return Value.of('sync', {
+ result: {},
+ reasons: [],
+ });
+ });
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 40,
+ },
+ });
+
+ const featureList = [
+ config.featureKeyMap['flag_1'],
+ config.featureKeyMap['flag_2'],
+ ];
+
+ const variations = decisionService.getVariationsForFeatureList(config, featureList, user);
+
+ expect(variations[0].result).toEqual({
+ experiment: config.experimentKeyMap['exp_2'],
+ variation: config.variationIdMap['5002'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(variations[1].result).toEqual({
+ experiment: config.experimentKeyMap['exp_4'],
+ variation: config.variationIdMap['5100'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ const variations2 = decisionService.getVariationsForFeatureList(config, featureList.reverse(), user);
+
+ expect(variations2[0].result).toEqual({
+ experiment: config.experimentKeyMap['exp_4'],
+ variation: config.variationIdMap['5100'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(variations2[1].result).toEqual({
+ experiment: config.experimentKeyMap['exp_2'],
+ variation: config.variationIdMap['5002'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+ });
+
+ it('should batch user profile lookup and save', () => {
+ const { decisionService, userProfileService } = getDecisionService({ userProfileService: true });
+
+ const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation')
+ .mockImplementation((
+ op,
+ config,
+ experiment: any,
+ user,
+ decideOptions,
+ userProfileTracker: any,
+ ) => {
+ if (experiment.key === 'exp_2') {
+ userProfileTracker.userProfile[experiment.id] = {
+ variation_id: '5002',
+ };
+ userProfileTracker.isProfileUpdated = true;
+
+ return Value.of('sync', {
+ result: { variationKey: 'variation_2' },
+ reasons: [],
+ });
+ } else if (experiment.key === 'exp_4') {
+ userProfileTracker.userProfile[experiment.id] = {
+ variation_id: '5100',
+ };
+ userProfileTracker.isProfileUpdated = true;
+
+ return Value.of('sync', {
+ result: { variationKey: 'variation_flag_2' },
+ reasons: [],
+ });
+ }
+ return Value.of('sync', {
+ result: {},
+ reasons: [],
+ });
+ });
+
+ const config = createProjectConfig(getDecisionTestDatafile());
+
+ const user = new OptimizelyUserContext({
+ optimizely: {} as any,
+ userId: 'tester',
+ attributes: {
+ age: 40,
+ },
+ });
+
+ const featureList = [
+ config.featureKeyMap['flag_1'],
+ config.featureKeyMap['flag_2'],
+ ];
+
+ const variations = decisionService.getVariationsForFeatureList(config, featureList, user);
+
+ expect(variations[0].result).toEqual({
+ experiment: config.experimentKeyMap['exp_2'],
+ variation: config.variationIdMap['5002'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(variations[1].result).toEqual({
+ experiment: config.experimentKeyMap['exp_4'],
+ variation: config.variationIdMap['5100'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ });
+
+ expect(userProfileService?.lookup).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.lookup).toHaveBeenCalledWith('tester');
+
+ expect(userProfileService?.save).toHaveBeenCalledTimes(1);
+ expect(userProfileService?.save).toHaveBeenCalledWith({
+ user_id: 'tester',
+ experiment_bucket_map: {
+ '2002': {
+ variation_id: '5002',
+ },
+ '2004': {
+ variation_id: '5100',
+ },
+ },
+ });
+ });
+ });
+
+
+ describe('forced variation management', () => {
+ it('should return true for a valid forcedVariation in setForcedVariation', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ const didSetVariation = decisionService.setForcedVariation(
+ config,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ expect(didSetVariation).toBe(true);
+ });
+
+ it('should return the same variation from getVariation as was set in setVariation', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+ decisionService.setForcedVariation(
+ config,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+
+ const variation = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result;
+ expect(variation).toBe('control');
+ });
+
+ it('should return null from getVariation if no forced variation was set for a valid experimentKey', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ expect(config.experimentKeyMap['testExperiment']).toBeDefined();
+ const variation = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result;
+
+ expect(variation).toBe(null);
+ });
+
+ it('should return null from getVariation for an invalid experimentKey', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ expect(config.experimentKeyMap['definitely_not_valid_exp_key']).not.toBeDefined();
+ const variation = decisionService.getForcedVariation(config, 'definitely_not_valid_exp_key', 'user1').result;
+
+ expect(variation).toBe(null);
+ });
+
+ it('should return null when a forced decision is set on another experiment key', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ decisionService.setForcedVariation(config, 'testExperiment', 'user1', 'control');
+ const variation = decisionService.getForcedVariation(config, 'testExperimentLaunched', 'user1').result;
+ expect(variation).toBe(null);
+ });
+
+ it('should not set forced variation for an invalid variation key and return false', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ const wasSet = decisionService.setForcedVariation(
+ config,
+ 'testExperiment',
+ 'user1',
+ 'definitely_not_valid_variation_key'
+ );
+
+ expect(wasSet).toBe(false);
+ const variation = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result;
+ expect(variation).toBe(null);
+ });
+
+ it('should reset the forcedVariation if null is passed to setForcedVariation', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ const didSetVariation = decisionService.setForcedVariation(
+ config,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+
+ expect(didSetVariation).toBe(true);
+
+ let variation = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result;
+ expect(variation).toBe('control');
+
+ const didSetVariationAgain = decisionService.setForcedVariation(
+ config,
+ 'testExperiment',
+ 'user1',
+ null
+ );
+
+ expect(didSetVariationAgain).toBe(true);
+
+ variation = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result;
+ expect(variation).toBe(null);
+ });
+
+ it('should be able to add variations for multiple experiments for one user', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ const didSetVariation1 = decisionService.setForcedVariation(
+ config,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ expect(didSetVariation1).toBe(true);
+
+ const didSetVariation2 = decisionService.setForcedVariation(
+ config,
+ 'testExperimentLaunched',
+ 'user1',
+ 'controlLaunched'
+ );
+ expect(didSetVariation2).toBe(true);
+
+ const variation = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result;
+ const variation2 = decisionService.getForcedVariation(config, 'testExperimentLaunched', 'user1').result;
+ expect(variation).toBe('control');
+ expect(variation2).toBe('controlLaunched');
+ });
+
+ it('should be able to forced variation to same experiment for multiple users', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ const didSetVariation1 = decisionService.setForcedVariation(
+ config,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ expect(didSetVariation1).toBe(true);
+
+ const didSetVariation2 = decisionService.setForcedVariation(
+ config,
+ 'testExperiment',
+ 'user2',
+ 'variation'
+ );
+ expect(didSetVariation2).toBe(true);
+
+ const variationControl = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result;
+ const variationVariation = decisionService.getForcedVariation(config, 'testExperiment', 'user2').result;
+
+ expect(variationControl).toBe('control');
+ expect(variationVariation).toBe('variation');
+ });
+
+ it('should be able to reset a variation for a user with multiple experiments', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ // Set the first time
+ const didSetVariation1 = decisionService.setForcedVariation(
+ config,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ expect(didSetVariation1).toBe(true);
+
+ const didSetVariation2 = decisionService.setForcedVariation(
+ config,
+ 'testExperimentLaunched',
+ 'user1',
+ 'controlLaunched'
+ );
+ expect(didSetVariation2).toBe(true);
+
+ let variation1 = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result;
+ let variation2 = decisionService.getForcedVariation(config, 'testExperimentLaunched', 'user1').result;
+
+ expect(variation1).toBe('control');
+ expect(variation2).toBe('controlLaunched');
+
+ // Reset for one of the experiments
+ const didSetVariationAgain = decisionService.setForcedVariation(
+ config,
+ 'testExperiment',
+ 'user1',
+ 'variation'
+ );
+ expect(didSetVariationAgain).toBe(true);
+
+ variation1 = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result;
+ variation2 = decisionService.getForcedVariation(config, 'testExperimentLaunched', 'user1').result;
+
+ expect(variation1).toBe('variation');
+ expect(variation2).toBe('controlLaunched');
+ });
+
+ it('should be able to unset a variation for a user with multiple experiments', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ // Set the first time
+ const didSetVariation1 = decisionService.setForcedVariation(
+ config,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ expect(didSetVariation1).toBe(true);
+
+ const didSetVariation2 = decisionService.setForcedVariation(
+ config,
+ 'testExperimentLaunched',
+ 'user1',
+ 'controlLaunched'
+ );
+ expect(didSetVariation2).toBe(true);
+
+ let variation1 = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result;
+ let variation2 = decisionService.getForcedVariation(config, 'testExperimentLaunched', 'user1').result;
+
+ expect(variation1).toBe('control');
+ expect(variation2).toBe('controlLaunched');
+
+ // Unset for one of the experiments
+ decisionService.setForcedVariation(config, 'testExperiment', 'user1', null);
+
+ variation1 = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result;
+ variation2 = decisionService.getForcedVariation(config, 'testExperimentLaunched', 'user1').result;
+
+ expect(variation1).toBe(null);
+ expect(variation2).toBe('controlLaunched');
+ });
+
+ it('should return false for an empty variation key', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ const didSetVariation = decisionService.setForcedVariation(config, 'testExperiment', 'user1', '');
+ expect(didSetVariation).toBe(false);
+ });
+
+ it('should return null when a variation was previously set, and that variation no longer exists on the config object', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ const didSetVariation = decisionService.setForcedVariation(
+ config,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ expect(didSetVariation).toBe(true);
+
+ const newDatafile = cloneDeep(testData);
+ // Remove 'control' variation from variations, traffic allocation, and datafile forcedVariations.
+ newDatafile.experiments[0].variations = [
+ {
+ key: 'variation',
+ id: '111129',
+ },
+ ];
+ newDatafile.experiments[0].trafficAllocation = [
+ {
+ entityId: '111129',
+ endOfRange: 9000,
+ },
+ ];
+ newDatafile.experiments[0].forcedVariations = {
+ user1: 'variation',
+ user2: 'variation',
+ };
+ // Now the only variation in testExperiment is 'variation'
+ const newConfigObj = createProjectConfig(newDatafile);
+ const forcedVar = decisionService.getForcedVariation(newConfigObj, 'testExperiment', 'user1').result;
+ expect(forcedVar).toBe(null);
+ });
+
+ it("should return null when a variation was previously set, and that variation's experiment no longer exists on the config object", function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ const didSetVariation = decisionService.setForcedVariation(
+ config,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ expect(didSetVariation).toBe(true);
+
+ const newConfigObj = createProjectConfig(cloneDeep(testDataWithFeatures));
+ const forcedVar = decisionService.getForcedVariation(newConfigObj, 'testExperiment', 'user1').result;
+ expect(forcedVar).toBe(null);
+ });
+
+ it('should return false from setForcedVariation and not set for invalid experiment key', function() {
+ const config = createProjectConfig(cloneDeep(testData));
+ const { decisionService } = getDecisionService();
+
+ const didSetVariation = decisionService.setForcedVariation(
+ config,
+ 'definitelyNotAValidExperimentKey',
+ 'user1',
+ 'control'
+ );
+ expect(didSetVariation).toBe(false);
+
+ const variation = decisionService.getForcedVariation(
+ config,
+ 'definitelyNotAValidExperimentKey',
+ 'user1'
+ ).result;
+ expect(variation).toBe(null);
+ });
+ });
+});
diff --git a/packages/optimizely-sdk/lib/core/decision_service/index.tests.js b/lib/core/decision_service/index.tests.js
similarity index 62%
rename from packages/optimizely-sdk/lib/core/decision_service/index.tests.js
rename to lib/core/decision_service/index.tests.js
index 5f624a12f..346814857 100644
--- a/packages/optimizely-sdk/lib/core/decision_service/index.tests.js
+++ b/lib/core/decision_service/index.tests.js
@@ -1,18 +1,18 @@
-/****************************************************************************
- * Copyright 2017-2022 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. *
- * 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 2017-2022, 2024-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.
+ */
import sinon from 'sinon';
import { assert } from 'chai';
import cloneDeep from 'lodash/cloneDeep';
@@ -20,29 +20,65 @@ import { sprintf } from '../../utils/fns';
import { createDecisionService } from './';
import * as bucketer from '../bucketer';
+import * as bucketValueGenerator from '../bucketer/bucket_value_generator';
import {
LOG_LEVEL,
DECISION_SOURCES,
} from '../../utils/enums';
-import { createLogger } from '../../plugins/logger';
-import { createForwardingEventProcessor } from '../../plugins/event_processor/forwarding_event_processor';
-import { createNotificationCenter } from '../notification_center';
+import { getForwardingEventProcessor } from '../../event_processor/event_processor_factory';
+import { createNotificationCenter } from '../../notification_center';
import Optimizely from '../../optimizely';
import OptimizelyUserContext from '../../optimizely_user_context';
-import projectConfig from '../project_config';
+import projectConfig, { createProjectConfig } from '../../project_config/project_config';
import AudienceEvaluator from '../audience_evaluator';
-import errorHandler from '../../plugins/error_handler';
-import eventDispatcher from '../../plugins/event_dispatcher/index.node';
+import eventDispatcher from '../../event_processor/event_dispatcher/default_dispatcher.browser';
import * as jsonSchemaValidator from '../../utils/json_schema_validator';
+import { getMockProjectConfigManager } from '../../tests/mock/mock_project_config_manager';
+import { Value } from '../../utils/promise/operation_value';
+
import {
getTestProjectConfig,
getTestProjectConfigWithFeatures,
} from '../../tests/test_data';
+import {
+ USER_HAS_NO_FORCED_VARIATION,
+ VALID_BUCKETING_ID,
+ SAVED_USER_VARIATION,
+ SAVED_VARIATION_NOT_FOUND,
+} from 'log_message';
+
+import {
+ EXPERIMENT_NOT_RUNNING,
+ RETURNING_STORED_VARIATION,
+ USER_NOT_IN_EXPERIMENT,
+ USER_FORCED_IN_VARIATION,
+ EVALUATING_AUDIENCES_COMBINED,
+ AUDIENCE_EVALUATION_RESULT_COMBINED,
+ USER_IN_ROLLOUT,
+ USER_NOT_IN_ROLLOUT,
+ FEATURE_HAS_NO_EXPERIMENTS,
+ USER_DOESNT_MEET_CONDITIONS_FOR_TARGETING_RULE,
+ USER_NOT_BUCKETED_INTO_TARGETING_RULE,
+ USER_BUCKETED_INTO_TARGETING_RULE,
+ NO_ROLLOUT_EXISTS,
+ USER_MEETS_CONDITIONS_FOR_TARGETING_RULE,
+} from '../decision_service/index';
+
+import { BUCKETING_ID_NOT_STRING, USER_PROFILE_LOOKUP_ERROR, USER_PROFILE_SAVE_ERROR } from 'error_message';
+
var testData = getTestProjectConfig();
var testDataWithFeatures = getTestProjectConfigWithFeatures();
var buildLogMessageFromArgs = args => sprintf(args[1], ...args.splice(2));
+var createLogger = () => ({
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => createLogger(),
+})
+
describe('lib/core/decision_service', function() {
describe('APIs', function() {
var configObj = projectConfig.createProjectConfig(cloneDeep(testData));
@@ -54,7 +90,11 @@ describe('lib/core/decision_service', function() {
beforeEach(function() {
bucketerStub = sinon.stub(bucketer, 'bucket');
- sinon.stub(mockLogger, 'log');
+ sinon.stub(mockLogger, 'info');
+ sinon.stub(mockLogger, 'debug');
+ sinon.stub(mockLogger, 'warn');
+ sinon.stub(mockLogger, 'error');
+
decisionServiceInstance = createDecisionService({
logger: mockLogger,
});
@@ -62,12 +102,16 @@ describe('lib/core/decision_service', function() {
afterEach(function() {
bucketer.bucket.restore();
- mockLogger.log.restore();
+ mockLogger.debug.restore();
+ mockLogger.info.restore();
+ mockLogger.warn.restore();
+ mockLogger.error.restore();
});
describe('#getVariation', function() {
it('should return the correct variation for the given experiment key and user ID for a running experiment', function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'tester'
});
@@ -86,6 +130,7 @@ describe('lib/core/decision_service', function() {
it('should return the whitelisted variation if the user is whitelisted', function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user2'
});
@@ -95,19 +140,17 @@ describe('lib/core/decision_service', function() {
decisionServiceInstance.getVariation(configObj, experiment, user).result
);
sinon.assert.notCalled(bucketerStub);
- assert.strictEqual(2, mockLogger.log.callCount);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: User user2 is not in the forced variation map.'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[1]),
- 'DECISION_SERVICE: User user2 is forced in variation variationWithAudience.'
- );
+ assert.strictEqual(1, mockLogger.debug.callCount);
+ assert.strictEqual(1, mockLogger.info.callCount);
+
+ assert.deepEqual(mockLogger.debug.args[0], [USER_HAS_NO_FORCED_VARIATION, 'user2']);
+
+ assert.deepEqual(mockLogger.info.args[0], [USER_FORCED_IN_VARIATION, 'user2', 'variationWithAudience']);
});
it('should return null if the user does not meet audience conditions', function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user3'
});
@@ -115,38 +158,28 @@ describe('lib/core/decision_service', function() {
assert.isNull(
decisionServiceInstance.getVariation(configObj, experiment, user, { foo: 'bar' }).result
);
- assert.strictEqual(4, mockLogger.log.callCount);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: User user3 is not in the forced variation map.'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[1]),
- 'DECISION_SERVICE: Evaluating audiences for experiment "testExperimentWithAudiences": ["11154"].'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[2]),
- 'DECISION_SERVICE: Audiences for experiment testExperimentWithAudiences collectively evaluated to FALSE.'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[3]),
- 'DECISION_SERVICE: User user3 does not meet conditions to be in experiment testExperimentWithAudiences.'
- );
+
+ assert.deepEqual(mockLogger.debug.args[0], [USER_HAS_NO_FORCED_VARIATION, 'user3']);
+
+ assert.deepEqual(mockLogger.debug.args[1], [EVALUATING_AUDIENCES_COMBINED, 'experiment', 'testExperimentWithAudiences', JSON.stringify(["11154"])]);
+
+ assert.deepEqual(mockLogger.info.args[0], [AUDIENCE_EVALUATION_RESULT_COMBINED, 'experiment', 'testExperimentWithAudiences', 'FALSE']);
+
+ assert.deepEqual(mockLogger.info.args[1], [USER_NOT_IN_EXPERIMENT, 'user3', 'testExperimentWithAudiences']);
});
it('should return null if the experiment is not running', function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1'
});
experiment = configObj.experimentIdMap['133337'];
assert.isNull(decisionServiceInstance.getVariation(configObj, experiment, user).result);
sinon.assert.notCalled(bucketerStub);
- assert.strictEqual(1, mockLogger.log.callCount);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: Experiment testExperimentNotRunning is not running.'
- );
+ assert.strictEqual(1, mockLogger.info.callCount);
+
+ assert.deepEqual(mockLogger.info.args[0], [EXPERIMENT_NOT_RUNNING, 'testExperimentNotRunning']);
});
describe('when attributes.$opt_experiment_bucket_map is supplied', function() {
@@ -165,6 +198,7 @@ describe('lib/core/decision_service', function() {
},
};
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'decision_service_user',
attributes,
@@ -222,6 +256,7 @@ describe('lib/core/decision_service', function() {
});
experiment = configObj.experimentIdMap['111127'];
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'decision_service_user',
});
@@ -232,14 +267,10 @@ describe('lib/core/decision_service', function() {
);
sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
sinon.assert.notCalled(bucketerStub);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[1]),
- 'DECISION_SERVICE: Returning previously activated variation "control" of experiment "testExperiment" for user "decision_service_user" from user profile.'
- );
+
+ assert.deepEqual(mockLogger.debug.args[0], [USER_HAS_NO_FORCED_VARIATION, 'decision_service_user']);
+
+ assert.deepEqual(mockLogger.info.args[0], [RETURNING_STORED_VARIATION, 'control', 'testExperiment', 'decision_service_user']);
});
it('should bucket if there was no prevously bucketed variation', function() {
@@ -250,6 +281,7 @@ describe('lib/core/decision_service', function() {
});
experiment = configObj.experimentIdMap['111127'];
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'decision_service_user',
});
@@ -276,6 +308,7 @@ describe('lib/core/decision_service', function() {
userProfileLookupStub.returns(null);
experiment = configObj.experimentIdMap['111127'];
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'decision_service_user',
});
@@ -308,6 +341,7 @@ describe('lib/core/decision_service', function() {
});
experiment = configObj.experimentIdMap['111127'];
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'decision_service_user',
});
@@ -317,14 +351,20 @@ describe('lib/core/decision_service', function() {
);
sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
sinon.assert.calledOnce(bucketerStub);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[1]),
- 'DECISION_SERVICE: User decision_service_user was previously bucketed into variation with ID not valid variation for experiment testExperiment, but no matching variation was found.'
+ // assert.strictEqual(
+ // buildLogMessageFromArgs(mockLogger.log.args[0]),
+ // 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'
+ // );
+ assert.deepEqual(mockLogger.debug.args[0], [USER_HAS_NO_FORCED_VARIATION, 'decision_service_user']);
+
+ sinon.assert.calledWith(
+ mockLogger.info,
+ SAVED_VARIATION_NOT_FOUND,
+ 'decision_service_user',
+ 'not valid variation',
+ 'testExperiment'
);
+
// make sure we save the decision
sinon.assert.calledWith(userProfileSaveStub, {
user_id: 'decision_service_user',
@@ -343,6 +383,7 @@ describe('lib/core/decision_service', function() {
experiment_bucket_map: {}, // no decisions for user
});
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'decision_service_user',
});
@@ -354,7 +395,7 @@ describe('lib/core/decision_service', function() {
);
sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
sinon.assert.calledOnce(bucketerStub);
- assert.strictEqual(5, mockLogger.log.callCount);
+
sinon.assert.calledWith(userProfileServiceInstance.save, {
user_id: 'decision_service_user',
experiment_bucket_map: {
@@ -363,14 +404,11 @@ describe('lib/core/decision_service', function() {
},
},
});
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[4]),
- 'DECISION_SERVICE: Saved variation "control" of experiment "testExperiment" for user "decision_service_user".'
- );
+
+
+ assert.deepEqual(mockLogger.debug.args[0], [USER_HAS_NO_FORCED_VARIATION, 'decision_service_user']);
+
+ assert.deepEqual(mockLogger.info.lastCall.args, [SAVED_USER_VARIATION, 'decision_service_user']);
});
it('should log an error message if "lookup" throws an error', function() {
@@ -378,23 +416,21 @@ describe('lib/core/decision_service', function() {
userProfileLookupStub.throws(new Error('I am an error'));
experiment = configObj.experimentIdMap['111127'];
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'decision_service_user',
});
+
assert.strictEqual(
'control',
decisionServiceInstance.getVariation(configObj, experiment, user).result
);
sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
sinon.assert.calledOnce(bucketerStub); // should still go through with bucketing
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[1]),
- 'DECISION_SERVICE: Error while looking up user profile for user ID "decision_service_user": I am an error.'
- );
+
+ assert.deepEqual(mockLogger.error.args[0], [USER_PROFILE_LOOKUP_ERROR, 'decision_service_user', 'I am an error']);
+
+ assert.deepEqual(mockLogger.debug.args[0], [USER_HAS_NO_FORCED_VARIATION, 'decision_service_user']);
});
it('should log an error message if "save" throws an error', function() {
@@ -403,6 +439,7 @@ describe('lib/core/decision_service', function() {
userProfileSaveStub.throws(new Error('I am an error'));
experiment = configObj.experimentIdMap['111127'];
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'decision_service_user',
});
@@ -413,15 +450,10 @@ describe('lib/core/decision_service', function() {
sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
sinon.assert.calledOnce(bucketerStub); // should still go through with bucketing
- assert.strictEqual(5, mockLogger.log.callCount);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[4]),
- 'DECISION_SERVICE: Error while saving user profile for user ID "decision_service_user": I am an error.'
- );
+
+ assert.deepEqual(mockLogger.debug.args[0], [USER_HAS_NO_FORCED_VARIATION, 'decision_service_user']);
+
+ assert.deepEqual(mockLogger.error.args[0], [USER_PROFILE_SAVE_ERROR, 'decision_service_user', 'I am an error']);
// make sure that we save the decision
sinon.assert.calledWith(userProfileSaveStub, {
@@ -456,6 +488,7 @@ describe('lib/core/decision_service', function() {
experiment = configObj.experimentIdMap['111127'];
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'decision_service_user',
attributes,
@@ -467,14 +500,10 @@ describe('lib/core/decision_service', function() {
);
sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
sinon.assert.notCalled(bucketerStub);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[1]),
- 'DECISION_SERVICE: Returning previously activated variation "variation" of experiment "testExperiment" for user "decision_service_user" from user profile.'
- );
+
+ assert.deepEqual(mockLogger.debug.args[0], [USER_HAS_NO_FORCED_VARIATION, 'decision_service_user']);
+
+ assert.deepEqual(mockLogger.info.args[0], [RETURNING_STORED_VARIATION, 'variation', 'testExperiment', 'decision_service_user']);
});
it('should ignore attributes for a different experiment id', function() {
@@ -500,6 +529,7 @@ describe('lib/core/decision_service', function() {
};
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'decision_service_user',
attributes,
@@ -511,14 +541,10 @@ describe('lib/core/decision_service', function() {
);
sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
sinon.assert.notCalled(bucketerStub);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[1]),
- 'DECISION_SERVICE: Returning previously activated variation "control" of experiment "testExperiment" for user "decision_service_user" from user profile.'
- );
+
+ assert.deepEqual(mockLogger.debug.args[0], [USER_HAS_NO_FORCED_VARIATION, 'decision_service_user']);
+
+ assert.deepEqual(mockLogger.info.args[0], [RETURNING_STORED_VARIATION, 'control', 'testExperiment', 'decision_service_user']);
});
it('should use attributes when the userProfileLookup variations for other experiments', function() {
@@ -544,6 +570,7 @@ describe('lib/core/decision_service', function() {
};
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'decision_service_user',
attributes,
@@ -555,14 +582,10 @@ describe('lib/core/decision_service', function() {
);
sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
sinon.assert.notCalled(bucketerStub);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[1]),
- 'DECISION_SERVICE: Returning previously activated variation "variation" of experiment "testExperiment" for user "decision_service_user" from user profile.'
- );
+
+ assert.deepEqual(mockLogger.debug.args[0], [USER_HAS_NO_FORCED_VARIATION, 'decision_service_user']);
+
+ assert.deepEqual(mockLogger.info.args[0], [RETURNING_STORED_VARIATION, 'variation', 'testExperiment', 'decision_service_user']);
});
it('should use attributes when the userProfileLookup returns null', function() {
@@ -579,6 +602,7 @@ describe('lib/core/decision_service', function() {
};
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'decision_service_user',
attributes,
@@ -590,14 +614,10 @@ describe('lib/core/decision_service', function() {
);
sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
sinon.assert.notCalled(bucketerStub);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[1]),
- 'DECISION_SERVICE: Returning previously activated variation "variation" of experiment "testExperiment" for user "decision_service_user" from user profile.'
- );
+
+ assert.deepEqual(mockLogger.debug.args[0], [USER_HAS_NO_FORCED_VARIATION, 'decision_service_user']);
+
+ assert.deepEqual(mockLogger.info.args[0], [RETURNING_STORED_VARIATION, 'variation', 'testExperiment', 'decision_service_user']);
});
});
});
@@ -633,21 +653,10 @@ describe('lib/core/decision_service', function() {
experimentIdMap: configObj.experimentIdMap,
experimentKeyMap: configObj.experimentKeyMap,
groupIdMap: configObj.groupIdMap,
+ validateEntity: true,
};
assert.deepEqual(bucketerParams, expectedParams);
-
- sinon.assert.notCalled(mockLogger.log);
- });
- });
-
- describe('checkIfExperimentIsActive', function() {
- it('should return true if experiment is running', function() {
- assert.isTrue(decisionServiceInstance.checkIfExperimentIsActive(configObj, 'testExperiment'));
- });
-
- it('should return false when experiment is not running', function() {
- assert.isFalse(decisionServiceInstance.checkIfExperimentIsActive(configObj, 'testExperimentNotRunning'));
});
});
@@ -673,15 +682,10 @@ describe('lib/core/decision_service', function() {
''
).result
);
- assert.strictEqual(2, mockLogger.log.callCount);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: Evaluating audiences for experiment "testExperimentWithAudiences": ["11154"].'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[1]),
- 'DECISION_SERVICE: Audiences for experiment testExperimentWithAudiences collectively evaluated to TRUE.'
- );
+
+ assert.deepEqual(mockLogger.debug.args[0], [EVALUATING_AUDIENCES_COMBINED, 'experiment', 'testExperimentWithAudiences', JSON.stringify(["11154"])]);
+
+ assert.deepEqual(mockLogger.info.args[0], [AUDIENCE_EVALUATION_RESULT_COMBINED, 'experiment', 'testExperimentWithAudiences', 'TRUE']);
});
it('should return decision response with result true when experiment has no audience', function() {
@@ -697,15 +701,9 @@ describe('lib/core/decision_service', function() {
);
assert.isTrue(__audienceEvaluateSpy.alwaysReturned(true));
- assert.strictEqual(2, mockLogger.log.callCount);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: Evaluating audiences for experiment "testExperiment": [].'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[1]),
- 'DECISION_SERVICE: Audiences for experiment testExperiment collectively evaluated to TRUE.'
- );
+ assert.deepEqual(mockLogger.debug.args[0], [EVALUATING_AUDIENCES_COMBINED, 'experiment', 'testExperiment', JSON.stringify([])]);
+
+ assert.deepEqual(mockLogger.info.args[0], [AUDIENCE_EVALUATION_RESULT_COMBINED, 'experiment', 'testExperiment', 'TRUE']);
});
it('should return decision response with result false when audience conditions can not be evaluated', function() {
@@ -721,15 +719,9 @@ describe('lib/core/decision_service', function() {
);
assert.isTrue(__audienceEvaluateSpy.alwaysReturned(false));
- assert.strictEqual(2, mockLogger.log.callCount);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: Evaluating audiences for experiment "testExperimentWithAudiences": ["11154"].'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[1]),
- 'DECISION_SERVICE: Audiences for experiment testExperimentWithAudiences collectively evaluated to FALSE.'
- );
+ assert.deepEqual(mockLogger.debug.args[0], [EVALUATING_AUDIENCES_COMBINED, 'experiment', 'testExperimentWithAudiences', JSON.stringify(["11154"])]);
+
+ assert.deepEqual(mockLogger.info.args[0], [AUDIENCE_EVALUATION_RESULT_COMBINED, 'experiment', 'testExperimentWithAudiences', 'FALSE']);
});
it('should return decision response with result false when audience conditions are not met', function() {
@@ -745,15 +737,10 @@ describe('lib/core/decision_service', function() {
);
assert.isTrue(__audienceEvaluateSpy.alwaysReturned(false));
- assert.strictEqual(2, mockLogger.log.callCount);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: Evaluating audiences for experiment "testExperimentWithAudiences": ["11154"].'
- );
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[1]),
- 'DECISION_SERVICE: Audiences for experiment testExperimentWithAudiences collectively evaluated to FALSE.'
- );
+
+ assert.deepEqual(mockLogger.debug.args[0], [EVALUATING_AUDIENCES_COMBINED, 'experiment', 'testExperimentWithAudiences', JSON.stringify(["11154"])]);
+
+ assert.deepEqual(mockLogger.info.args[0], [AUDIENCE_EVALUATION_RESULT_COMBINED, 'experiment', 'testExperimentWithAudiences', 'FALSE']);
});
});
@@ -1051,22 +1038,21 @@ describe('lib/core/decision_service', function() {
beforeEach(function() {
optlyInstance = new Optimizely({
clientEngine: 'node-sdk',
- datafile: cloneDeep(testData),
+ projectConfigManager: getMockProjectConfigManager({
+ initConfig: createProjectConfig(cloneDeep(testData))
+ }),
jsonSchemaValidator: jsonSchemaValidator,
isValidInstance: true,
logger: createdLogger,
- eventProcessor: createForwardingEventProcessor(eventDispatcher),
- notificationCenter: createNotificationCenter(createdLogger, errorHandler),
- errorHandler: errorHandler,
+ eventProcessor: getForwardingEventProcessor(eventDispatcher),
+ notificationCenter: createNotificationCenter(createdLogger),
});
sinon.stub(eventDispatcher, 'dispatchEvent');
- sinon.stub(errorHandler, 'handleError');
});
afterEach(function() {
eventDispatcher.dispatchEvent.restore();
- errorHandler.handleError.restore();
});
var testUserAttributes = {
@@ -1146,6 +1132,7 @@ describe('lib/core/decision_service', function() {
},
});
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'test_user',
attributes: userAttributesWithBucketingId,
@@ -1180,7 +1167,11 @@ describe('lib/core/decision_service', function() {
};
beforeEach(function() {
- sinon.stub(mockLogger, 'log');
+ sinon.stub(mockLogger, 'debug');
+ sinon.stub(mockLogger, 'info');
+ sinon.stub(mockLogger, 'warn');
+ sinon.stub(mockLogger, 'error');
+
configObj = projectConfig.createProjectConfig(cloneDeep(testData));
decisionService = createDecisionService({
logger: mockLogger,
@@ -1188,7 +1179,10 @@ describe('lib/core/decision_service', function() {
});
afterEach(function() {
- mockLogger.log.restore();
+ mockLogger.debug.restore();
+ mockLogger.info.restore();
+ mockLogger.warn.restore();
+ mockLogger.error.restore();
});
it('should return userId if bucketingId is not defined in user attributes', function() {
@@ -1198,17 +1192,13 @@ describe('lib/core/decision_service', function() {
it('should log warning in case of invalid bucketingId', function() {
assert.strictEqual(userId, decisionService.getBucketingId(userId, userAttributesWithInvalidBucketingId));
- assert.strictEqual(1, mockLogger.log.callCount);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[0]),
- 'DECISION_SERVICE: BucketingID attribute is not a string. Defaulted to userId'
- );
+ assert.deepEqual(mockLogger.warn.args[0], [BUCKETING_ID_NOT_STRING]);
});
it('should return correct bucketingId when provided in attributes', function() {
assert.strictEqual('123456789', decisionService.getBucketingId(userId, userAttributesWithBucketingId));
- assert.strictEqual(1, mockLogger.log.callCount);
- assert.strictEqual(buildLogMessageFromArgs(mockLogger.log.args[0]), 'DECISION_SERVICE: BucketingId is valid: "123456789"');
+ assert.strictEqual(1, mockLogger.debug.callCount);
+ assert.deepEqual(mockLogger.debug.args[0], [VALID_BUCKETING_ID, '123456789']);
});
});
@@ -1219,15 +1209,19 @@ describe('lib/core/decision_service', function() {
var sandbox;
var mockLogger = createLogger({ logLevel: LOG_LEVEL.INFO });
var fakeDecisionResponseWithArgs;
- var fakeDecisionResponse = {
- result: null,
+ var fakeDecisionResponse = Value.of('sync', {
+ result: {},
reasons: [],
- };
+ });
var user;
beforeEach(function() {
configObj = projectConfig.createProjectConfig(cloneDeep(testDataWithFeatures));
sandbox = sinon.sandbox.create();
- sandbox.stub(mockLogger, 'log');
+ sandbox.stub(mockLogger, 'debug');
+ sandbox.stub(mockLogger, 'info');
+ sandbox.stub(mockLogger, 'warn');
+ sandbox.stub(mockLogger, 'error');
+
decisionServiceInstance = createDecisionService({
logger: mockLogger,
});
@@ -1248,239 +1242,41 @@ describe('lib/core/decision_service', function() {
var experiment;
beforeEach(function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: {
test_attribute: 'test_value',
},
});
- fakeDecisionResponseWithArgs = {
- result: 'variation',
+ fakeDecisionResponseWithArgs = Value.of('sync', {
+ result: { variationKey: 'variation' },
reasons: [],
- };
+ });
experiment = configObj.experimentIdMap['594098'];
- getVariationStub = sandbox.stub(decisionServiceInstance, 'getVariation');
+ getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation');
getVariationStub.returns(fakeDecisionResponse);
- getVariationStub.withArgs(configObj, experiment, user).returns(fakeDecisionResponseWithArgs);
+ getVariationStub.withArgs('sync', configObj, experiment, user, sinon.match.any, sinon.match.any).returns(fakeDecisionResponseWithArgs);
});
it('returns a decision with a variation in the experiment the feature is attached to', function() {
var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
- var expectedDecision = {
- experiment: {
- forcedVariations: {},
- status: 'Running',
- key: 'testing_my_feature',
- id: '594098',
- variations: [
- {
- id: '594096',
- variables: [
- {
- id: '4792309476491264',
- value: '2',
- },
- {
- id: '5073784453201920',
- value: 'true',
- },
- {
- id: '5636734406623232',
- value: 'Buy me NOW',
- },
- {
- id: '6199684360044544',
- value: '20.25',
- },
- {
- id: '1547854156498475',
- value: '{ "num_buttons": 1, "text": "first variation"}',
- },
- ],
- featureEnabled: true,
- key: 'variation',
- },
- {
- id: '594097',
- variables: [
- {
- id: '4792309476491264',
- value: '10',
- },
- {
- id: '5073784453201920',
- value: 'false',
- },
- {
- id: '5636734406623232',
- value: 'Buy me',
- },
- {
- id: '6199684360044544',
- value: '50.55',
- },
- {
- id: '1547854156498475',
- value: '{ "num_buttons": 2, "text": "second variation"}',
- },
- ],
- featureEnabled: true,
- key: 'control',
- },
- {
- id: '594099',
- variables: [
- {
- id: '4792309476491264',
- value: '40',
- },
- {
- id: '5073784453201920',
- value: 'true',
- },
- {
- id: '5636734406623232',
- value: 'Buy me Later',
- },
- {
- id: '6199684360044544',
- value: '99.99',
- },
- {
- id: '1547854156498475',
- value: '{ "num_buttons": 3, "text": "third variation"}',
- },
- ],
- featureEnabled: false,
- key: 'variation2',
- },
- ],
- audienceIds: [],
- trafficAllocation: [
- { endOfRange: 5000, entityId: '594096' },
- { endOfRange: 10000, entityId: '594097' },
- ],
- layerId: '594093',
- variationKeyMap: {
- control: {
- id: '594097',
- variables: [
- {
- id: '4792309476491264',
- value: '10',
- },
- {
- id: '5073784453201920',
- value: 'false',
- },
- {
- id: '5636734406623232',
- value: 'Buy me',
- },
- {
- id: '6199684360044544',
- value: '50.55',
- },
- {
- id: '1547854156498475',
- value: '{ "num_buttons": 2, "text": "second variation"}',
- },
- ],
- featureEnabled: true,
- key: 'control',
- },
- variation: {
- id: '594096',
- variables: [
- {
- id: '4792309476491264',
- value: '2',
- },
- {
- id: '5073784453201920',
- value: 'true',
- },
- {
- id: '5636734406623232',
- value: 'Buy me NOW',
- },
- {
- id: '6199684360044544',
- value: '20.25',
- },
- {
- id: '1547854156498475',
- value: '{ "num_buttons": 1, "text": "first variation"}',
- },
- ],
- featureEnabled: true,
- key: 'variation',
- },
- variation2: {
- id: '594099',
- variables: [
- {
- id: '4792309476491264',
- value: '40',
- },
- {
- id: '5073784453201920',
- value: 'true',
- },
- {
- id: '5636734406623232',
- value: 'Buy me Later',
- },
- {
- id: '6199684360044544',
- value: '99.99',
- },
- {
- id: '1547854156498475',
- value: '{ "num_buttons": 3, "text": "third variation"}',
- },
- ],
- featureEnabled: false,
- key: 'variation2',
- },
- },
- },
- variation: {
- id: '594096',
- variables: [
- {
- id: '4792309476491264',
- value: '2',
- },
- {
- id: '5073784453201920',
- value: 'true',
- },
- {
- id: '5636734406623232',
- value: 'Buy me NOW',
- },
- {
- id: '6199684360044544',
- value: '20.25',
- },
- {
- id: '1547854156498475',
- value: '{ "num_buttons": 1, "text": "first variation"}',
- },
- ],
- featureEnabled: true,
- key: 'variation',
- },
+ const expectedDecision = {
+ cmabUuid: undefined,
+ experiment: configObj.experimentIdMap['594098'],
+ variation: configObj.variationIdMap['594096'],
decisionSource: DECISION_SOURCES.FEATURE_TEST,
};
+
assert.deepEqual(decision, expectedDecision);
- sinon.assert.calledWithExactly(
+ sinon.assert.calledWith(
getVariationStub,
+ 'sync',
configObj,
experiment,
user,
- {}
+ sinon.match.any,
+ sinon.match.any
);
});
});
@@ -1489,10 +1285,11 @@ describe('lib/core/decision_service', function() {
var getVariationStub;
beforeEach(function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
});
- getVariationStub = sandbox.stub(decisionServiceInstance, 'getVariation');
+ getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation');
getVariationStub.returns(fakeDecisionResponse);
});
@@ -1504,10 +1301,8 @@ describe('lib/core/decision_service', function() {
decisionSource: DECISION_SOURCES.ROLLOUT,
};
assert.deepEqual(decision, expectedDecision);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.lastCall.args),
- 'DECISION_SERVICE: User user1 is not in rollout of feature test_feature_for_experiment.'
- );
+
+ assert.deepEqual(mockLogger.debug.lastCall.args, [USER_NOT_IN_ROLLOUT, 'user1', 'test_feature_for_experiment']);
});
});
});
@@ -1523,14 +1318,15 @@ describe('lib/core/decision_service', function() {
var user;
beforeEach(function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
});
- fakeDecisionResponseWithArgs = {
- result: 'var',
+ fakeDecisionResponseWithArgs = Value.of('sync', {
+ result: { variationKey: 'var' },
reasons: [],
- };
- getVariationStub = sandbox.stub(decisionServiceInstance, 'getVariation');
+ });
+ getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation');
getVariationStub.returns(fakeDecisionResponseWithArgs);
getVariationStub.withArgs(configObj, 'exp_with_group', user).returns(fakeDecisionResponseWithArgs);
});
@@ -1538,42 +1334,12 @@ describe('lib/core/decision_service', function() {
it('returns a decision with a variation in an experiment in a group', function() {
var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
var expectedDecision = {
- experiment: {
- forcedVariations: {},
- status: 'Running',
- key: 'exp_with_group',
- id: '595010',
- variations: [
- { id: '595008', variables: [], key: 'var' },
- { id: '595009', variables: [], key: 'con' },
- ],
- audienceIds: [],
- trafficAllocation: [
- { endOfRange: 5000, entityId: '595008' },
- { endOfRange: 10000, entityId: '595009' },
- ],
- layerId: '595005',
- groupId: '595024',
- variationKeyMap: {
- con: {
- id: '595009',
- variables: [],
- key: 'con',
- },
- var: {
- id: '595008',
- variables: [],
- key: 'var',
- },
- },
- },
- variation: {
- id: '595008',
- variables: [],
- key: 'var',
- },
+ cmabUuid: undefined,
+ experiment: configObj.experimentIdMap['595010'],
+ variation: configObj.variationIdMap['595008'],
decisionSource: DECISION_SOURCES.FEATURE_TEST,
- };
+ };
+
assert.deepEqual(decision, expectedDecision);
});
});
@@ -1583,10 +1349,11 @@ describe('lib/core/decision_service', function() {
var user;
beforeEach(function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
});
- getVariationStub = sandbox.stub(decisionServiceInstance, 'getVariation');
+ getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation');
getVariationStub.returns(fakeDecisionResponse);
});
@@ -1598,10 +1365,8 @@ describe('lib/core/decision_service', function() {
decisionSource: DECISION_SOURCES.ROLLOUT,
};
assert.deepEqual(decision, expectedDecision);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.lastCall.args),
- 'DECISION_SERVICE: User user1 is not in rollout of feature feature_with_group.'
- );
+
+ assert.deepEqual(mockLogger.debug.lastCall.args, [USER_NOT_IN_ROLLOUT, 'user1', 'feature_with_group']);
});
it('returns null decision for group experiment not referenced by the feature', function() {
@@ -1614,10 +1379,9 @@ describe('lib/core/decision_service', function() {
};
assert.deepEqual(decision, expectedDecision);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: There is no rollout of feature %s.',
- 'DECISION_SERVICE', 'feature_exp_no_traffic'
+ mockLogger.debug,
+ NO_ROLLOUT_EXISTS,
+ 'feature_exp_no_traffic'
);
});
});
@@ -1642,127 +1406,34 @@ describe('lib/core/decision_service', function() {
it('returns a decision with a variation and experiment from the audience targeting rule', function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: { test_attribute: 'test_value' },
});
var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
var expectedDecision = {
- experiment: {
- forcedVariations: {},
- status: 'Not started',
- key: '594031',
- id: '594031',
- variations: [
- {
- id: '594032',
- variables: [
- {
- id: '4919852825313280',
- value: 'true',
- },
- {
- id: '5482802778734592',
- value: '395',
- },
- {
- id: '6045752732155904',
- value: '4.99',
- },
- {
- id: '6327227708866560',
- value: 'Hello audience',
- },
- {
- id: "8765345281230956",
- value: '{ "count": 2, "message": "Hello audience" }',
- },
- ],
- featureEnabled: true,
- key: '594032',
- },
- ],
- variationKeyMap: {
- 594032: {
- id: '594032',
- variables: [
- {
- id: '4919852825313280',
- value: 'true',
- },
- {
- id: '5482802778734592',
- value: '395',
- },
- {
- id: '6045752732155904',
- value: '4.99',
- },
- {
- id: '6327227708866560',
- value: 'Hello audience',
- },
- {
- id: "8765345281230956",
- value: '{ "count": 2, "message": "Hello audience" }',
- },
- ],
- featureEnabled: true,
- key: '594032',
- },
- },
- audienceIds: ['594017'],
- trafficAllocation: [{ endOfRange: 5000, entityId: '594032' }],
- layerId: '594030',
- },
- variation: {
- id: '594032',
- variables: [
- {
- id: '4919852825313280',
- value: 'true',
- },
- {
- id: '5482802778734592',
- value: '395',
- },
- {
- id: '6045752732155904',
- value: '4.99',
- },
- {
- id: '6327227708866560',
- value: 'Hello audience',
- },
- {
- id: "8765345281230956",
- value: '{ "count": 2, "message": "Hello audience" }',
- },
- ],
- featureEnabled: true,
- key: '594032',
- },
+ experiment: configObj.experimentIdMap['594031'],
+ variation: configObj.variationIdMap['594032'],
decisionSource: DECISION_SOURCES.ROLLOUT,
};
+
assert.deepEqual(decision, expectedDecision);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: User %s meets conditions for targeting rule %s.',
- 'DECISION_SERVICE', 'user1', 1
+ mockLogger.debug,
+ USER_MEETS_CONDITIONS_FOR_TARGETING_RULE,
+ 'user1', 1
);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: User %s bucketed into targeting rule %s.',
- 'DECISION_SERVICE', 'user1', 1
+ mockLogger.debug,
+ USER_BUCKETED_INTO_TARGETING_RULE,
+ 'user1', 1
);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: User %s is in rollout of feature %s.',
- 'DECISION_SERVICE', 'user1', 'test_feature'
+ mockLogger.debug,
+ USER_IN_ROLLOUT,
+ 'user1', 'test_feature'
);
});
});
@@ -1778,126 +1449,33 @@ describe('lib/core/decision_service', function() {
it('returns a decision with a variation and experiment from the everyone else targeting rule', function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: {},
});
var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
var expectedDecision = {
- experiment: {
- forcedVariations: {},
- status: 'Not started',
- key: '594037',
- id: '594037',
- variations: [
- {
- id: '594038',
- variables: [
- {
- id: '4919852825313280',
- value: 'false',
- },
- {
- id: '5482802778734592',
- value: '400',
- },
- {
- id: '6045752732155904',
- value: '14.99',
- },
- {
- id: '6327227708866560',
- value: 'Hello',
- },
- {
- id: '8765345281230956',
- value: '{ "count": 1, "message": "Hello" }',
- },
- ],
- featureEnabled: false,
- key: '594038',
- },
- ],
- audienceIds: [],
- trafficAllocation: [{ endOfRange: 0, entityId: '594038' }],
- layerId: '594030',
- variationKeyMap: {
- 594038: {
- id: '594038',
- variables: [
- {
- id: '4919852825313280',
- value: 'false',
- },
- {
- id: '5482802778734592',
- value: '400',
- },
- {
- id: '6045752732155904',
- value: '14.99',
- },
- {
- id: '6327227708866560',
- value: 'Hello',
- },
- {
- id: '8765345281230956',
- value: '{ "count": 1, "message": "Hello" }',
- },
- ],
- featureEnabled: false,
- key: '594038',
- },
- },
- },
- variation: {
- id: '594038',
- variables: [
- {
- id: '4919852825313280',
- value: 'false',
- },
- {
- id: '5482802778734592',
- value: '400',
- },
- {
- id: '6045752732155904',
- value: '14.99',
- },
- {
- id: '6327227708866560',
- value: 'Hello',
- },
- {
- id: '8765345281230956',
- value: '{ "count": 1, "message": "Hello" }',
- },
- ],
- featureEnabled: false,
- key: '594038',
- },
+ experiment: configObj.experimentIdMap['594037'],
+ variation: configObj.variationIdMap['594038'],
decisionSource: DECISION_SOURCES.ROLLOUT,
- };
+ };
+
assert.deepEqual(decision, expectedDecision);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: User %s does not meet conditions for targeting rule %s.',
- 'DECISION_SERVICE', 'user1', 1
+ mockLogger.debug,
+ USER_DOESNT_MEET_CONDITIONS_FOR_TARGETING_RULE,
+ 'user1', 1
);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: User %s bucketed into targeting rule %s.',
- 'DECISION_SERVICE', 'user1', 'Everyone Else'
+ mockLogger.debug,
+ USER_BUCKETED_INTO_TARGETING_RULE,
+ 'user1', 'Everyone Else'
);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: User %s is in rollout of feature %s.',
- 'DECISION_SERVICE', 'user1', 'test_feature'
+ mockLogger.debug,
+ USER_IN_ROLLOUT,
+ 'user1', 'test_feature'
);
});
});
@@ -1913,6 +1491,7 @@ describe('lib/core/decision_service', function() {
it('returns a decision with no variation, no experiment and source rollout', function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
});
@@ -1924,16 +1503,14 @@ describe('lib/core/decision_service', function() {
};
assert.deepEqual(decision, expectedDecision);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: User %s does not meet conditions for targeting rule %s.',
- 'DECISION_SERVICE', 'user1', 1
+ mockLogger.debug,
+ USER_DOESNT_MEET_CONDITIONS_FOR_TARGETING_RULE,
+ 'user1', 1
);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: User %s is not in rollout of feature %s.',
- 'DECISION_SERVICE', 'user1', 'test_feature'
+ mockLogger.debug,
+ USER_NOT_IN_ROLLOUT,
+ 'user1', 'test_feature'
);
});
});
@@ -1960,126 +1537,33 @@ describe('lib/core/decision_service', function() {
it('returns a decision with a variation and experiment from the everyone else targeting rule', function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: { test_attribute: 'test_value' }
});
var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
var expectedDecision = {
- experiment: {
- forcedVariations: {},
- status: 'Not started',
- key: '594037',
- id: '594037',
- variations: [
- {
- id: '594038',
- variables: [
- {
- id: '4919852825313280',
- value: 'false',
- },
- {
- id: '5482802778734592',
- value: '400',
- },
- {
- id: '6045752732155904',
- value: '14.99',
- },
- {
- id: '6327227708866560',
- value: 'Hello',
- },
- {
- id: '8765345281230956',
- value: '{ "count": 1, "message": "Hello" }',
- },
- ],
- featureEnabled: false,
- key: '594038',
- },
- ],
- audienceIds: [],
- trafficAllocation: [{ endOfRange: 0, entityId: '594038' }],
- layerId: '594030',
- variationKeyMap: {
- 594038: {
- id: '594038',
- variables: [
- {
- id: '4919852825313280',
- value: 'false',
- },
- {
- id: '5482802778734592',
- value: '400',
- },
- {
- id: '6045752732155904',
- value: '14.99',
- },
- {
- id: '6327227708866560',
- value: 'Hello',
- },
- {
- id: '8765345281230956',
- value: '{ "count": 1, "message": "Hello" }',
- },
- ],
- featureEnabled: false,
- key: '594038',
- },
- },
- },
- variation: {
- id: '594038',
- variables: [
- {
- id: '4919852825313280',
- value: 'false',
- },
- {
- id: '5482802778734592',
- value: '400',
- },
- {
- id: '6045752732155904',
- value: '14.99',
- },
- {
- id: '6327227708866560',
- value: 'Hello',
- },
- {
- id: '8765345281230956',
- value: '{ "count": 1, "message": "Hello" }',
- },
- ],
- featureEnabled: false,
- key: '594038',
- },
+ experiment: configObj.experimentIdMap['594037'],
+ variation: configObj.variationIdMap['594038'],
decisionSource: DECISION_SOURCES.ROLLOUT,
};
+
assert.deepEqual(decision, expectedDecision);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: User %s meets conditions for targeting rule %s.',
- 'DECISION_SERVICE', 'user1', 1
+ mockLogger.debug,
+ USER_MEETS_CONDITIONS_FOR_TARGETING_RULE,
+ 'user1', 1
);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s User %s not bucketed into targeting rule %s due to traffic allocation. Trying everyone rule.',
- 'DECISION_SERVICE', 'user1', 1
+ mockLogger.debug,
+ USER_NOT_BUCKETED_INTO_TARGETING_RULE,
+ 'user1', 1
);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: User %s bucketed into targeting rule %s.',
- 'DECISION_SERVICE', 'user1', 'Everyone Else'
+ mockLogger.debug,
+ USER_BUCKETED_INTO_TARGETING_RULE,
+ 'user1', 'Everyone Else'
);
});
});
@@ -2089,10 +1573,10 @@ describe('lib/core/decision_service', function() {
var feature;
var getVariationStub;
var bucketStub;
- fakeDecisionResponse = {
- result: null,
+ fakeDecisionResponse = Value.of('sync', {
+ result: {},
reasons: [],
- };
+ });
var fakeBucketStubDecisionResponse = {
result: '599057',
reasons: [],
@@ -2109,88 +1593,27 @@ describe('lib/core/decision_service', function() {
// No attributes passed to the user context, so user is not in the audience for the experiment
// It should fall through to the rollout
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1'
});
var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
var expectedDecision = {
- experiment: {
- trafficAllocation: [
- {
- endOfRange: 10000,
- entityId: '599057',
- },
- ],
- layerId: '599055',
- forcedVariations: {},
- audienceIds: [],
- variations: [
- {
- key: '599057',
- id: '599057',
- featureEnabled: true,
- variables: [
- {
- id: '4937719889264640',
- value: '200',
- },
- {
- id: '6345094772817920',
- value: "i'm a rollout",
- },
- ],
- },
- ],
- status: 'Not started',
- key: '599056',
- id: '599056',
- variationKeyMap: {
- 599057: {
- key: '599057',
- id: '599057',
- featureEnabled: true,
- variables: [
- {
- id: '4937719889264640',
- value: '200',
- },
- {
- id: '6345094772817920',
- value: "i'm a rollout",
- },
- ],
- },
- },
- },
- variation: {
- key: '599057',
- id: '599057',
- featureEnabled: true,
- variables: [
- {
- id: '4937719889264640',
- value: '200',
- },
- {
- id: '6345094772817920',
- value: "i'm a rollout",
- },
- ],
- },
+ experiment: configObj.experimentIdMap['599056'],
+ variation: configObj.variationIdMap['599057'],
decisionSource: DECISION_SOURCES.ROLLOUT,
};
+
assert.deepEqual(decision, expectedDecision);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: User %s bucketed into targeting rule %s.',
- 'DECISION_SERVICE', 'user1', 'Everyone Else'
+ mockLogger.debug,
+ USER_BUCKETED_INTO_TARGETING_RULE,
+ 'user1', 'Everyone Else'
);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: User %s is in rollout of feature %s.',
- 'DECISION_SERVICE', 'user1', 'shared_feature'
+ mockLogger.debug,
+ USER_IN_ROLLOUT,
+ 'user1', 'shared_feature'
);
});
});
@@ -2203,6 +1626,7 @@ describe('lib/core/decision_service', function() {
it('returns a decision with no variation, no experiment and source rollout', function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1'
});
@@ -2214,16 +1638,14 @@ describe('lib/core/decision_service', function() {
};
assert.deepEqual(decision, expectedDecision);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: Feature %s is not attached to any experiments.',
- 'DECISION_SERVICE', 'unused_flag'
+ mockLogger.debug,
+ FEATURE_HAS_NO_EXPERIMENTS,
+ 'unused_flag'
);
sinon.assert.calledWithExactly(
- mockLogger.log,
- LOG_LEVEL.DEBUG,
- '%s: There is no rollout of feature %s.',
- 'DECISION_SERVICE', 'unused_flag'
+ mockLogger.debug,
+ NO_ROLLOUT_EXISTS,
+ 'unused_flag'
);
});
});
@@ -2233,12 +1655,13 @@ describe('lib/core/decision_service', function() {
var generateBucketValueStub;
beforeEach(function() {
feature = configObj.featureKeyMap.test_feature_in_exclusion_group;
- generateBucketValueStub = sandbox.stub(bucketer, '_generateBucketValue');
+ generateBucketValueStub = sandbox.stub(bucketValueGenerator, 'generateBucketValue');
});
it('returns a decision with a variation in mutex group bucket less than 2500', function() {
generateBucketValueStub.returns(2400);
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: { experiment_attr: 'group_experiment' }
@@ -2246,6 +1669,7 @@ describe('lib/core/decision_service', function() {
var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
var expectedExperiment = projectConfig.getExperimentFromKey(configObj, 'group_2_exp_1');
var expectedDecision = {
+ cmabUuid: undefined,
experiment: expectedExperiment,
variation: {
id: '38901',
@@ -2255,10 +1679,7 @@ describe('lib/core/decision_service', function() {
decisionSource: DECISION_SOURCES.FEATURE_TEST,
};
assert.deepEqual(decision, expectedDecision);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[3]),
- 'BUCKETER: Assigned bucket 2400 to user with bucketing ID user1.'
- );
+
sinon.assert.calledWithExactly(
generateBucketValueStub,
'user142222'
@@ -2268,6 +1689,7 @@ describe('lib/core/decision_service', function() {
it('returns a decision with a variation in mutex group bucket range 2500 to 5000', function() {
generateBucketValueStub.returns(4000);
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: { experiment_attr: 'group_experiment' }
@@ -2275,6 +1697,7 @@ describe('lib/core/decision_service', function() {
var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
var expectedExperiment = projectConfig.getExperimentFromKey(configObj, 'group_2_exp_2');
var expectedDecision = {
+ cmabUuid: undefined,
experiment: expectedExperiment,
variation: {
id: '38905',
@@ -2284,10 +1707,7 @@ describe('lib/core/decision_service', function() {
decisionSource: DECISION_SOURCES.FEATURE_TEST,
};
assert.deepEqual(decision, expectedDecision);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[3]),
- 'BUCKETER: Assigned bucket 4000 to user with bucketing ID user1.'
- );
+
sinon.assert.calledWithExactly(
generateBucketValueStub,
'user142223'
@@ -2297,6 +1717,7 @@ describe('lib/core/decision_service', function() {
it('returns a decision with a variation in mutex group bucket range 5000 to 7500', function() {
generateBucketValueStub.returns(6500);
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: { experiment_attr: 'group_experiment' }
@@ -2304,6 +1725,7 @@ describe('lib/core/decision_service', function() {
var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
var expectedExperiment = projectConfig.getExperimentFromKey(configObj, 'group_2_exp_3');
var expectedDecision = {
+ cmabUuid: undefined,
experiment: expectedExperiment,
variation: {
id: '38906',
@@ -2313,10 +1735,7 @@ describe('lib/core/decision_service', function() {
decisionSource: DECISION_SOURCES.FEATURE_TEST,
};
assert.deepEqual(decision, expectedDecision);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[3]),
- 'BUCKETER: Assigned bucket 6500 to user with bucketing ID user1.'
- );
+
sinon.assert.calledWithExactly(
generateBucketValueStub,
'user142224'
@@ -2326,6 +1745,7 @@ describe('lib/core/decision_service', function() {
it('returns a decision with variation and source rollout in mutex group bucket greater than 7500', function() {
generateBucketValueStub.returns(8000);
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: { experiment_attr: 'group_experiment' }
@@ -2334,36 +1754,11 @@ describe('lib/core/decision_service', function() {
var expectedExperiment = projectConfig.getExperimentFromId(configObj, '594066');
var expectedDecision = {
experiment: expectedExperiment,
- variation: {
- id: '594067',
- key: '594067',
- featureEnabled: true,
- variables: [
- {
- id: '5060590313668608',
- value: '30.34'
- },
- {
- id: '5342065290379264',
- value: 'Winter is coming definitely'
- },
- {
- id: '6186490220511232',
- value: '500'
- },
- {
- id: '6467965197221888',
- value: 'true'
- },
- ],
- },
+ variation: configObj.variationIdMap['594067'],
decisionSource: DECISION_SOURCES.ROLLOUT,
}
assert.deepEqual(decision, expectedDecision);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[3]),
- 'BUCKETER: Assigned bucket 8000 to user with bucketing ID user1.'
- );
+
sinon.assert.calledWithExactly(
generateBucketValueStub,
'user1594066'
@@ -2373,6 +1768,7 @@ describe('lib/core/decision_service', function() {
it('returns a decision with variation for rollout in mutex group with audience mismatch', function() {
generateBucketValueStub.returns(2400);
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: { experiment_attr: 'group_experiment_invalid' }
@@ -2381,36 +1777,11 @@ describe('lib/core/decision_service', function() {
var expectedExperiment = projectConfig.getExperimentFromId(configObj, '594066', mockLogger);
var expectedDecision = {
experiment: expectedExperiment,
- variation: {
- id: '594067',
- key: '594067',
- featureEnabled: true,
- variables: [
- {
- id: '5060590313668608',
- value: '30.34'
- },
- {
- id: '5342065290379264',
- value: 'Winter is coming definitely'
- },
- {
- id: '6186490220511232',
- value: '500'
- },
- {
- id: '6467965197221888',
- value: 'true'
- },
- ],
- },
+ variation: configObj.variationIdMap['594067'],
decisionSource: DECISION_SOURCES.ROLLOUT,
}
assert.deepEqual(decision, expectedDecision);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[18]),
- 'BUCKETER: Assigned bucket 2400 to user with bucketing ID user1.'
- );
+
sinon.assert.calledWithExactly(
generateBucketValueStub,
'user1594066'
@@ -2423,12 +1794,13 @@ describe('lib/core/decision_service', function() {
var generateBucketValueStub;
beforeEach(function() {
feature = configObj.featureKeyMap.test_feature_in_multiple_experiments;
- generateBucketValueStub = sandbox.stub(bucketer, '_generateBucketValue');
+ generateBucketValueStub = sandbox.stub(bucketValueGenerator, 'generateBucketValue');
});
it('returns a decision with a variation in mutex group bucket less than 2500', function() {
generateBucketValueStub.returns(2400);
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: { experiment_attr: 'group_experiment' }
@@ -2436,6 +1808,7 @@ describe('lib/core/decision_service', function() {
var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
var expectedExperiment = projectConfig.getExperimentFromKey(configObj, 'test_experiment3');
var expectedDecision = {
+ cmabUuid: undefined,
experiment: expectedExperiment,
variation: {
id: '222239',
@@ -2446,10 +1819,7 @@ describe('lib/core/decision_service', function() {
decisionSource: DECISION_SOURCES.FEATURE_TEST,
};
assert.deepEqual(decision, expectedDecision);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[3]),
- 'BUCKETER: Assigned bucket 2400 to user with bucketing ID user1.'
- );
+
sinon.assert.calledWithExactly(
generateBucketValueStub,
'user1111134'
@@ -2459,6 +1829,7 @@ describe('lib/core/decision_service', function() {
it('returns a decision with a variation in mutex group bucket range 2500 to 5000', function() {
generateBucketValueStub.returns(4000);
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: { experiment_attr: 'group_experiment' }
@@ -2466,6 +1837,7 @@ describe('lib/core/decision_service', function() {
var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
var expectedExperiment = projectConfig.getExperimentFromKey(configObj, 'test_experiment4');
var expectedDecision = {
+ cmabUuid: undefined,
experiment: expectedExperiment,
variation: {
id: '222240',
@@ -2476,10 +1848,7 @@ describe('lib/core/decision_service', function() {
decisionSource: DECISION_SOURCES.FEATURE_TEST,
};
assert.deepEqual(decision, expectedDecision);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[3]),
- 'BUCKETER: Assigned bucket 4000 to user with bucketing ID user1.'
- );
+
sinon.assert.calledWithExactly(
generateBucketValueStub,
'user1111135'
@@ -2489,6 +1858,7 @@ describe('lib/core/decision_service', function() {
it('returns a decision with a variation in mutex group bucket range 5000 to 7500', function() {
generateBucketValueStub.returns(6500);
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: { experiment_attr: 'group_experiment' }
@@ -2496,6 +1866,7 @@ describe('lib/core/decision_service', function() {
var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
var expectedExperiment = projectConfig.getExperimentFromKey(configObj, 'test_experiment5');
var expectedDecision = {
+ cmabUuid: undefined,
experiment: expectedExperiment,
variation: {
id: '222241',
@@ -2506,10 +1877,7 @@ describe('lib/core/decision_service', function() {
decisionSource: DECISION_SOURCES.FEATURE_TEST,
};
assert.deepEqual(decision, expectedDecision);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[3]),
- 'BUCKETER: Assigned bucket 6500 to user with bucketing ID user1.'
- );
+
sinon.assert.calledWithExactly(
generateBucketValueStub,
'user1111136'
@@ -2519,6 +1887,7 @@ describe('lib/core/decision_service', function() {
it('returns a decision with variation and source rollout in mutex group bucket greater than 7500', function() {
generateBucketValueStub.returns(8000);
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: { experiment_attr: 'group_experiment' }
@@ -2527,36 +1896,11 @@ describe('lib/core/decision_service', function() {
var expectedExperiment = projectConfig.getExperimentFromId(configObj, '594066');
var expectedDecision = {
experiment: expectedExperiment,
- variation: {
- id: '594067',
- key: '594067',
- featureEnabled: true,
- variables: [
- {
- id: '5060590313668608',
- value: '30.34'
- },
- {
- id: '5342065290379264',
- value: 'Winter is coming definitely'
- },
- {
- id: '6186490220511232',
- value: '500'
- },
- {
- id: '6467965197221888',
- value: 'true'
- },
- ],
- },
+ variation: configObj.variationIdMap['594067'],
decisionSource: DECISION_SOURCES.ROLLOUT,
}
assert.deepEqual(decision, expectedDecision);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[3]),
- 'BUCKETER: Assigned bucket 8000 to user with bucketing ID user1.'
- );
+
sinon.assert.calledWithExactly(
generateBucketValueStub,
'user1594066'
@@ -2566,6 +1910,7 @@ describe('lib/core/decision_service', function() {
it('returns a decision with variation for rollout in mutex group bucket range 2500 to 5000', function() {
generateBucketValueStub.returns(4000);
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'user1',
attributes: { experiment_attr: 'group_experiment_invalid' }
@@ -2574,36 +1919,11 @@ describe('lib/core/decision_service', function() {
var expectedExperiment = projectConfig.getExperimentFromId(configObj, '594066', mockLogger);
var expectedDecision = {
experiment: expectedExperiment,
- variation: {
- id: '594067',
- key: '594067',
- featureEnabled: true,
- variables: [
- {
- id: '5060590313668608',
- value: '30.34'
- },
- {
- id: '5342065290379264',
- value: 'Winter is coming definitely'
- },
- {
- id: '6186490220511232',
- value: '500'
- },
- {
- id: '6467965197221888',
- value: 'true'
- },
- ],
- },
+ variation: configObj.variationIdMap['594067'],
decisionSource: DECISION_SOURCES.ROLLOUT,
}
assert.deepEqual(decision, expectedDecision);
- assert.strictEqual(
- buildLogMessageFromArgs(mockLogger.log.args[18]),
- 'BUCKETER: Assigned bucket 4000 to user with bucketing ID user1.'
- );
+
sinon.assert.calledWithExactly(
generateBucketValueStub,
'user1594066'
@@ -2634,6 +1954,7 @@ describe('lib/core/decision_service', function() {
it('should call buildBucketerParams with user Id when bucketing Id is not provided in the attributes', function() {
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'testUser',
attributes: { test_attribute: 'test_value' }
@@ -2651,6 +1972,7 @@ describe('lib/core/decision_service', function() {
$opt_bucketing_id: 'abcdefg',
};
user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
optimizely: {},
userId: 'testUser',
attributes,
diff --git a/lib/core/decision_service/index.ts b/lib/core/decision_service/index.ts
new file mode 100644
index 000000000..057a0e129
--- /dev/null
+++ b/lib/core/decision_service/index.ts
@@ -0,0 +1,1697 @@
+/**
+ * Copyright 2017-2022, 2024-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.
+ */
+import { LoggerFacade } from '../../logging/logger'
+import { bucket } from '../bucketer';
+import {
+ AUDIENCE_EVALUATION_TYPES,
+ CONTROL_ATTRIBUTES,
+ DECISION_SOURCES,
+ DecisionSource,
+} from '../../utils/enums';
+import {
+ getAudiencesById,
+ getExperimentFromId,
+ getExperimentFromKey,
+ getFlagVariationByKey,
+ getVariationIdFromExperimentAndVariationKey,
+ getVariationFromId,
+ getVariationKeyFromId,
+ isActive,
+ ProjectConfig,
+ getHoldoutsForFlag,
+} from '../../project_config/project_config';
+import { AudienceEvaluator, createAudienceEvaluator } from '../audience_evaluator';
+import * as stringValidator from '../../utils/string_value_validator';
+import {
+ BucketerParams,
+ DecisionResponse,
+ Experiment,
+ ExperimentBucketMap,
+ ExperimentCore,
+ FeatureFlag,
+ Holdout,
+ OptimizelyDecideOption,
+ OptimizelyUserContext,
+ UserAttributes,
+ UserProfile,
+ UserProfileService,
+ UserProfileServiceAsync,
+ Variation,
+} from '../../shared_types';
+
+import {
+ INVALID_USER_ID,
+ INVALID_VARIATION_KEY,
+ NO_VARIATION_FOR_EXPERIMENT_KEY,
+ USER_NOT_IN_FORCED_VARIATION,
+ USER_PROFILE_LOOKUP_ERROR,
+ USER_PROFILE_SAVE_ERROR,
+ BUCKETING_ID_NOT_STRING,
+} from 'error_message';
+
+import {
+ SAVED_USER_VARIATION,
+ SAVED_VARIATION_NOT_FOUND,
+ USER_HAS_NO_FORCED_VARIATION,
+ USER_MAPPED_TO_FORCED_VARIATION,
+ USER_HAS_NO_FORCED_VARIATION_FOR_EXPERIMENT,
+ VALID_BUCKETING_ID,
+ VARIATION_REMOVED_FOR_USER,
+} from 'log_message';
+import { OptimizelyError } from '../../error/optimizly_error';
+import { CmabService } from './cmab/cmab_service';
+import { Maybe, OpType, OpValue } from '../../utils/type';
+import { Value } from '../../utils/promise/operation_value';
+import * as featureToggle from '../../feature_toggle';
+
+export const EXPERIMENT_NOT_RUNNING = 'Experiment %s is not running.';
+export const RETURNING_STORED_VARIATION =
+ 'Returning previously activated variation "%s" of experiment "%s" for user "%s" from user profile.';
+export const USER_NOT_IN_EXPERIMENT = 'User %s does not meet conditions to be in experiment %s.';
+export const USER_HAS_NO_VARIATION = 'User %s is in no variation of experiment %s.';
+export const USER_HAS_VARIATION = 'User %s is in variation %s of experiment %s.';
+export const USER_FORCED_IN_VARIATION = 'User %s is forced in variation %s.';
+export const FORCED_BUCKETING_FAILED = 'Variation key %s is not in datafile. Not activating user %s.';
+export const EVALUATING_AUDIENCES_COMBINED = 'Evaluating audiences for %s "%s": %s.';
+export const AUDIENCE_EVALUATION_RESULT_COMBINED = 'Audiences for %s %s collectively evaluated to %s.';
+export const USER_IN_ROLLOUT = 'User %s is in rollout of feature %s.';
+export const USER_NOT_IN_ROLLOUT = 'User %s is not in rollout of feature %s.';
+export const FEATURE_HAS_NO_EXPERIMENTS = 'Feature %s is not attached to any experiments.';
+export const USER_DOESNT_MEET_CONDITIONS_FOR_TARGETING_RULE =
+ 'User %s does not meet conditions for targeting rule %s.';
+export const USER_NOT_BUCKETED_INTO_TARGETING_RULE =
+'User %s not bucketed into targeting rule %s due to traffic allocation. Trying everyone rule.';
+export const USER_BUCKETED_INTO_TARGETING_RULE = 'User %s bucketed into targeting rule %s.';
+export const NO_ROLLOUT_EXISTS = 'There is no rollout of feature %s.';
+export const INVALID_ROLLOUT_ID = 'Invalid rollout ID %s attached to feature %s';
+export const ROLLOUT_HAS_NO_EXPERIMENTS = 'Rollout of feature %s has no experiments';
+export const IMPROPERLY_FORMATTED_EXPERIMENT = 'Experiment key %s is improperly formatted.';
+export const USER_HAS_FORCED_VARIATION =
+ 'Variation %s is mapped to experiment %s and user %s in the forced variation map.';
+export const USER_MEETS_CONDITIONS_FOR_TARGETING_RULE = 'User %s meets conditions for targeting rule %s.';
+export const USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED =
+ 'Variation (%s) is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.';
+export const USER_HAS_FORCED_DECISION_WITH_NO_RULE_SPECIFIED =
+ 'Variation (%s) is mapped to flag (%s) and user (%s) in the forced decision map.';
+export const USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID =
+ 'Invalid variation is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.';
+export const USER_HAS_FORCED_DECISION_WITH_NO_RULE_SPECIFIED_BUT_INVALID =
+ 'Invalid variation is mapped to flag (%s) and user (%s) in the forced decision map.';
+export const CMAB_NOT_SUPPORTED_IN_SYNC = 'CMAB is not supported in sync mode.';
+export const CMAB_FETCH_FAILED = 'Failed to fetch CMAB data for experiment %s.';
+export const CMAB_FETCHED_VARIATION_INVALID = 'Fetched variation %s for cmab experiment %s is invalid.';
+export const HOLDOUT_NOT_RUNNING = 'Holdout %s is not running.';
+export const USER_MEETS_CONDITIONS_FOR_HOLDOUT = 'User %s meets conditions for holdout %s.';
+export const USER_DOESNT_MEET_CONDITIONS_FOR_HOLDOUT = 'User %s does not meet conditions for holdout %s.';
+export const USER_BUCKETED_INTO_HOLDOUT_VARIATION = 'User %s is in variation %s of holdout %s.';
+export const USER_NOT_BUCKETED_INTO_HOLDOUT_VARIATION = 'User %s is in no holdout variation.';
+
+export interface DecisionObj {
+ experiment: Experiment | Holdout | null;
+ variation: Variation | null;
+ decisionSource: DecisionSource;
+ cmabUuid?: string;
+}
+
+interface DecisionServiceOptions {
+ userProfileService?: UserProfileService;
+ userProfileServiceAsync?: UserProfileServiceAsync;
+ logger?: LoggerFacade;
+ UNSTABLE_conditionEvaluators: unknown;
+ cmabService: CmabService;
+}
+
+interface DeliveryRuleResponse extends DecisionResponse {
+ skipToEveryoneElse: K;
+}
+
+interface UserProfileTracker {
+ userProfile: ExperimentBucketMap | null;
+ isProfileUpdated: boolean;
+}
+
+type VarationKeyWithCmabParams = {
+ variationKey?: string;
+ cmabUuid?: string;
+};
+export type DecisionReason = [string, ...any[]];
+export type VariationResult = DecisionResponse;
+export type DecisionResult = DecisionResponse;
+type VariationIdWithCmabParams = {
+ variationId? : string;
+ cmabUuid?: string;
+};
+export type DecideOptionsMap = Partial>;
+
+export const CMAB_DUMMY_ENTITY_ID= '$'
+
+/**
+ * Optimizely's decision service that determines which variation of an experiment the user will be allocated to.
+ *
+ * The decision service contains all logic around how a user decision is made. This includes all of the following (in order):
+ * 1. Checking experiment status
+ * 2. Checking forced bucketing
+ * 3. Checking whitelisting
+ * 4. Checking user profile service for past bucketing decisions (sticky bucketing)
+ * 5. Checking audience targeting
+ * 6. Using Murmurhash3 to bucket the user.
+ *
+ * @constructor
+ * @param {DecisionServiceOptions} options
+ * @returns {DecisionService}
+ */
+export class DecisionService {
+ private logger?: LoggerFacade;
+ private audienceEvaluator: AudienceEvaluator;
+ private forcedVariationMap: { [key: string]: { [id: string]: string } };
+ private userProfileService?: UserProfileService;
+ private userProfileServiceAsync?: UserProfileServiceAsync;
+ private cmabService: CmabService;
+
+ constructor(options: DecisionServiceOptions) {
+ this.logger = options.logger;
+ this.audienceEvaluator = createAudienceEvaluator(options.UNSTABLE_conditionEvaluators, this.logger);
+ this.forcedVariationMap = {};
+ this.userProfileService = options.userProfileService;
+ this.userProfileServiceAsync = options.userProfileServiceAsync;
+ this.cmabService = options.cmabService;
+ }
+
+ private isCmab(experiment: Experiment): boolean {
+ return !!experiment.cmab;
+ }
+
+ /**
+ * Resolves the variation into which the visitor will be bucketed.
+ *
+ * @param {ProjectConfig} configObj - The parsed project configuration object.
+ * @param {Experiment} experiment - The experiment for which the variation is being resolved.
+ * @param {OptimizelyUserContext} user - The user context associated with this decision.
+ * @returns {DecisionResponse} - A DecisionResponse containing the variation the user is bucketed into,
+ * along with the decision reasons.
+ */
+ private resolveVariation(
+ op: OP,
+ configObj: ProjectConfig,
+ experiment: Experiment,
+ user: OptimizelyUserContext,
+ decideOptions: DecideOptionsMap,
+ userProfileTracker?: UserProfileTracker,
+ ): Value {
+ const userId = user.getUserId();
+
+ const experimentKey = experiment.key;
+
+ if (!isActive(configObj, experimentKey)) {
+ this.logger?.info(EXPERIMENT_NOT_RUNNING, experimentKey);
+ return Value.of(op, {
+ result: {},
+ reasons: [[EXPERIMENT_NOT_RUNNING, experimentKey]],
+ });
+ }
+
+ const decideReasons: DecisionReason[] = [];
+
+ const decisionForcedVariation = this.getForcedVariation(configObj, experimentKey, userId);
+ decideReasons.push(...decisionForcedVariation.reasons);
+ const forcedVariationKey = decisionForcedVariation.result;
+
+ if (forcedVariationKey) {
+ return Value.of(op, {
+ result: { variationKey: forcedVariationKey },
+ reasons: decideReasons,
+ });
+ }
+
+ const decisionWhitelistedVariation = this.getWhitelistedVariation(experiment, userId);
+ decideReasons.push(...decisionWhitelistedVariation.reasons);
+ let variation = decisionWhitelistedVariation.result;
+ if (variation) {
+ return Value.of(op, {
+ result: { variationKey: variation.key },
+ reasons: decideReasons,
+ });
+ }
+
+ // check for sticky bucketing
+ if (userProfileTracker) {
+ variation = this.getStoredVariation(configObj, experiment, userId, userProfileTracker.userProfile);
+ if (variation) {
+ this.logger?.info(
+ RETURNING_STORED_VARIATION,
+ variation.key,
+ experimentKey,
+ userId,
+ );
+ decideReasons.push([
+ RETURNING_STORED_VARIATION,
+ variation.key,
+ experimentKey,
+ userId,
+ ]);
+ return Value.of(op, {
+ result: { variationKey: variation.key },
+ reasons: decideReasons,
+ });
+ }
+ }
+
+ const decisionifUserIsInAudience = this.checkIfUserIsInAudience(
+ configObj,
+ experiment,
+ AUDIENCE_EVALUATION_TYPES.EXPERIMENT,
+ user,
+ ''
+ );
+ decideReasons.push(...decisionifUserIsInAudience.reasons);
+ if (!decisionifUserIsInAudience.result) {
+ this.logger?.info(
+ USER_NOT_IN_EXPERIMENT,
+ userId,
+ experimentKey,
+ );
+ decideReasons.push([
+ USER_NOT_IN_EXPERIMENT,
+ userId,
+ experimentKey,
+ ]);
+ return Value.of(op, {
+ result: {},
+ reasons: decideReasons,
+ });
+ }
+
+ const decisionVariationValue = this.isCmab(experiment) ?
+ this.getDecisionForCmabExperiment(op, configObj, experiment, user, decideOptions) :
+ this.getDecisionFromBucketer(op, configObj, experiment, user);
+
+ return decisionVariationValue.then((variationResult): Value => {
+ decideReasons.push(...variationResult.reasons);
+ if (variationResult.error) {
+ return Value.of(op, {
+ error: true,
+ result: {},
+ reasons: decideReasons,
+ });
+ }
+
+ const variationId = variationResult.result.variationId;
+ variation = variationId ? configObj.variationIdMap[variationId] : null;
+ if (!variation) {
+ this.logger?.debug(
+ USER_HAS_NO_VARIATION,
+ userId,
+ experimentKey,
+ );
+ decideReasons.push([
+ USER_HAS_NO_VARIATION,
+ userId,
+ experimentKey,
+ ]);
+ return Value.of(op, {
+ result: {},
+ reasons: decideReasons,
+ });
+ }
+
+ this.logger?.info(
+ USER_HAS_VARIATION,
+ userId,
+ variation.key,
+ experimentKey,
+ );
+ decideReasons.push([
+ USER_HAS_VARIATION,
+ userId,
+ variation.key,
+ experimentKey,
+ ]);
+ // update experiment bucket map if decide options do not include shouldIgnoreUPS
+ if (userProfileTracker) {
+ this.updateUserProfile(experiment, variation, userProfileTracker);
+ }
+
+ return Value.of(op, {
+ result: { variationKey: variation.key, cmabUuid: variationResult.result.cmabUuid },
+ reasons: decideReasons,
+ });
+ });
+ }
+
+ private getDecisionForCmabExperiment(
+ op: OP,
+ configObj: ProjectConfig,
+ experiment: Experiment,
+ user: OptimizelyUserContext,
+ decideOptions: DecideOptionsMap,
+ ): Value> {
+ if (op === 'sync') {
+ return Value.of(op, {
+ error: false, // this is not considered an error, the evaluation should continue to next rule
+ result: {},
+ reasons: [[CMAB_NOT_SUPPORTED_IN_SYNC]],
+ });
+ }
+
+ const userId = user.getUserId();
+ const attributes = user.getAttributes();
+
+ const bucketingId = this.getBucketingId(userId, attributes);
+ const bucketerParams = this.buildBucketerParams(configObj, experiment, bucketingId, userId);
+
+ const bucketerResult = bucket(bucketerParams);
+
+ // this means the user is not in the cmab experiment
+ if (bucketerResult.result !== CMAB_DUMMY_ENTITY_ID) {
+ return Value.of(op, {
+ error: false,
+ result: {},
+ reasons: bucketerResult.reasons,
+ });
+ }
+
+ const cmabPromise = this.cmabService.getDecision(configObj, user, experiment.id, decideOptions).then(
+ (cmabDecision) => {
+ return {
+ error: false,
+ result: cmabDecision,
+ reasons: [] as DecisionReason[],
+ };
+ }
+ ).catch((ex: any) => {
+ this.logger?.error(CMAB_FETCH_FAILED, experiment.key);
+ return {
+ error: true,
+ result: {},
+ reasons: [[CMAB_FETCH_FAILED, experiment.key]] as DecisionReason[],
+ };
+ });
+
+ return Value.of(op, cmabPromise);
+ }
+
+ private getDecisionFromBucketer(
+ op: OP,
+ configObj: ProjectConfig,
+ experiment: Experiment,
+ user: OptimizelyUserContext
+ ): Value> {
+ const userId = user.getUserId();
+ const attributes = user.getAttributes();
+
+ // by default, the bucketing ID should be the user ID
+ const bucketingId = this.getBucketingId(userId, attributes);
+ const bucketerParams = this.buildBucketerParams(configObj, experiment, bucketingId, userId);
+
+ const decisionVariation = bucket(bucketerParams);
+ return Value.of(op, {
+ result: {
+ variationId: decisionVariation.result || undefined,
+ },
+ reasons: decisionVariation.reasons,
+ });
+ }
+
+ /**
+ * Gets variation where visitor will be bucketed.
+ * @param {ProjectConfig} configObj The parsed project configuration object
+ * @param {Experiment} experiment
+ * @param {OptimizelyUserContext} user A user context
+ * @param {[key: string]: boolean} options Optional map of decide options
+ * @return {DecisionResponse} DecisionResponse containing the variation the user is bucketed into
+ * and the decide reasons.
+ */
+ getVariation(
+ configObj: ProjectConfig,
+ experiment: Experiment,
+ user: OptimizelyUserContext,
+ options: DecideOptionsMap = {}
+ ): DecisionResponse {
+ const shouldIgnoreUPS = options[OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE];
+ const userProfileTracker: Maybe = shouldIgnoreUPS ? undefined
+ : {
+ isProfileUpdated: false,
+ userProfile: this.resolveExperimentBucketMap('sync', user.getUserId(), user.getAttributes()).get(),
+ };
+
+ const result = this.resolveVariation('sync', configObj, experiment, user, options, userProfileTracker).get();
+
+ if(userProfileTracker) {
+ this.saveUserProfile('sync', user.getUserId(), userProfileTracker)
+ }
+
+ return {
+ result: result.result.variationKey || null,
+ reasons: result.reasons,
+ }
+ }
+
+ /**
+ * Merges attributes from attributes[STICKY_BUCKETING_KEY] and userProfileService
+ * @param {string} userId
+ * @param {UserAttributes} attributes
+ * @return {ExperimentBucketMap} finalized copy of experiment_bucket_map
+ */
+ private resolveExperimentBucketMap(
+ op: OP,
+ userId: string,
+ attributes: UserAttributes = {},
+ ): Value {
+ const fromAttributes = (attributes[CONTROL_ATTRIBUTES.STICKY_BUCKETING_KEY] || {}) as any as ExperimentBucketMap;
+ return this.getUserProfile(op, userId).then((userProfile) => {
+ const fromUserProfileService = userProfile?.experiment_bucket_map || {};
+ return Value.of(op, {
+ ...fromUserProfileService,
+ ...fromAttributes,
+ });
+ });
+ }
+
+ /**
+ * Checks if user is whitelisted into any variation and return that variation if so
+ * @param {Experiment} experiment
+ * @param {string} userId
+ * @return {DecisionResponse} DecisionResponse containing the forced variation if it exists
+ * or user ID and the decide reasons.
+ */
+ private getWhitelistedVariation(
+ experiment: Experiment,
+ userId: string
+ ): DecisionResponse {
+ const decideReasons: DecisionReason[] = [];
+ if (experiment.forcedVariations && experiment.forcedVariations.hasOwnProperty(userId)) {
+ const forcedVariationKey = experiment.forcedVariations[userId];
+ if (experiment.variationKeyMap.hasOwnProperty(forcedVariationKey)) {
+ this.logger?.info(
+ USER_FORCED_IN_VARIATION,
+ userId,
+ forcedVariationKey,
+ );
+ decideReasons.push([
+ USER_FORCED_IN_VARIATION,
+ userId,
+ forcedVariationKey,
+ ]);
+ return {
+ result: experiment.variationKeyMap[forcedVariationKey],
+ reasons: decideReasons,
+ };
+ } else {
+ this.logger?.error(
+ FORCED_BUCKETING_FAILED,
+ forcedVariationKey,
+ userId,
+ );
+ decideReasons.push([
+ FORCED_BUCKETING_FAILED,
+ forcedVariationKey,
+ userId,
+ ]);
+ return {
+ result: null,
+ reasons: decideReasons,
+ };
+ }
+ }
+
+ return {
+ result: null,
+ reasons: decideReasons,
+ };
+ }
+
+ /**
+ * Checks whether the user is included in experiment audience
+ * @param {ProjectConfig} configObj The parsed project configuration object
+ * @param {string} experimentKey Key of experiment being validated
+ * @param {string} evaluationAttribute String representing experiment key or rule
+ * @param {string} userId ID of user
+ * @param {UserAttributes} attributes Optional parameter for user's attributes
+ * @param {string} loggingKey String representing experiment key or rollout rule. To be used in log messages only.
+ * @return {DecisionResponse} DecisionResponse DecisionResponse containing result true if user meets audience conditions and
+ * the decide reasons.
+ */
+ private checkIfUserIsInAudience(
+ configObj: ProjectConfig,
+ experiment: ExperimentCore,
+ evaluationAttribute: string,
+ user: OptimizelyUserContext,
+ loggingKey?: string | number,
+ ): DecisionResponse {
+ const decideReasons: DecisionReason[] = [];
+ const experimentAudienceConditions = experiment.audienceConditions || experiment.audienceIds;
+ const audiencesById = getAudiencesById(configObj);
+
+ this.logger?.debug(
+ EVALUATING_AUDIENCES_COMBINED,
+ evaluationAttribute,
+ loggingKey || experiment.key,
+ JSON.stringify(experimentAudienceConditions),
+ );
+ decideReasons.push([
+ EVALUATING_AUDIENCES_COMBINED,
+ evaluationAttribute,
+ loggingKey || experiment.key,
+ JSON.stringify(experimentAudienceConditions),
+ ]);
+
+ const result = this.audienceEvaluator.evaluate(experimentAudienceConditions, audiencesById, user);
+
+ this.logger?.info(
+ AUDIENCE_EVALUATION_RESULT_COMBINED,
+ evaluationAttribute,
+ loggingKey || experiment.key,
+ result.toString().toUpperCase(),
+ );
+ decideReasons.push([
+ AUDIENCE_EVALUATION_RESULT_COMBINED,
+ evaluationAttribute,
+ loggingKey || experiment.key,
+ result.toString().toUpperCase(),
+ ]);
+
+ return {
+ result: result,
+ reasons: decideReasons,
+ };
+ }
+
+ /**
+ * Given an experiment key and user ID, returns params used in bucketer call
+ * @param {ProjectConfig} configObj The parsed project configuration object
+ * @param {string} experimentKey Experiment key used for bucketer
+ * @param {string} bucketingId ID to bucket user into
+ * @param {string} userId ID of user to be bucketed
+ * @return {BucketerParams}
+ */
+ private buildBucketerParams(
+ configObj: ProjectConfig,
+ experiment: Experiment | Holdout,
+ bucketingId: string,
+ userId: string
+ ): BucketerParams {
+ let validateEntity = true;
+
+ let trafficAllocationConfig = experiment.trafficAllocation;
+
+ if ('cmab' in experiment && experiment.cmab) {
+ trafficAllocationConfig = [{
+ entityId: CMAB_DUMMY_ENTITY_ID,
+ endOfRange: experiment.cmab.trafficAllocation
+ }];
+
+ validateEntity = false;
+ }
+
+ return {
+ bucketingId,
+ experimentId: experiment.id,
+ experimentKey: experiment.key,
+ experimentIdMap: configObj.experimentIdMap,
+ experimentKeyMap: configObj.experimentKeyMap,
+ groupIdMap: configObj.groupIdMap,
+ logger: this.logger,
+ trafficAllocationConfig,
+ userId,
+ variationIdMap: configObj.variationIdMap,
+ validateEntity,
+ }
+ }
+
+ /**
+ * Determines if a user should be bucketed into a holdout variation.
+ * @param {ProjectConfig} configObj - The parsed project configuration object.
+ * @param {Holdout} holdout - The holdout to evaluate.
+ * @param {OptimizelyUserContext} user - The user context.
+ * @returns {DecisionResponse} - DecisionResponse containing holdout decision and reasons.
+ */
+ private getVariationForHoldout(
+ configObj: ProjectConfig,
+ holdout: Holdout,
+ user: OptimizelyUserContext,
+ ): DecisionResponse {
+ const userId = user.getUserId();
+ const decideReasons: DecisionReason[] = [];
+
+ if (holdout.status !== 'Running') {
+ const reason: DecisionReason = [HOLDOUT_NOT_RUNNING, holdout.key];
+ decideReasons.push(reason);
+ this.logger?.info(HOLDOUT_NOT_RUNNING, holdout.key);
+ return {
+ result: {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.HOLDOUT
+ },
+ reasons: decideReasons
+ };
+ }
+
+ const audienceResult = this.checkIfUserIsInAudience(
+ configObj,
+ holdout,
+ AUDIENCE_EVALUATION_TYPES.EXPERIMENT,
+ user
+ );
+ decideReasons.push(...audienceResult.reasons);
+
+ if (!audienceResult.result) {
+ const reason: DecisionReason = [USER_DOESNT_MEET_CONDITIONS_FOR_HOLDOUT, userId, holdout.key];
+ decideReasons.push(reason);
+ this.logger?.info(USER_DOESNT_MEET_CONDITIONS_FOR_HOLDOUT, userId, holdout.key);
+ return {
+ result: {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.HOLDOUT
+ },
+ reasons: decideReasons
+ };
+ }
+
+ const reason: DecisionReason = [USER_MEETS_CONDITIONS_FOR_HOLDOUT, userId, holdout.key];
+ decideReasons.push(reason);
+ this.logger?.info(USER_MEETS_CONDITIONS_FOR_HOLDOUT, userId, holdout.key);
+
+ const attributes = user.getAttributes();
+ const bucketingId = this.getBucketingId(userId, attributes);
+ const bucketerParams = this.buildBucketerParams(configObj, holdout, bucketingId, userId);
+ const bucketResult = bucket(bucketerParams);
+
+ decideReasons.push(...bucketResult.reasons);
+
+ if (bucketResult.result) {
+ const variation = configObj.variationIdMap[bucketResult.result];
+ if (variation) {
+ const bucketReason: DecisionReason = [USER_BUCKETED_INTO_HOLDOUT_VARIATION, userId, holdout.key, variation.key];
+ decideReasons.push(bucketReason);
+ this.logger?.info(USER_BUCKETED_INTO_HOLDOUT_VARIATION, userId, holdout.key, variation.key);
+
+ return {
+ result: {
+ experiment: holdout,
+ variation: variation,
+ decisionSource: DECISION_SOURCES.HOLDOUT
+ },
+ reasons: decideReasons
+ };
+ }
+ }
+
+ const noBucketReason: DecisionReason = [USER_NOT_BUCKETED_INTO_HOLDOUT_VARIATION, userId];
+ decideReasons.push(noBucketReason);
+ this.logger?.info(USER_NOT_BUCKETED_INTO_HOLDOUT_VARIATION, userId);
+ return {
+ result: {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.HOLDOUT
+ },
+ reasons: decideReasons
+ };
+ }
+
+ /**
+ * Pull the stored variation out of the experimentBucketMap for an experiment/userId
+ * @param {ProjectConfig} configObj The parsed project configuration object
+ * @param {Experiment} experiment
+ * @param {string} userId
+ * @param {ExperimentBucketMap} experimentBucketMap mapping experiment => { variation_id: }
+ * @return {Variation|null} the stored variation or null if the user profile does not have one for the given experiment
+ */
+ private getStoredVariation(
+ configObj: ProjectConfig,
+ experiment: Experiment,
+ userId: string,
+ experimentBucketMap: ExperimentBucketMap | null
+ ): Variation | null {
+ if (experimentBucketMap?.hasOwnProperty(experiment.id)) {
+ const decision = experimentBucketMap[experiment.id];
+ const variationId = decision.variation_id;
+ if (configObj.variationIdMap.hasOwnProperty(variationId)) {
+ return configObj.variationIdMap[decision.variation_id];
+ } else {
+ this.logger?.info(
+ SAVED_VARIATION_NOT_FOUND,
+ userId,
+ variationId,
+ experiment.key,
+ );
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the user profile with the given user ID
+ * @param {string} userId
+ * @return {UserProfile} the stored user profile or an empty profile if one isn't found or error
+ */
+ private getUserProfile(op: OP, userId: string): Value {
+ const emptyProfile = {
+ user_id: userId,
+ experiment_bucket_map: {},
+ };
+
+ if (this.userProfileService) {
+ try {
+ return Value.of(op, this.userProfileService.lookup(userId));
+ } catch (ex: any) {
+ this.logger?.error(
+ USER_PROFILE_LOOKUP_ERROR,
+ userId,
+ ex.message,
+ );
+ }
+ return Value.of(op, emptyProfile);
+ }
+
+ if (this.userProfileServiceAsync && op === 'async') {
+ return Value.of(op, this.userProfileServiceAsync.lookup(userId).catch((ex: any) => {
+ this.logger?.error(
+ USER_PROFILE_LOOKUP_ERROR,
+ userId,
+ ex.message,
+ );
+ return emptyProfile;
+ }));
+ }
+
+ return Value.of(op, emptyProfile);
+ }
+
+ private updateUserProfile(
+ experiment: Experiment,
+ variation: Variation,
+ userProfileTracker: UserProfileTracker
+ ): void {
+ if(!userProfileTracker.userProfile) {
+ return
+ }
+
+ userProfileTracker.userProfile[experiment.id] = {
+ variation_id: variation.id
+ }
+ userProfileTracker.isProfileUpdated = true
+ }
+
+ /**
+ * Saves the bucketing decision to the user profile
+ * @param {Experiment} experiment
+ * @param {Variation} variation
+ * @param {string} userId
+ * @param {ExperimentBucketMap} experimentBucketMap
+ */
+ private saveUserProfile(
+ op: OP,
+ userId: string,
+ userProfileTracker: UserProfileTracker
+ ): Value {
+ const { userProfile, isProfileUpdated } = userProfileTracker;
+
+ if (!userProfile || !isProfileUpdated) {
+ return Value.of(op, undefined);
+ }
+
+ if (op === 'sync' && !this.userProfileService) {
+ return Value.of(op, undefined);
+ }
+
+ if (this.userProfileService) {
+ try {
+ this.userProfileService.save({
+ user_id: userId,
+ experiment_bucket_map: userProfile,
+ });
+
+ this.logger?.info(
+ SAVED_USER_VARIATION,
+ userId,
+ );
+ } catch (ex: any) {
+ this.logger?.error(USER_PROFILE_SAVE_ERROR, userId, ex.message);
+ }
+ return Value.of(op, undefined);
+ }
+
+ if (this.userProfileServiceAsync) {
+ return Value.of(op, this.userProfileServiceAsync.save({
+ user_id: userId,
+ experiment_bucket_map: userProfile,
+ }).catch((ex: any) => {
+ this.logger?.error(USER_PROFILE_SAVE_ERROR, userId, ex.message);
+ }));
+ }
+
+ return Value.of(op, undefined);
+ }
+
+
+ /**
+ * Determines variations for the specified feature flags.
+ *
+ * @param {ProjectConfig} configObj - The parsed project configuration object.
+ * @param {FeatureFlag[]} featureFlags - The feature flags for which variations are to be determined.
+ * @param {OptimizelyUserContext} user - The user context associated with this decision.
+ * @param {Record} options - An optional map of decision options.
+ * @returns {DecisionResponse[]} - An array of DecisionResponse containing objects with
+ * experiment, variation, decisionSource properties, and decision reasons.
+ */
+ getVariationsForFeatureList(
+ configObj: ProjectConfig,
+ featureFlags: FeatureFlag[],
+ user: OptimizelyUserContext,
+ options: DecideOptionsMap = {}): DecisionResult[] {
+ return this.resolveVariationsForFeatureList('sync', configObj, featureFlags, user, options).get();
+ }
+
+ resolveVariationsForFeatureList(
+ op: OP,
+ configObj: ProjectConfig,
+ featureFlags: FeatureFlag[],
+ user: OptimizelyUserContext,
+ options: DecideOptionsMap): Value {
+ const userId = user.getUserId();
+ const attributes = user.getAttributes();
+ const decisions: DecisionResponse[] = [];
+ // const userProfileTracker : UserProfileTracker = {
+ // isProfileUpdated: false,
+ // userProfile: null,
+ // }
+ const shouldIgnoreUPS = !!options[OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE];
+
+ const userProfileTrackerValue: Value> = shouldIgnoreUPS ? Value.of(op, undefined)
+ : this.resolveExperimentBucketMap(op, userId, attributes).then((userProfile) => {
+ return Value.of(op, {
+ isProfileUpdated: false,
+ userProfile: userProfile,
+ });
+ });
+
+ return userProfileTrackerValue.then((userProfileTracker) => {
+ const flagResults = featureFlags.map((feature) => this.resolveVariationForFlag(op, configObj, feature, user, options, userProfileTracker));
+ const opFlagResults = Value.all(op, flagResults);
+
+ return opFlagResults.then(() => {
+ if(userProfileTracker) {
+ this.saveUserProfile(op, userId, userProfileTracker);
+ }
+ return opFlagResults;
+ });
+ });
+ }
+
+ private resolveVariationForFlag(
+ op: OP,
+ configObj: ProjectConfig,
+ feature: FeatureFlag,
+ user: OptimizelyUserContext,
+ decideOptions: DecideOptionsMap,
+ userProfileTracker?: UserProfileTracker
+ ): Value {
+ const decideReasons: DecisionReason[] = [];
+
+ const forcedDecisionResponse = this.findValidatedForcedDecision(configObj, user, feature.key);
+ decideReasons.push(...forcedDecisionResponse.reasons);
+
+ if (forcedDecisionResponse.result) {
+ return Value.of(op, {
+ result: {
+ variation: forcedDecisionResponse.result,
+ experiment: null,
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ },
+ reasons: decideReasons,
+ });
+ }
+ if (featureToggle.holdout()) {
+ const holdouts = getHoldoutsForFlag(configObj, feature.key);
+
+ for (const holdout of holdouts) {
+ const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user);
+ decideReasons.push(...holdoutDecision.reasons);
+
+ if (holdoutDecision.result.variation) {
+ return Value.of(op, {
+ result: holdoutDecision.result,
+ reasons: decideReasons,
+ });
+ }
+ }
+ }
+
+ return this.getVariationForFeatureExperiment(op, configObj, feature, user, decideOptions, userProfileTracker).then((experimentDecision) => {
+ if (experimentDecision.error || experimentDecision.result.variation !== null) {
+ return Value.of(op, {
+ ...experimentDecision,
+ reasons: [...decideReasons, ...experimentDecision.reasons],
+ });
+ }
+
+ decideReasons.push(...experimentDecision.reasons);
+
+ const rolloutDecision = this.getVariationForRollout(configObj, feature, user);
+ decideReasons.push(...rolloutDecision.reasons);
+ const rolloutDecisionResult = rolloutDecision.result;
+ const userId = user.getUserId();
+
+ if (rolloutDecisionResult.variation) {
+ this.logger?.debug(USER_IN_ROLLOUT, userId, feature.key);
+ decideReasons.push([USER_IN_ROLLOUT, userId, feature.key]);
+ } else {
+ this.logger?.debug(USER_NOT_IN_ROLLOUT, userId, feature.key);
+ decideReasons.push([USER_NOT_IN_ROLLOUT, userId, feature.key]);
+ }
+
+ return Value.of(op, {
+ result: rolloutDecisionResult,
+ reasons: decideReasons,
+ });
+ });
+ }
+
+ /**
+ * Given a feature, user ID, and attributes, returns a decision response containing
+ * an object representing a decision and decide reasons. If the user was bucketed into
+ * a variation for the given feature and attributes, the decision object will have variation and
+ * experiment properties (both objects), as well as a decisionSource property.
+ * decisionSource indicates whether the decision was due to a rollout or an
+ * experiment.
+ * @param {ProjectConfig} configObj The parsed project configuration object
+ * @param {FeatureFlag} feature A feature flag object from project configuration
+ * @param {OptimizelyUserContext} user A user context
+ * @param {[key: string]: boolean} options Map of decide options
+ * @return {DecisionResponse} DecisionResponse DecisionResponse containing an object with experiment, variation, and decisionSource
+ * properties and decide reasons. If the user was not bucketed into a variation, the variation
+ * property in decision object is null.
+ */
+ getVariationForFeature(
+ configObj: ProjectConfig,
+ feature: FeatureFlag,
+ user: OptimizelyUserContext,
+ options: DecideOptionsMap = {}
+ ): DecisionResponse {
+ return this.resolveVariationsForFeatureList('sync', configObj, [feature], user, options).get()[0]
+ }
+
+ private getVariationForFeatureExperiment(
+ op: OP,
+ configObj: ProjectConfig,
+ feature: FeatureFlag,
+ user: OptimizelyUserContext,
+ decideOptions: DecideOptionsMap,
+ userProfileTracker?: UserProfileTracker,
+ ): Value {
+
+ // const decideReasons: DecisionReason[] = [];
+ // let variationKey = null;
+ // let decisionVariation;
+ // let index;
+ // let variationForFeatureExperiment;
+
+ if (feature.experimentIds.length === 0) {
+ this.logger?.debug(FEATURE_HAS_NO_EXPERIMENTS, feature.key);
+ return Value.of(op, {
+ result: {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ },
+ reasons: [
+ [FEATURE_HAS_NO_EXPERIMENTS, feature.key],
+ ],
+ });
+ }
+
+ return this.traverseFeatureExperimentList(op, configObj, feature, 0, user, [], decideOptions, userProfileTracker);
+ }
+
+ private traverseFeatureExperimentList(
+ op: OP,
+ configObj: ProjectConfig,
+ feature: FeatureFlag,
+ fromIndex: number,
+ user: OptimizelyUserContext,
+ decideReasons: DecisionReason[],
+ decideOptions: DecideOptionsMap,
+ userProfileTracker?: UserProfileTracker,
+ ): Value {
+ const experimentIds = feature.experimentIds;
+ if (fromIndex >= experimentIds.length) {
+ return Value.of(op, {
+ result: {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ },
+ reasons: decideReasons,
+ });
+ }
+
+ const experiment = getExperimentFromId(configObj, experimentIds[fromIndex], this.logger);
+ if (!experiment) {
+ return this.traverseFeatureExperimentList(
+ op, configObj, feature, fromIndex + 1, user, decideReasons, decideOptions, userProfileTracker);
+ }
+
+ const decisionVariationValue = this.getVariationFromExperimentRule(
+ op, configObj, feature.key, experiment, user, decideOptions, userProfileTracker,
+ );
+
+ return decisionVariationValue.then((decisionVariation) => {
+ decideReasons.push(...decisionVariation.reasons);
+
+ if (decisionVariation.error) {
+ return Value.of(op, {
+ error: true,
+ result: {
+ experiment,
+ variation: null,
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ },
+ reasons: decideReasons,
+ });
+ }
+
+ if(!decisionVariation.result.variationKey) {
+ return this.traverseFeatureExperimentList(
+ op, configObj, feature, fromIndex + 1, user, decideReasons, decideOptions, userProfileTracker);
+ }
+
+ const variationKey = decisionVariation.result.variationKey;
+ let variation: Variation | null = experiment.variationKeyMap[variationKey];
+ if (!variation) {
+ variation = getFlagVariationByKey(configObj, feature.key, variationKey);
+ }
+
+ return Value.of(op, {
+ result: {
+ cmabUuid: decisionVariation.result.cmabUuid,
+ experiment,
+ variation,
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ },
+ reasons: decideReasons,
+ });
+ });
+ }
+
+ private getVariationForRollout(
+ configObj: ProjectConfig,
+ feature: FeatureFlag,
+ user: OptimizelyUserContext,
+ ): DecisionResponse {
+ const decideReasons: DecisionReason[] = [];
+ let decisionObj: DecisionObj;
+ if (!feature.rolloutId) {
+ this.logger?.debug(NO_ROLLOUT_EXISTS, feature.key);
+ decideReasons.push([NO_ROLLOUT_EXISTS, feature.key]);
+ decisionObj = {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ };
+
+ return {
+ result: decisionObj,
+ reasons: decideReasons,
+ };
+ }
+
+ const rollout = configObj.rolloutIdMap[feature.rolloutId];
+ if (!rollout) {
+ this.logger?.error(
+ INVALID_ROLLOUT_ID,
+ feature.rolloutId,
+ feature.key,
+ );
+ decideReasons.push([INVALID_ROLLOUT_ID, feature.rolloutId, feature.key]);
+ decisionObj = {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ };
+ return {
+ result: decisionObj,
+ reasons: decideReasons,
+ };
+ }
+
+ const rolloutRules = rollout.experiments;
+ if (rolloutRules.length === 0) {
+ this.logger?.error(
+ ROLLOUT_HAS_NO_EXPERIMENTS,
+ feature.rolloutId,
+ );
+ decideReasons.push([ROLLOUT_HAS_NO_EXPERIMENTS, feature.rolloutId]);
+ decisionObj = {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ };
+ return {
+ result: decisionObj,
+ reasons: decideReasons,
+ };
+ }
+ let decisionVariation;
+ let skipToEveryoneElse;
+ let variation;
+ let rolloutRule;
+ let index = 0;
+ while (index < rolloutRules.length) {
+ decisionVariation = this.getVariationFromDeliveryRule(configObj, feature.key, rolloutRules, index, user);
+ decideReasons.push(...decisionVariation.reasons);
+ variation = decisionVariation.result;
+ skipToEveryoneElse = decisionVariation.skipToEveryoneElse;
+ if (variation) {
+ rolloutRule = configObj.experimentIdMap[rolloutRules[index].id];
+ decisionObj = {
+ experiment: rolloutRule,
+ variation: variation,
+ decisionSource: DECISION_SOURCES.ROLLOUT
+ };
+ return {
+ result: decisionObj,
+ reasons: decideReasons,
+ };
+ }
+ // the last rule is special for "Everyone Else"
+ index = skipToEveryoneElse ? (rolloutRules.length - 1) : (index + 1);
+ }
+
+ decisionObj = {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ };
+
+ return {
+ result: decisionObj,
+ reasons: decideReasons,
+ };
+ }
+
+ /**
+ * Get bucketing Id from user attributes.
+ * @param {string} userId
+ * @param {UserAttributes} attributes
+ * @returns {string} Bucketing Id if it is a string type in attributes, user Id otherwise.
+ */
+ private getBucketingId(userId: string, attributes?: UserAttributes): string {
+ let bucketingId = userId;
+
+ // If the bucketing ID key is defined in attributes, than use that in place of the userID for the murmur hash key
+ if (
+ attributes != null &&
+ typeof attributes === 'object' &&
+ attributes.hasOwnProperty(CONTROL_ATTRIBUTES.BUCKETING_ID)
+ ) {
+ if (typeof attributes[CONTROL_ATTRIBUTES.BUCKETING_ID] === 'string') {
+ bucketingId = String(attributes[CONTROL_ATTRIBUTES.BUCKETING_ID]);
+ this.logger?.debug(VALID_BUCKETING_ID, bucketingId);
+ } else {
+ this.logger?.warn(BUCKETING_ID_NOT_STRING);
+ }
+ }
+
+ return bucketingId;
+ }
+
+ /**
+ * Finds a validated forced decision for specific flagKey and optional ruleKey.
+ * @param {ProjectConfig} config A projectConfig.
+ * @param {OptimizelyUserContext} user A Optimizely User Context.
+ * @param {string} flagKey A flagKey.
+ * @param {ruleKey} ruleKey A ruleKey (optional).
+ * @return {DecisionResponse} DecisionResponse object containing valid variation object and decide reasons.
+ */
+ findValidatedForcedDecision(
+ config: ProjectConfig,
+ user: OptimizelyUserContext,
+ flagKey: string,
+ ruleKey?: string
+ ): DecisionResponse {
+
+ const decideReasons: DecisionReason[] = [];
+ const forcedDecision = user.getForcedDecision({ flagKey, ruleKey });
+ let variation = null;
+ let variationKey;
+ const userId = user.getUserId()
+ if (config && forcedDecision) {
+ variationKey = forcedDecision.variationKey;
+ variation = getFlagVariationByKey(config, flagKey, variationKey);
+ if (variation) {
+ if (ruleKey) {
+ this.logger?.info(
+ USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED,
+ variationKey,
+ flagKey,
+ ruleKey,
+ userId
+ );
+ decideReasons.push([
+ USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED,
+ variationKey,
+ flagKey,
+ ruleKey,
+ userId
+ ]);
+ } else {
+ this.logger?.info(
+ USER_HAS_FORCED_DECISION_WITH_NO_RULE_SPECIFIED,
+ variationKey,
+ flagKey,
+ userId
+ );
+ decideReasons.push([
+ USER_HAS_FORCED_DECISION_WITH_NO_RULE_SPECIFIED,
+ variationKey,
+ flagKey,
+ userId
+ ])
+ }
+ } else {
+ if (ruleKey) {
+ this.logger?.info(
+ USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID,
+ flagKey,
+ ruleKey,
+ userId
+ );
+ decideReasons.push([
+ USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID,
+ flagKey,
+ ruleKey,
+ userId
+ ]);
+ } else {
+ this.logger?.info(
+ USER_HAS_FORCED_DECISION_WITH_NO_RULE_SPECIFIED_BUT_INVALID,
+ flagKey,
+ userId
+ );
+ decideReasons.push([
+ USER_HAS_FORCED_DECISION_WITH_NO_RULE_SPECIFIED_BUT_INVALID,
+ flagKey,
+ userId
+ ])
+ }
+ }
+ }
+
+ return {
+ result: variation,
+ reasons: decideReasons,
+ }
+ }
+
+ /**
+ * Removes forced variation for given userId and experimentKey
+ * @param {string} userId String representing the user id
+ * @param {string} experimentId Number representing the experiment id
+ * @param {string} experimentKey Key representing the experiment id
+ * @throws If the user id is not valid or not in the forced variation map
+ */
+ private removeForcedVariation(userId: string, experimentId: string, experimentKey: string): void {
+ if (!userId) {
+ throw new OptimizelyError(INVALID_USER_ID);
+ }
+
+ if (this.forcedVariationMap.hasOwnProperty(userId)) {
+ delete this.forcedVariationMap[userId][experimentId];
+ this.logger?.debug(
+ VARIATION_REMOVED_FOR_USER,
+ experimentKey,
+ userId,
+ );
+ } else {
+ throw new OptimizelyError(USER_NOT_IN_FORCED_VARIATION, userId);
+ }
+ }
+
+ /**
+ * Sets forced variation for given userId and experimentKey
+ * @param {string} userId String representing the user id
+ * @param {string} experimentId Number representing the experiment id
+ * @param {number} variationId Number representing the variation id
+ * @throws If the user id is not valid
+ */
+ private setInForcedVariationMap(userId: string, experimentId: string, variationId: string): void {
+ if (this.forcedVariationMap.hasOwnProperty(userId)) {
+ this.forcedVariationMap[userId][experimentId] = variationId;
+ } else {
+ this.forcedVariationMap[userId] = {};
+ this.forcedVariationMap[userId][experimentId] = variationId;
+ }
+
+ this.logger?.debug(
+ USER_MAPPED_TO_FORCED_VARIATION,
+ variationId,
+ experimentId,
+ userId,
+ );
+ }
+
+ /**
+ * Gets the forced variation key for the given user and experiment.
+ * @param {ProjectConfig} configObj Object representing project configuration
+ * @param {string} experimentKey Key for experiment.
+ * @param {string} userId The user Id.
+ * @return {DecisionResponse} DecisionResponse containing variation which the given user and experiment
+ * should be forced into and the decide reasons.
+ */
+ getForcedVariation(
+ configObj: ProjectConfig,
+ experimentKey: string,
+ userId: string
+ ): DecisionResponse {
+ const decideReasons: DecisionReason[] = [];
+ const experimentToVariationMap = this.forcedVariationMap[userId];
+ if (!experimentToVariationMap) {
+ this.logger?.debug(
+ USER_HAS_NO_FORCED_VARIATION,
+ userId,
+ );
+
+ return {
+ result: null,
+ reasons: decideReasons,
+ };
+ }
+
+ let experimentId;
+ try {
+ const experiment = getExperimentFromKey(configObj, experimentKey);
+ if (experiment.hasOwnProperty('id')) {
+ experimentId = experiment['id'];
+ } else {
+ // catching improperly formatted experiments
+ this.logger?.error(
+ IMPROPERLY_FORMATTED_EXPERIMENT,
+ experimentKey,
+ );
+ decideReasons.push([
+ IMPROPERLY_FORMATTED_EXPERIMENT,
+ experimentKey,
+ ]);
+
+ return {
+ result: null,
+ reasons: decideReasons,
+ };
+ }
+ } catch (ex: any) {
+ // catching experiment not in datafile
+ this.logger?.error(ex);
+ decideReasons.push(ex.message);
+
+ return {
+ result: null,
+ reasons: decideReasons,
+ };
+ }
+
+ const variationId = experimentToVariationMap[experimentId];
+ if (!variationId) {
+ this.logger?.debug(
+ USER_HAS_NO_FORCED_VARIATION_FOR_EXPERIMENT,
+ experimentKey,
+ userId,
+ );
+ return {
+ result: null,
+ reasons: decideReasons,
+ };
+ }
+
+ const variationKey = getVariationKeyFromId(configObj, variationId);
+ if (variationKey) {
+ this.logger?.debug(
+ USER_HAS_FORCED_VARIATION,
+ variationKey,
+ experimentKey,
+ userId,
+ );
+ decideReasons.push([
+ USER_HAS_FORCED_VARIATION,
+ variationKey,
+ experimentKey,
+ userId,
+ ]);
+ } else {
+ this.logger?.debug(
+ USER_HAS_NO_FORCED_VARIATION_FOR_EXPERIMENT,
+ experimentKey,
+ userId,
+ );
+ }
+
+ return {
+ result: variationKey,
+ reasons: decideReasons,
+ };
+ }
+
+ /**
+ * Sets the forced variation for a user in a given experiment
+ * @param {ProjectConfig} configObj Object representing project configuration
+ * @param {string} experimentKey Key for experiment.
+ * @param {string} userId The user Id.
+ * @param {string|null} variationKey Key for variation. If null, then clear the existing experiment-to-variation mapping
+ * @return {boolean} A boolean value that indicates if the set completed successfully.
+ */
+ setForcedVariation(
+ configObj: ProjectConfig,
+ experimentKey: string,
+ userId: string,
+ variationKey: string | null
+ ): boolean {
+ if (variationKey != null && !stringValidator.validate(variationKey)) {
+ this.logger?.error(INVALID_VARIATION_KEY);
+ return false;
+ }
+
+ let experimentId;
+ try {
+ const experiment = getExperimentFromKey(configObj, experimentKey);
+ if (experiment.hasOwnProperty('id')) {
+ experimentId = experiment['id'];
+ } else {
+ // catching improperly formatted experiments
+ this.logger?.error(
+ IMPROPERLY_FORMATTED_EXPERIMENT,
+ experimentKey,
+ );
+ return false;
+ }
+ } catch (ex: any) {
+ // catching experiment not in datafile
+ this.logger?.error(ex);
+ return false;
+ }
+
+ if (variationKey == null) {
+ try {
+ this.removeForcedVariation(userId, experimentId, experimentKey);
+ return true;
+ } catch (ex: any) {
+ this.logger?.error(ex);
+ return false;
+ }
+ }
+
+ const variationId = getVariationIdFromExperimentAndVariationKey(configObj, experimentKey, variationKey);
+
+ if (!variationId) {
+ this.logger?.error(
+ NO_VARIATION_FOR_EXPERIMENT_KEY,
+ variationKey,
+ experimentKey,
+ );
+ return false;
+ }
+
+ try {
+ this.setInForcedVariationMap(userId, experimentId, variationId);
+ return true;
+ } catch (ex: any) {
+ this.logger?.error(ex);
+ return false;
+ }
+ }
+
+ private getVariationFromExperimentRule(
+ op: OP,
+ configObj: ProjectConfig,
+ flagKey: string,
+ rule: Experiment,
+ user: OptimizelyUserContext,
+ decideOptions: DecideOptionsMap,
+ userProfileTracker?: UserProfileTracker,
+ ): Value {
+ const decideReasons: DecisionReason[] = [];
+
+ // check forced decision first
+ const forcedDecisionResponse = this.findValidatedForcedDecision(configObj, user, flagKey, rule.key);
+ decideReasons.push(...forcedDecisionResponse.reasons);
+
+ const forcedVariation = forcedDecisionResponse.result;
+ if (forcedVariation) {
+ return Value.of(op, {
+ result: { variationKey: forcedVariation.key },
+ reasons: decideReasons,
+ });
+ }
+ const decisionVariationValue = this.resolveVariation(op, configObj, rule, user, decideOptions, userProfileTracker);
+
+ return decisionVariationValue.then((variationResult) => {
+ decideReasons.push(...variationResult.reasons);
+ return Value.of(op, {
+ error: variationResult.error,
+ result: variationResult.result,
+ reasons: decideReasons,
+ });
+ });
+
+ // return response;
+
+ // decideReasons.push(...decisionVariation.reasons);
+ // const variationKey = decisionVariation.result;
+
+ // return {
+ // result: variationKey,
+ // reasons: decideReasons,
+ // };
+ }
+
+ private getVariationFromDeliveryRule(
+ configObj: ProjectConfig,
+ flagKey: string,
+ rules: Experiment[],
+ ruleIndex: number,
+ user: OptimizelyUserContext
+ ): DeliveryRuleResponse {
+ const decideReasons: DecisionReason[] = [];
+ let skipToEveryoneElse = false;
+
+ // check forced decision first
+ const rule = rules[ruleIndex];
+ const forcedDecisionResponse = this.findValidatedForcedDecision(configObj, user, flagKey, rule.key);
+ decideReasons.push(...forcedDecisionResponse.reasons);
+
+ const forcedVariation = forcedDecisionResponse.result;
+ if (forcedVariation) {
+ return {
+ result: forcedVariation,
+ reasons: decideReasons,
+ skipToEveryoneElse,
+ };
+ }
+
+ const userId = user.getUserId();
+ const attributes = user.getAttributes();
+ const bucketingId = this.getBucketingId(userId, attributes);
+ const everyoneElse = ruleIndex === rules.length - 1;
+ const loggingKey = everyoneElse ? "Everyone Else" : ruleIndex + 1;
+
+ let bucketedVariation = null;
+ let bucketerVariationId;
+ let bucketerParams;
+ let decisionVariation;
+ const decisionifUserIsInAudience = this.checkIfUserIsInAudience(
+ configObj,
+ rule,
+ AUDIENCE_EVALUATION_TYPES.RULE,
+ user,
+ loggingKey
+ );
+ decideReasons.push(...decisionifUserIsInAudience.reasons);
+ if (decisionifUserIsInAudience.result) {
+ this.logger?.debug(
+ USER_MEETS_CONDITIONS_FOR_TARGETING_RULE,
+ userId,
+ loggingKey
+ );
+ decideReasons.push([
+ USER_MEETS_CONDITIONS_FOR_TARGETING_RULE,
+ userId,
+ loggingKey
+ ]);
+
+ bucketerParams = this.buildBucketerParams(configObj, rule, bucketingId, userId);
+ decisionVariation = bucket(bucketerParams);
+ decideReasons.push(...decisionVariation.reasons);
+ bucketerVariationId = decisionVariation.result;
+ if (bucketerVariationId) {
+ bucketedVariation = getVariationFromId(configObj, bucketerVariationId);
+ }
+ if (bucketedVariation) {
+ this.logger?.debug(
+ USER_BUCKETED_INTO_TARGETING_RULE,
+ userId,
+ loggingKey
+ );
+ decideReasons.push([
+ USER_BUCKETED_INTO_TARGETING_RULE,
+ userId,
+ loggingKey]);
+ } else if (!everyoneElse) {
+ // skip this logging for EveryoneElse since this has a message not for EveryoneElse
+ this.logger?.debug(
+ USER_NOT_BUCKETED_INTO_TARGETING_RULE,
+ userId,
+ loggingKey
+ );
+ decideReasons.push([
+ USER_NOT_BUCKETED_INTO_TARGETING_RULE,
+ userId,
+ loggingKey
+ ]);
+
+ // skip the rest of rollout rules to the everyone-else rule if audience matches but not bucketed
+ skipToEveryoneElse = true;
+ }
+ } else {
+ this.logger?.debug(
+ USER_DOESNT_MEET_CONDITIONS_FOR_TARGETING_RULE,
+ userId,
+ loggingKey
+ );
+ decideReasons.push([
+ USER_DOESNT_MEET_CONDITIONS_FOR_TARGETING_RULE,
+ userId,
+ loggingKey
+ ]);
+ }
+
+ return {
+ result: bucketedVariation,
+ reasons: decideReasons,
+ skipToEveryoneElse,
+ };
+ }
+}
+
+/**
+ * Creates an instance of the DecisionService.
+ * @param {DecisionServiceOptions} options Configuration options
+ * @return {Object} An instance of the DecisionService
+ */
+export function createDecisionService(options: DecisionServiceOptions): DecisionService {
+ return new DecisionService(options);
+}
diff --git a/lib/entrypoint.test-d.ts b/lib/entrypoint.test-d.ts
new file mode 100644
index 000000000..366889ea8
--- /dev/null
+++ b/lib/entrypoint.test-d.ts
@@ -0,0 +1,110 @@
+/**
+ * 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.
+ */
+
+import { expectTypeOf } from 'vitest';
+
+import * as browser from './index.browser';
+import * as node from './index.node';
+import * as reactNative from './index.react_native';
+
+type WithoutReadonly = { -readonly [P in keyof T]: T[P] };
+
+const nodeEntrypoint: WithoutReadonly = node;
+const browserEntrypoint: WithoutReadonly = browser;
+const reactNativeEntrypoint: WithoutReadonly = reactNative;
+
+import {
+ Config,
+ Client,
+ StaticConfigManagerConfig,
+ OpaqueConfigManager,
+ PollingConfigManagerConfig,
+ EventDispatcher,
+ OpaqueEventProcessor,
+ BatchEventProcessorOptions,
+ OdpManagerOptions,
+ OpaqueOdpManager,
+ VuidManagerOptions,
+ OpaqueVuidManager,
+ OpaqueLevelPreset,
+ LoggerConfig,
+ OpaqueLogger,
+ ErrorHandler,
+ OpaqueErrorNotifier,
+} from './export_types';
+
+import {
+ DECISION_SOURCES,
+} from './utils/enums';
+
+import { NOTIFICATION_TYPES, DECISION_NOTIFICATION_TYPES } from './notification_center/type';
+
+import { LogLevel } from './logging/logger';
+
+import { OptimizelyDecideOption } from './shared_types';
+import { Maybe } from './utils/type';
+
+export type Entrypoint = {
+ // client factory
+ createInstance: (config: Config) => Client;
+
+ // config manager related exports
+ createStaticProjectConfigManager: (config: StaticConfigManagerConfig) => OpaqueConfigManager;
+ createPollingProjectConfigManager: (config: PollingConfigManagerConfig) => OpaqueConfigManager;
+
+ // event processor related exports
+ eventDispatcher: EventDispatcher;
+ getSendBeaconEventDispatcher: () => Maybe;
+ createForwardingEventProcessor: (eventDispatcher?: EventDispatcher) => OpaqueEventProcessor;
+ createBatchEventProcessor: (options?: BatchEventProcessorOptions) => OpaqueEventProcessor;
+
+ // odp manager related exports
+ createOdpManager: (options?: OdpManagerOptions) => OpaqueOdpManager;
+
+ // vuid manager related exports
+ createVuidManager: (options?: VuidManagerOptions) => OpaqueVuidManager;
+
+ // logger related exports
+ LogLevel: typeof LogLevel;
+ DEBUG: OpaqueLevelPreset,
+ INFO: OpaqueLevelPreset,
+ WARN: OpaqueLevelPreset,
+ ERROR: OpaqueLevelPreset,
+ createLogger: (config: LoggerConfig) => OpaqueLogger;
+
+ // error related exports
+ createErrorNotifier: (errorHandler: ErrorHandler) => OpaqueErrorNotifier;
+
+ // enums
+ DECISION_SOURCES: typeof DECISION_SOURCES;
+ NOTIFICATION_TYPES: typeof NOTIFICATION_TYPES;
+ DECISION_NOTIFICATION_TYPES: typeof DECISION_NOTIFICATION_TYPES;
+
+ // decide options
+ OptimizelyDecideOption: typeof OptimizelyDecideOption;
+
+ // client engine
+ clientEngine: string;
+}
+
+
+expectTypeOf(browserEntrypoint).toEqualTypeOf();
+expectTypeOf(nodeEntrypoint).toEqualTypeOf();
+expectTypeOf(reactNativeEntrypoint).toEqualTypeOf();
+
+expectTypeOf(browserEntrypoint).toEqualTypeOf(nodeEntrypoint);
+expectTypeOf(browserEntrypoint).toEqualTypeOf(reactNativeEntrypoint);
+expectTypeOf(nodeEntrypoint).toEqualTypeOf(reactNativeEntrypoint);
diff --git a/lib/entrypoint.universal.test-d.ts b/lib/entrypoint.universal.test-d.ts
new file mode 100644
index 000000000..184583a35
--- /dev/null
+++ b/lib/entrypoint.universal.test-d.ts
@@ -0,0 +1,98 @@
+/**
+ * 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.
+ */
+
+import { expectTypeOf } from 'vitest';
+
+import * as universal from './index.universal';
+
+type WithoutReadonly = { -readonly [P in keyof T]: T[P] };
+
+const universalEntrypoint: WithoutReadonly = universal;
+
+import {
+ Config,
+ Client,
+ StaticConfigManagerConfig,
+ OpaqueConfigManager,
+ EventDispatcher,
+ OpaqueEventProcessor,
+ OpaqueLevelPreset,
+ LoggerConfig,
+ OpaqueLogger,
+ ErrorHandler,
+ OpaqueErrorNotifier,
+} from './export_types';
+
+import { UniversalPollingConfigManagerConfig } from './project_config/config_manager_factory.universal';
+import { RequestHandler } from './utils/http_request_handler/http';
+import { UniversalBatchEventProcessorOptions } from './event_processor/event_processor_factory.universal';
+import {
+ DECISION_SOURCES,
+} from './utils/enums';
+
+import { NOTIFICATION_TYPES, DECISION_NOTIFICATION_TYPES } from './notification_center/type';
+
+import { LogLevel } from './logging/logger';
+
+import { OptimizelyDecideOption } from './shared_types';
+import { UniversalConfig } from './index.universal';
+import { OpaqueOdpManager } from './odp/odp_manager_factory';
+
+import { UniversalOdpManagerOptions } from './odp/odp_manager_factory.universal';
+
+export type UniversalEntrypoint = {
+ // client factory
+ createInstance: (config: UniversalConfig) => Client;
+
+ // config manager related exports
+ createStaticProjectConfigManager: (config: StaticConfigManagerConfig) => OpaqueConfigManager;
+ createPollingProjectConfigManager: (config: UniversalPollingConfigManagerConfig) => OpaqueConfigManager;
+
+ // event processor related exports
+ createEventDispatcher: (requestHandler: RequestHandler) => EventDispatcher;
+ createForwardingEventProcessor: (eventDispatcher: EventDispatcher) => OpaqueEventProcessor;
+ createBatchEventProcessor: (options: UniversalBatchEventProcessorOptions) => OpaqueEventProcessor;
+
+ createOdpManager: (options: UniversalOdpManagerOptions) => OpaqueOdpManager;
+
+ // TODO: vuid manager related exports
+ // createVuidManager: (options: VuidManagerOptions) => OpaqueVuidManager;
+
+ // logger related exports
+ LogLevel: typeof LogLevel;
+ DEBUG: OpaqueLevelPreset,
+ INFO: OpaqueLevelPreset,
+ WARN: OpaqueLevelPreset,
+ ERROR: OpaqueLevelPreset,
+ createLogger: (config: LoggerConfig) => OpaqueLogger;
+
+ // error related exports
+ createErrorNotifier: (errorHandler: ErrorHandler) => OpaqueErrorNotifier;
+
+ // enums
+ DECISION_SOURCES: typeof DECISION_SOURCES;
+ NOTIFICATION_TYPES: typeof NOTIFICATION_TYPES;
+ DECISION_NOTIFICATION_TYPES: typeof DECISION_NOTIFICATION_TYPES;
+
+ // decide options
+ OptimizelyDecideOption: typeof OptimizelyDecideOption;
+
+ // client engine
+ clientEngine: string;
+}
+
+
+expectTypeOf(universalEntrypoint).toEqualTypeOf();
diff --git a/packages/optimizely-sdk/lib/plugins/error_handler/index.ts b/lib/error/error_handler.ts
similarity index 72%
rename from packages/optimizely-sdk/lib/plugins/error_handler/index.ts
rename to lib/error/error_handler.ts
index 7afb8c5e3..4a772c71c 100644
--- a/packages/optimizely-sdk/lib/plugins/error_handler/index.ts
+++ b/lib/error/error_handler.ts
@@ -1,5 +1,5 @@
/**
- * Copyright 2016, 2020-2021, Optimizely
+ * Copyright 2019, 2025, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,14 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
/**
- * Default error handler implementation
+ * @export
+ * @interface ErrorHandler
*/
-export function handleError(): void {
- // no-op
-}
-
-export default {
- handleError,
+export interface ErrorHandler {
+ /**
+ * @param {Error} exception
+ * @memberof ErrorHandler
+ */
+ handleError(exception: Error): void
}
diff --git a/lib/error/error_notifier.spec.ts b/lib/error/error_notifier.spec.ts
new file mode 100644
index 000000000..7c2b19d89
--- /dev/null
+++ b/lib/error/error_notifier.spec.ts
@@ -0,0 +1,50 @@
+/**
+ * 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.
+ */
+import { describe, it, expect, vi } from 'vitest';
+
+import { DefaultErrorNotifier } from './error_notifier';
+import { OptimizelyError } from './optimizly_error';
+
+const mockMessageResolver = (prefix = '') => {
+ return {
+ resolve: vi.fn().mockImplementation((message) => `${prefix} ${message}`),
+ };
+}
+
+describe('DefaultErrorNotifier', () => {
+ it('should call the error handler with the error if the error is not an OptimizelyError', () => {
+ const errorHandler = { handleError: vi.fn() };
+ const messageResolver = mockMessageResolver();
+ const errorNotifier = new DefaultErrorNotifier(errorHandler, messageResolver);
+
+ const error = new Error('error');
+ errorNotifier.notify(error);
+
+ expect(errorHandler.handleError).toHaveBeenCalledWith(error);
+ });
+
+ it('should resolve the message of an OptimizelyError before calling the error handler', () => {
+ const errorHandler = { handleError: vi.fn() };
+ const messageResolver = mockMessageResolver('err');
+ const errorNotifier = new DefaultErrorNotifier(errorHandler, messageResolver);
+
+ const error = new OptimizelyError('test %s', 'one');
+ errorNotifier.notify(error);
+
+ expect(errorHandler.handleError).toHaveBeenCalledWith(error);
+ expect(error.message).toBe('err test one');
+ });
+});
diff --git a/lib/error/error_notifier.ts b/lib/error/error_notifier.ts
new file mode 100644
index 000000000..174c163e2
--- /dev/null
+++ b/lib/error/error_notifier.ts
@@ -0,0 +1,46 @@
+/**
+ * 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.
+ */
+import { MessageResolver } from "../message/message_resolver";
+import { ErrorHandler } from "./error_handler";
+import { OptimizelyError } from "./optimizly_error";
+
+export interface ErrorNotifier {
+ notify(error: Error): void;
+ child(name: string): ErrorNotifier;
+}
+
+export class DefaultErrorNotifier implements ErrorNotifier {
+ private name: string;
+ private errorHandler: ErrorHandler;
+ private messageResolver: MessageResolver;
+
+ constructor(errorHandler: ErrorHandler, messageResolver: MessageResolver, name?: string) {
+ this.errorHandler = errorHandler;
+ this.messageResolver = messageResolver;
+ this.name = name || '';
+ }
+
+ notify(error: Error): void {
+ if (error instanceof OptimizelyError) {
+ error.setMessage(this.messageResolver);
+ }
+ this.errorHandler.handleError(error);
+ }
+
+ child(name: string): ErrorNotifier {
+ return new DefaultErrorNotifier(this.errorHandler, this.messageResolver, name);
+ }
+}
diff --git a/lib/error/error_notifier_factory.spec.ts b/lib/error/error_notifier_factory.spec.ts
new file mode 100644
index 000000000..556d7f2af
--- /dev/null
+++ b/lib/error/error_notifier_factory.spec.ts
@@ -0,0 +1,33 @@
+/**
+ * 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.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { createErrorNotifier } from './error_notifier_factory';
+
+describe('createErrorNotifier', () => {
+ it('should throw errors for invalid error handlers', () => {
+ expect(() => createErrorNotifier(null as any)).toThrow('Invalid error handler');
+ expect(() => createErrorNotifier(undefined as any)).toThrow('Invalid error handler');
+
+
+ expect(() => createErrorNotifier('abc' as any)).toThrow('Invalid error handler');
+ expect(() => createErrorNotifier(123 as any)).toThrow('Invalid error handler');
+
+ expect(() => createErrorNotifier({} as any)).toThrow('Invalid error handler');
+
+ expect(() => createErrorNotifier({ handleError: 'abc' } as any)).toThrow('Invalid error handler');
+ });
+});
diff --git a/lib/error/error_notifier_factory.ts b/lib/error/error_notifier_factory.ts
new file mode 100644
index 000000000..994564f1a
--- /dev/null
+++ b/lib/error/error_notifier_factory.ts
@@ -0,0 +1,48 @@
+/**
+ * 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.
+ */
+import { errorResolver } from "../message/message_resolver";
+import { Maybe } from "../utils/type";
+import { ErrorHandler } from "./error_handler";
+import { DefaultErrorNotifier } from "./error_notifier";
+
+export const INVALID_ERROR_HANDLER = 'Invalid error handler';
+
+const errorNotifierSymbol = Symbol();
+
+export type OpaqueErrorNotifier = {
+ [errorNotifierSymbol]: unknown;
+};
+
+const validateErrorHandler = (errorHandler: ErrorHandler) => {
+ if (!errorHandler || typeof errorHandler !== 'object' || typeof errorHandler.handleError !== 'function') {
+ throw new Error(INVALID_ERROR_HANDLER);
+ }
+}
+
+export const createErrorNotifier = (errorHandler: ErrorHandler): OpaqueErrorNotifier => {
+ validateErrorHandler(errorHandler);
+ return {
+ [errorNotifierSymbol]: new DefaultErrorNotifier(errorHandler, errorResolver),
+ }
+}
+
+export const extractErrorNotifier = (errorNotifier: Maybe): Maybe => {
+ if (!errorNotifier || typeof errorNotifier !== 'object') {
+ return undefined;
+ }
+
+ return errorNotifier[errorNotifierSymbol] as Maybe;
+}
diff --git a/lib/error/error_reporter.spec.ts b/lib/error/error_reporter.spec.ts
new file mode 100644
index 000000000..abdd932d0
--- /dev/null
+++ b/lib/error/error_reporter.spec.ts
@@ -0,0 +1,60 @@
+/**
+ * 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.
+ */
+import { describe, it, expect, vi } from 'vitest';
+
+import { ErrorReporter } from './error_reporter';
+
+import { OptimizelyError } from './optimizly_error';
+
+const mockMessageResolver = (prefix = '') => {
+ return {
+ resolve: vi.fn().mockImplementation((message) => `${prefix} ${message}`),
+ };
+}
+
+describe('ErrorReporter', () => {
+ it('should call the logger and errorNotifier with the first argument if it is an Error object', () => {
+ const logger = { error: vi.fn() };
+ const errorNotifier = { notify: vi.fn() };
+ const errorReporter = new ErrorReporter(logger as any, errorNotifier as any);
+
+ const error = new Error('error');
+ errorReporter.report(error);
+
+ expect(logger.error).toHaveBeenCalledWith(error);
+ expect(errorNotifier.notify).toHaveBeenCalledWith(error);
+ });
+
+ it('should create an OptimizelyError and call the logger and errorNotifier with it if the first argument is a string', () => {
+ const logger = { error: vi.fn() };
+ const errorNotifier = { notify: vi.fn() };
+ const errorReporter = new ErrorReporter(logger as any, errorNotifier as any);
+
+ errorReporter.report('message', 1, 2);
+
+ expect(logger.error).toHaveBeenCalled();
+ const loggedError = logger.error.mock.calls[0][0];
+ expect(loggedError).toBeInstanceOf(OptimizelyError);
+ expect(loggedError.baseMessage).toBe('message');
+ expect(loggedError.params).toEqual([1, 2]);
+
+ expect(errorNotifier.notify).toHaveBeenCalled();
+ const notifiedError = errorNotifier.notify.mock.calls[0][0];
+ expect(notifiedError).toBeInstanceOf(OptimizelyError);
+ expect(notifiedError.baseMessage).toBe('message');
+ expect(notifiedError.params).toEqual([1, 2]);
+ });
+});
diff --git a/lib/error/error_reporter.ts b/lib/error/error_reporter.ts
new file mode 100644
index 000000000..130527928
--- /dev/null
+++ b/lib/error/error_reporter.ts
@@ -0,0 +1,55 @@
+/**
+ * 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.
+ */
+import { LoggerFacade } from "../logging/logger";
+import { ErrorNotifier } from "./error_notifier";
+import { OptimizelyError } from "./optimizly_error";
+
+export class ErrorReporter {
+ private logger?: LoggerFacade;
+ private errorNotifier?: ErrorNotifier;
+
+ constructor(logger?: LoggerFacade, errorNotifier?: ErrorNotifier) {
+ this.logger = logger;
+ this.errorNotifier = errorNotifier;
+ }
+
+ report(error: Error): void;
+ report(baseMessage: string, ...params: any[]): void;
+
+ report(error: Error | string, ...params: any[]): void {
+ if (typeof error === 'string') {
+ error = new OptimizelyError(error, ...params);
+ this.report(error);
+ return;
+ }
+
+ if (this.errorNotifier) {
+ this.errorNotifier.notify(error);
+ }
+
+ if (this.logger) {
+ this.logger.error(error);
+ }
+ }
+
+ setLogger(logger: LoggerFacade): void {
+ this.logger = logger;
+ }
+
+ setErrorNotifier(errorNotifier: ErrorNotifier): void {
+ this.errorNotifier = errorNotifier;
+ }
+}
diff --git a/lib/error/optimizly_error.ts b/lib/error/optimizly_error.ts
new file mode 100644
index 000000000..76a07511a
--- /dev/null
+++ b/lib/error/optimizly_error.ts
@@ -0,0 +1,40 @@
+/**
+ * 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.
+ */
+import { MessageResolver } from "../message/message_resolver";
+import { sprintf } from "../utils/fns";
+
+export class OptimizelyError extends Error {
+ baseMessage: string;
+ params: any[];
+ private resolved = false;
+ constructor(baseMessage: string, ...params: any[]) {
+ super();
+ this.name = 'OptimizelyError';
+ this.baseMessage = baseMessage;
+ this.params = params;
+
+ // this is needed cause instanceof doesn't work for
+ // custom Errors when TS is compiled to es5
+ Object.setPrototypeOf(this, OptimizelyError.prototype);
+ }
+
+ setMessage(resolver: MessageResolver): void {
+ if (!this.resolved) {
+ this.message = sprintf(resolver.resolve(this.baseMessage), ...this.params);
+ this.resolved = true;
+ }
+ }
+}
diff --git a/lib/event_processor/batch_event_processor.react_native.spec.ts b/lib/event_processor/batch_event_processor.react_native.spec.ts
new file mode 100644
index 000000000..5e17ca966
--- /dev/null
+++ b/lib/event_processor/batch_event_processor.react_native.spec.ts
@@ -0,0 +1,169 @@
+/**
+ * Copyright 2024, 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.
+ */
+
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+
+const mockNetInfo = vi.hoisted(() => {
+ const netInfo = {
+ listeners: [] as any[],
+ unsubs: [] as any[],
+ addEventListener(fn: any) {
+ this.listeners.push(fn);
+ const unsub = vi.fn();
+ this.unsubs.push(unsub);
+ return unsub;
+ },
+ pushState(state: boolean) {
+ for (const listener of this.listeners) {
+ listener({ isInternetReachable: state });
+ }
+ },
+ clear() {
+ this.listeners = [];
+ this.unsubs = [];
+ }
+ };
+ return netInfo;
+});
+
+vi.mock('@react-native-community/netinfo', () => {
+ return {
+ addEventListener: mockNetInfo.addEventListener.bind(mockNetInfo),
+ };
+});
+
+import { ReactNativeNetInfoEventProcessor } from './batch_event_processor.react_native';
+import { getMockRepeater } from '../tests/mock/mock_repeater';
+import { getMockAsyncCache } from '../tests/mock/mock_cache';
+
+import { EventWithId } from './batch_event_processor';
+import { buildLogEvent } from './event_builder/log_event';
+import { createImpressionEvent } from '../tests/mock/create_event';
+import { ProcessableEvent } from './event_processor';
+
+const getMockDispatcher = () => {
+ return {
+ dispatchEvent: vi.fn(),
+ };
+};
+
+const exhaustMicrotasks = async (loop = 100) => {
+ for(let i = 0; i < loop; i++) {
+ await Promise.resolve();
+ }
+}
+
+
+describe('ReactNativeNetInfoEventProcessor', () => {
+ beforeEach(() => {
+ mockNetInfo.clear();
+ });
+
+ it('should not retry failed events when reachable state does not change', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const dispatchRepeater = getMockRepeater();
+ const failedEventRepeater = getMockRepeater();
+
+ const cache = getMockAsyncCache();
+ const events: ProcessableEvent[] = [];
+
+ for(let i = 0; i < 5; i++) {
+ const id = `id-${i}`;
+ const event = createImpressionEvent(id);
+ events.push(event);
+ await cache.set(id, { id, event });
+ }
+
+ const processor = new ReactNativeNetInfoEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ failedEventRepeater,
+ batchSize: 1000,
+ eventStore: cache,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ mockNetInfo.pushState(true);
+ expect(eventDispatcher.dispatchEvent).not.toHaveBeenCalled();
+
+ mockNetInfo.pushState(true);
+ expect(eventDispatcher.dispatchEvent).not.toHaveBeenCalled();
+ });
+
+ it('should retry failed events when network becomes reachable', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const dispatchRepeater = getMockRepeater();
+ const failedEventRepeater = getMockRepeater();
+
+ const cache = getMockAsyncCache();
+ const events: ProcessableEvent[] = [];
+
+ for(let i = 0; i < 5; i++) {
+ const id = `id-${i}`;
+ const event = createImpressionEvent(id);
+ events.push(event);
+ await cache.set(id, { id, event });
+ }
+
+ const processor = new ReactNativeNetInfoEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ failedEventRepeater,
+ batchSize: 1000,
+ eventStore: cache,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ mockNetInfo.pushState(false);
+ expect(eventDispatcher.dispatchEvent).not.toHaveBeenCalled();
+
+ mockNetInfo.pushState(true);
+
+ await exhaustMicrotasks();
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledWith(buildLogEvent(events));
+ });
+
+ it('should unsubscribe from netinfo listener when stopped', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const dispatchRepeater = getMockRepeater();
+ const failedEventRepeater = getMockRepeater();
+
+ const cache = getMockAsyncCache();
+
+ const processor = new ReactNativeNetInfoEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ failedEventRepeater,
+ batchSize: 1000,
+ eventStore: cache,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ mockNetInfo.pushState(false);
+
+ processor.stop();
+ await processor.onTerminated();
+
+ expect(mockNetInfo.unsubs[0]).toHaveBeenCalled();
+ });
+});
diff --git a/lib/event_processor/batch_event_processor.react_native.ts b/lib/event_processor/batch_event_processor.react_native.ts
new file mode 100644
index 000000000..28741380a
--- /dev/null
+++ b/lib/event_processor/batch_event_processor.react_native.ts
@@ -0,0 +1,51 @@
+/**
+ * Copyright 2024, 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.
+ */
+
+import { NetInfoState, addEventListener } from '@react-native-community/netinfo';
+
+import { BatchEventProcessor, BatchEventProcessorConfig } from './batch_event_processor';
+import { Fn } from '../utils/type';
+
+export class ReactNativeNetInfoEventProcessor extends BatchEventProcessor {
+ private isInternetReachable = true;
+ private unsubscribeNetInfo?: Fn;
+
+ constructor(config: BatchEventProcessorConfig) {
+ super(config);
+ }
+
+ private async connectionListener(state: NetInfoState) {
+ if (this.isInternetReachable && !state.isInternetReachable) {
+ this.isInternetReachable = false;
+ return;
+ }
+
+ if (!this.isInternetReachable && state.isInternetReachable) {
+ this.isInternetReachable = true;
+ this.retryFailedEvents();
+ }
+ }
+
+ start(): void {
+ super.start();
+ this.unsubscribeNetInfo = addEventListener(this.connectionListener.bind(this));
+ }
+
+ stop(): void {
+ this.unsubscribeNetInfo?.();
+ super.stop();
+ }
+}
diff --git a/lib/event_processor/batch_event_processor.spec.ts b/lib/event_processor/batch_event_processor.spec.ts
new file mode 100644
index 000000000..6d7674fd5
--- /dev/null
+++ b/lib/event_processor/batch_event_processor.spec.ts
@@ -0,0 +1,1483 @@
+/**
+ * Copyright 2024-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.
+ */
+import { expect, describe, it, vi, beforeEach, afterEach, MockInstance } from 'vitest';
+
+import { EventWithId, BatchEventProcessor, LOGGER_NAME } from './batch_event_processor';
+import { getMockAsyncCache, getMockSyncCache } from '../tests/mock/mock_cache';
+import { createImpressionEvent } from '../tests/mock/create_event';
+import { ProcessableEvent } from './event_processor';
+import { buildLogEvent } from './event_builder/log_event';
+import { ResolvablePromise, resolvablePromise } from '../utils/promise/resolvablePromise';
+import { advanceTimersByTime } from '../tests/testUtils';
+import { getMockLogger } from '../tests/mock/mock_logger';
+import { getMockRepeater } from '../tests/mock/mock_repeater';
+import * as retry from '../utils/executor/backoff_retry_runner';
+import { ServiceState, StartupLog } from '../service';
+import { LogLevel } from '../logging/logger';
+import { IdGenerator } from '../utils/id_generator';
+
+const getMockDispatcher = () => {
+ return {
+ dispatchEvent: vi.fn(),
+ };
+};
+
+const exhaustMicrotasks = async (loop = 100) => {
+ for(let i = 0; i < loop; i++) {
+ await Promise.resolve();
+ }
+}
+
+describe('BatchEventProcessor', async () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should set name on the logger passed into the constructor', () => {
+ const logger = getMockLogger();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher: getMockDispatcher(),
+ dispatchRepeater: getMockRepeater(),
+ batchSize: 1000,
+ logger,
+ });
+
+ expect(logger.setName).toHaveBeenCalledWith(LOGGER_NAME);
+ });
+
+ it('should set name on the logger set by setLogger', () => {
+ const logger = getMockLogger();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher: getMockDispatcher(),
+ dispatchRepeater: getMockRepeater(),
+ batchSize: 1000,
+ });
+
+ processor.setLogger(logger);
+ expect(logger.setName).toHaveBeenCalledWith(LOGGER_NAME);
+ });
+
+ describe('start', () => {
+ it('should log startupLogs on start', () => {
+ const startupLogs: StartupLog[] = [
+ {
+ level: LogLevel.Warn,
+ message: 'warn message',
+ params: [1, 2]
+ },
+ {
+ level: LogLevel.Error,
+ message: 'error message',
+ params: [3, 4]
+ },
+ ];
+
+ const logger = getMockLogger();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher: getMockDispatcher(),
+ dispatchRepeater: getMockRepeater(),
+ batchSize: 1000,
+ startupLogs,
+ });
+
+ processor.setLogger(logger);
+ processor.start();
+
+ expect(logger.warn).toHaveBeenCalledTimes(1);
+ expect(logger.warn).toHaveBeenCalledWith('warn message', 1, 2);
+ expect(logger.error).toHaveBeenCalledTimes(1);
+ expect(logger.error).toHaveBeenCalledWith('error message', 3, 4);
+ });
+
+ it('should resolve onRunning() when start() is called', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater: getMockRepeater(),
+ batchSize: 1000,
+ });
+
+ processor.start();
+ await expect(processor.onRunning()).resolves.not.toThrow();
+ });
+
+ it('should start failedEventRepeater', () => {
+ const eventDispatcher = getMockDispatcher();
+ const dispatchRepeater = getMockRepeater();
+ const failedEventRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ failedEventRepeater,
+ batchSize: 1000,
+ });
+
+ processor.start();
+ expect(failedEventRepeater.start).toHaveBeenCalledOnce();
+ });
+
+ it('should dispatch failed events in correct batch sizes and order', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockResolvedValue({});
+
+ const cache = getMockSyncCache();
+ const events: ProcessableEvent[] = [];
+
+ for(let i = 0; i < 5; i++) {
+ const id = `id-${i}`;
+ const event = createImpressionEvent(id);
+ events.push(event);
+ cache.set(id, { id, event });
+ }
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater: getMockRepeater(),
+ batchSize: 2,
+ eventStore: cache,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ await exhaustMicrotasks();
+
+ expect(mockDispatch).toHaveBeenCalledTimes(3);
+ expect(mockDispatch.mock.calls[0][0]).toEqual(buildLogEvent([events[0], events[1]]));
+ expect(mockDispatch.mock.calls[1][0]).toEqual(buildLogEvent([events[2], events[3]]));
+ expect(mockDispatch.mock.calls[2][0]).toEqual(buildLogEvent([events[4]]));
+ });
+ });
+
+ describe('process', () => {
+ it('should return a promise that rejects if processor is not running', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater: getMockRepeater(),
+ batchSize: 100,
+ });
+
+ await expect(processor.process(createImpressionEvent('id-1'))).rejects.toThrow();
+ });
+
+ it('should enqueue event without dispatching immediately', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater: getMockRepeater(),
+ batchSize: 100,
+ });
+
+ processor.start();
+ await processor.onRunning();
+ for(let i = 0; i < 99; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ await processor.process(event);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+ });
+
+ it('should start the dispatchRepeater if it is not running', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const dispatchRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const event = createImpressionEvent('id-1');
+ await processor.process(event);
+
+ expect(dispatchRepeater.start).toHaveBeenCalledOnce();
+ });
+
+ it('should dispatch events if queue is full and clear queue', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockResolvedValue({});
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater: getMockRepeater(),
+ batchSize: 100,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ let events: ProcessableEvent[] = [];
+ for(let i = 0; i < 99; i++){
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+
+ let event = createImpressionEvent('id-99');
+ events.push(event);
+ await processor.process(event);
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(1);
+ expect(eventDispatcher.dispatchEvent.mock.calls[0][0]).toEqual(buildLogEvent(events));
+
+ events = [];
+
+ for(let i = 100; i < 199; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(1);
+
+ event = createImpressionEvent('id-199');
+ events.push(event);
+ await processor.process(event);
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(2);
+ expect(eventDispatcher.dispatchEvent.mock.calls[1][0]).toEqual(buildLogEvent(events));
+ });
+
+ it('should flush queue is context of the new event is different and enqueue the new event', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockResolvedValue({});
+
+ const dispatchRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 80; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+
+ const newEvent = createImpressionEvent('id-a');
+ newEvent.context.accountId = 'account-' + Math.random();
+ await processor.process(newEvent);
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(1);
+ expect(eventDispatcher.dispatchEvent.mock.calls[0][0]).toEqual(buildLogEvent(events));
+
+ await dispatchRepeater.execute(0);
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(2);
+ expect(eventDispatcher.dispatchEvent.mock.calls[1][0]).toEqual(buildLogEvent([newEvent]));
+ });
+
+ it('should flush queue immediately regardless of batchSize, if event processor is disposable', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockResolvedValue({});
+
+ const dispatchRepeater = getMockRepeater();
+ const failedEventRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ failedEventRepeater,
+ batchSize: 100,
+ });
+
+ processor.makeDisposable();
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ const event = createImpressionEvent('id-1');
+ events.push(event);
+ await processor.process(event);
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(1);
+ expect(eventDispatcher.dispatchEvent.mock.calls[0][0]).toEqual(buildLogEvent(events));
+ expect(dispatchRepeater.reset).toHaveBeenCalledTimes(1);
+ expect(dispatchRepeater.start).not.toHaveBeenCalled();
+ expect(failedEventRepeater.start).not.toHaveBeenCalled();
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ expect(processor.retryConfig?.maxRetries).toEqual(5);
+ });
+
+ it('should store the event in the eventStore with increasing ids', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const eventStore = getMockSyncCache();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater: getMockRepeater(),
+ batchSize: 100,
+ eventStore,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 10; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event)
+ }
+
+ expect(eventStore.size()).toEqual(10);
+
+ const eventsInStore = Array.from(eventStore.getAll().values())
+ .sort((a, b) => a < b ? -1 : 1).map(e => e.event);
+
+ expect(events).toEqual(eventsInStore);
+ });
+
+ it('should not store the event in the eventStore but still dispatch if the \
+ number of pending events is greater than the limit', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockResolvedValue(resolvablePromise().promise);
+
+ const eventStore = getMockSyncCache();
+
+ const idGenerator = new IdGenerator();
+
+ for (let i = 0; i < 505; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ const cacheId = idGenerator.getId();
+ await eventStore.set(cacheId, { id: cacheId, event });
+ }
+
+ expect(eventStore.size()).toEqual(505);
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater: getMockRepeater(),
+ batchSize: 1,
+ eventStore,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 2; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event)
+ }
+
+ expect(eventStore.size()).toEqual(505);
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(507);
+ expect(eventDispatcher.dispatchEvent.mock.calls[505][0]).toEqual(buildLogEvent([events[0]]));
+ expect(eventDispatcher.dispatchEvent.mock.calls[506][0]).toEqual(buildLogEvent([events[1]]));
+ });
+
+ it('should store events in the eventStore when the number of events in the store\
+ becomes lower than the limit', async () => {
+ const eventDispatcher = getMockDispatcher();
+
+ const dispatchResponses: ResolvablePromise[] = [];
+
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockImplementation((arg) => {
+ const dispatchResponse = resolvablePromise();
+ dispatchResponses.push(dispatchResponse);
+ return dispatchResponse.promise;
+ });
+
+ const eventStore = getMockSyncCache();
+
+ const idGenerator = new IdGenerator();
+
+ for (let i = 0; i < 502; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ const cacheId = String(i);
+ await eventStore.set(cacheId, { id: cacheId, event });
+ }
+
+ expect(eventStore.size()).toEqual(502);
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater: getMockRepeater(),
+ batchSize: 1,
+ eventStore,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ let events: ProcessableEvent[] = [];
+ for(let i = 0; i < 2; i++) {
+ const event = createImpressionEvent(`id-${i + 502}`);
+ events.push(event);
+ await processor.process(event)
+ }
+
+ expect(eventStore.size()).toEqual(502);
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(504);
+
+ expect(eventDispatcher.dispatchEvent.mock.calls[502][0]).toEqual(buildLogEvent([events[0]]));
+ expect(eventDispatcher.dispatchEvent.mock.calls[503][0]).toEqual(buildLogEvent([events[1]]));
+
+ // resolve the dispatch for events not saved in the store
+ dispatchResponses[502].resolve({ statusCode: 200 });
+ dispatchResponses[503].resolve({ statusCode: 200 });
+
+ await exhaustMicrotasks();
+ expect(eventStore.size()).toEqual(502);
+
+ // resolve the dispatch for 3 events in store, making the store size 499 which is lower than the limit
+ dispatchResponses[0].resolve({ statusCode: 200 });
+ dispatchResponses[1].resolve({ statusCode: 200 });
+ dispatchResponses[2].resolve({ statusCode: 200 });
+
+ await exhaustMicrotasks();
+ expect(eventStore.size()).toEqual(499);
+
+ // process 2 more events
+ events = [];
+ for(let i = 0; i < 2; i++) {
+ const event = createImpressionEvent(`id-${i + 504}`);
+ events.push(event);
+ await processor.process(event)
+ }
+
+ expect(eventStore.size()).toEqual(500);
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(506);
+ expect(eventDispatcher.dispatchEvent.mock.calls[504][0]).toEqual(buildLogEvent([events[0]]));
+ expect(eventDispatcher.dispatchEvent.mock.calls[505][0]).toEqual(buildLogEvent([events[1]]));
+ });
+
+ it('should still dispatch events even if the store save fails', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockResolvedValue({});
+
+ const eventStore = getMockAsyncCache();
+ // Simulate failure in saving to store
+ eventStore.set = vi.fn().mockRejectedValue(new Error('Failed to save'));
+
+ const dispatchRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ eventStore,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 10; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event)
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+
+ await dispatchRepeater.execute(0);
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(1);
+ expect(eventDispatcher.dispatchEvent.mock.calls[0][0]).toEqual(buildLogEvent(events));
+ });
+ });
+
+ it('should dispatch events when dispatchRepeater is triggered', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockResolvedValue({});
+ const dispatchRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ let events: ProcessableEvent[] = [];
+ for(let i = 0; i < 10; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+ await dispatchRepeater.execute(0);
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(1);
+ expect(eventDispatcher.dispatchEvent.mock.calls[0][0]).toEqual(buildLogEvent(events));
+
+ events = [];
+ for(let i = 1; i < 15; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event);
+ }
+
+ await dispatchRepeater.execute(0);
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(2);
+ expect(eventDispatcher.dispatchEvent.mock.calls[1][0]).toEqual(buildLogEvent(events));
+ });
+
+ it('should not retry failed dispatch if retryConfig is not provided', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockRejectedValue(new Error());
+ const dispatchRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 10; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+ await dispatchRepeater.execute(0);
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(1);
+ });
+
+ it('should retry specified number of times using the provided backoffController', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockRejectedValue(new Error());
+ const dispatchRepeater = getMockRepeater();
+
+ const backoffController = {
+ backoff: vi.fn().mockReturnValue(1000),
+ reset: vi.fn(),
+ };
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ retryConfig: {
+ backoffProvider: () => backoffController,
+ maxRetries: 3,
+ },
+ batchSize: 100,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 10; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+ await dispatchRepeater.execute(0);
+
+ for(let i = 0; i < 10; i++) {
+ await exhaustMicrotasks();
+ await advanceTimersByTime(1000);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(4);
+ expect(backoffController.backoff).toHaveBeenCalledTimes(3);
+
+ const request = buildLogEvent(events);
+ for(let i = 0; i < 4; i++) {
+ expect(eventDispatcher.dispatchEvent.mock.calls[i][0]).toEqual(request);
+ }
+ });
+
+ it('should remove the events from the eventStore after dispatch is successfull', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ const dispatchResponse = resolvablePromise();
+
+ mockDispatch.mockResolvedValue(dispatchResponse.promise);
+
+ const eventStore = getMockSyncCache();
+ const dispatchRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ eventStore,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 10; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event)
+ }
+
+ expect(eventStore.size()).toEqual(10);
+ await dispatchRepeater.execute(0);
+
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ // the dispatch is not resolved yet, so all the events should still be in the store
+ expect(eventStore.size()).toEqual(10);
+
+ dispatchResponse.resolve({ statusCode: 200 });
+
+ await exhaustMicrotasks();
+
+ expect(eventStore.size()).toEqual(0);
+ });
+
+ it('should remove the events from the eventStore after dispatch is successfull', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ const dispatchResponse = resolvablePromise();
+
+ mockDispatch.mockResolvedValue(dispatchResponse.promise);
+
+ const eventStore = getMockSyncCache();
+ const dispatchRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ eventStore,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 10; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event)
+ }
+
+ expect(eventStore.size()).toEqual(10);
+ await dispatchRepeater.execute(0);
+
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ // the dispatch is not resolved yet, so all the events should still be in the store
+ expect(eventStore.size()).toEqual(10);
+
+ dispatchResponse.resolve({ statusCode: 200 });
+
+ await exhaustMicrotasks();
+
+ expect(eventStore.size()).toEqual(0);
+ });
+
+ it('should remove the events from the eventStore after dispatch is successfull after retries', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+
+ mockDispatch.mockResolvedValueOnce({ statusCode: 500 })
+ .mockResolvedValueOnce({ statusCode: 500 })
+ .mockResolvedValueOnce({ statusCode: 200 });
+
+ const eventStore = getMockSyncCache();
+ const dispatchRepeater = getMockRepeater();
+
+ const backoffController = {
+ backoff: vi.fn().mockReturnValue(1000),
+ reset: vi.fn(),
+ };
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ eventStore,
+ retryConfig: {
+ backoffProvider: () => backoffController,
+ maxRetries: 3,
+ },
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 10; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event)
+ }
+
+ expect(eventStore.size()).toEqual(10);
+ await dispatchRepeater.execute(0);
+
+ for(let i = 0; i < 10; i++) {
+ await exhaustMicrotasks();
+ await advanceTimersByTime(1000);
+ }
+
+ expect(mockDispatch).toHaveBeenCalledTimes(3);
+ expect(eventStore.size()).toEqual(0);
+ });
+
+ it('should log error and keep events in store if dispatch return 5xx response', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockResolvedValue({ statusCode: 500 });
+ const dispatchRepeater = getMockRepeater();
+
+ const backoffController = {
+ backoff: vi.fn().mockReturnValue(1000),
+ reset: vi.fn(),
+ };
+
+ const eventStore = getMockSyncCache();
+ const logger = getMockLogger();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ eventStore,
+ retryConfig: {
+ backoffProvider: () => backoffController,
+ maxRetries: 3,
+ },
+ batchSize: 100,
+ logger,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 10; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+ expect(eventStore.size()).toEqual(10);
+
+ await dispatchRepeater.execute(0);
+
+ for(let i = 0; i < 10; i++) {
+ await exhaustMicrotasks();
+ await advanceTimersByTime(1000);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(4);
+ expect(backoffController.backoff).toHaveBeenCalledTimes(3);
+ expect(eventStore.size()).toEqual(10);
+ expect(logger.error).toHaveBeenCalledOnce();
+ });
+
+ it('should log error and keep events in store if dispatch promise fails', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockRejectedValue(new Error());
+ const dispatchRepeater = getMockRepeater();
+
+ const backoffController = {
+ backoff: vi.fn().mockReturnValue(1000),
+ reset: vi.fn(),
+ };
+
+ const eventStore = getMockSyncCache();
+ const logger = getMockLogger();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ eventStore,
+ retryConfig: {
+ backoffProvider: () => backoffController,
+ maxRetries: 3,
+ },
+ batchSize: 100,
+ logger,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 10; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+ expect(eventStore.size()).toEqual(10);
+
+ await dispatchRepeater.execute(0);
+
+ for(let i = 0; i < 10; i++) {
+ await exhaustMicrotasks();
+ await advanceTimersByTime(1000);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(4);
+ expect(backoffController.backoff).toHaveBeenCalledTimes(3);
+ expect(eventStore.size()).toEqual(10);
+ expect(logger.error).toHaveBeenCalledOnce();
+ });
+
+ describe('retryFailedEvents', () => {
+ it('should dispatch only failed events from the store and not dispatch queued events', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockResolvedValue({});
+
+ const cache = getMockSyncCache();
+ const dispatchRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ eventStore: cache,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ // these events should be in queue and should not be reomoved from store or dispatched with failed events
+ const eventA = createImpressionEvent('id-A');
+ const eventB = createImpressionEvent('id-B');
+ await processor.process(eventA);
+ await processor.process(eventB);
+
+ const failedEvents: ProcessableEvent[] = [];
+
+ for(let i = 0; i < 5; i++) {
+ const id = `id-${i}`;
+ const event = createImpressionEvent(id);
+ failedEvents.push(event);
+ cache.set(id, { id, event });
+ }
+
+ await processor.retryFailedEvents();
+ await exhaustMicrotasks();
+
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ expect(mockDispatch.mock.calls[0][0]).toEqual(buildLogEvent(failedEvents));
+
+ const eventsInStore = [...cache.getAll().values()].sort((a, b) => a.id < b.id ? -1 : 1).map(e => e.event);
+ expect(eventsInStore).toEqual(expect.arrayContaining([
+ expect.objectContaining(eventA),
+ expect.objectContaining(eventB),
+ ]));
+ });
+
+ it('should dispatch only failed events from the store and not dispatch events that are being dispatched', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ const mockResult1 = resolvablePromise();
+ const mockResult2 = resolvablePromise();
+ mockDispatch.mockResolvedValueOnce(mockResult1.promise).mockRejectedValueOnce(mockResult2.promise);
+
+ const cache = getMockSyncCache();
+ const dispatchRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ eventStore: cache,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ // these events should be in dispatch and should not be reomoved from store or dispatched with failed events
+ const eventA = createImpressionEvent('id-A');
+ const eventB = createImpressionEvent('id-B');
+ await processor.process(eventA);
+ await processor.process(eventB);
+
+ dispatchRepeater.execute(0);
+ await exhaustMicrotasks();
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ expect(mockDispatch.mock.calls[0][0]).toEqual(buildLogEvent([eventA, eventB]));
+
+ const failedEvents: ProcessableEvent[] = [];
+
+ for(let i = 0; i < 5; i++) {
+ const id = `id-${i}`;
+ const event = createImpressionEvent(id);
+ failedEvents.push(event);
+ cache.set(id, { id, event });
+ }
+
+ await processor.retryFailedEvents();
+ await exhaustMicrotasks();
+
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
+ expect(mockDispatch.mock.calls[1][0]).toEqual(buildLogEvent(failedEvents));
+
+ mockResult2.resolve({});
+ await exhaustMicrotasks();
+
+ const eventsInStore = [...cache.getAll().values()].sort((a, b) => a.id < b.id ? -1 : 1).map(e => e.event);
+ expect(eventsInStore).toEqual(expect.arrayContaining([
+ expect.objectContaining(eventA),
+ expect.objectContaining(eventB),
+ ]));
+ });
+
+ it('should dispatch events in correct batch size and separate events with differnt contexts in separate batch', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockResolvedValue({});
+
+ const cache = getMockSyncCache();
+ const dispatchRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 3,
+ eventStore: cache,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const failedEvents: ProcessableEvent[] = [];
+
+ for(let i = 0; i < 8; i++) {
+ const id = `id-${i}`;
+ const event = createImpressionEvent(id);
+
+ if (i == 2 || i == 3) {
+ event.context.accountId = 'new-account';
+ }
+
+ failedEvents.push(event);
+ cache.set(id, { id, event });
+ }
+
+ await processor.retryFailedEvents();
+ await exhaustMicrotasks();
+
+ // events 0 1 4 5 6 7 have one context, and 2 3 have different context
+ // batches should be [0, 1], [2, 3], [4, 5, 6], [7]
+ expect(mockDispatch).toHaveBeenCalledTimes(4);
+ expect(mockDispatch.mock.calls[0][0]).toEqual(buildLogEvent([failedEvents[0], failedEvents[1]]));
+ expect(mockDispatch.mock.calls[1][0]).toEqual(buildLogEvent([failedEvents[2], failedEvents[3]]));
+ expect(mockDispatch.mock.calls[2][0]).toEqual(buildLogEvent([failedEvents[4], failedEvents[5], failedEvents[6]]));
+ expect(mockDispatch.mock.calls[3][0]).toEqual(buildLogEvent([failedEvents[7]]));
+ });
+ });
+
+ describe('when failedEventRepeater is fired', () => {
+ it('should dispatch only failed events from the store and not dispatch queued events', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockResolvedValue({});
+
+ const cache = getMockSyncCache();
+ const dispatchRepeater = getMockRepeater();
+ const failedEventRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ failedEventRepeater,
+ batchSize: 100,
+ eventStore: cache,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ // these events should be in queue and should not be reomoved from store or dispatched with failed events
+ const eventA = createImpressionEvent('id-A');
+ const eventB = createImpressionEvent('id-B');
+ await processor.process(eventA);
+ await processor.process(eventB);
+
+ const failedEvents: ProcessableEvent[] = [];
+
+ for(let i = 0; i < 5; i++) {
+ const id = `id-${i}`;
+ const event = createImpressionEvent(id);
+ failedEvents.push(event);
+ cache.set(id, { id, event });
+ }
+
+ failedEventRepeater.execute(0);
+ await exhaustMicrotasks();
+
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ expect(mockDispatch.mock.calls[0][0]).toEqual(buildLogEvent(failedEvents));
+
+ const eventsInStore = [...cache.getAll().values()].sort((a, b) => a.id < b.id ? -1 : 1).map(e => e.event);
+ expect(eventsInStore).toEqual(expect.arrayContaining([
+ expect.objectContaining(eventA),
+ expect.objectContaining(eventB),
+ ]));
+ });
+
+ it('should dispatch only failed events from the store and not dispatch events that are being dispatched', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ const mockResult1 = resolvablePromise();
+ const mockResult2 = resolvablePromise();
+ mockDispatch.mockResolvedValueOnce(mockResult1.promise).mockRejectedValueOnce(mockResult2.promise);
+
+ const cache = getMockSyncCache();
+ const dispatchRepeater = getMockRepeater();
+ const failedEventRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ failedEventRepeater,
+ batchSize: 100,
+ eventStore: cache,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ // these events should be in dispatch and should not be reomoved from store or dispatched with failed events
+ const eventA = createImpressionEvent('id-A');
+ const eventB = createImpressionEvent('id-B');
+ await processor.process(eventA);
+ await processor.process(eventB);
+
+ dispatchRepeater.execute(0);
+ await exhaustMicrotasks();
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ expect(mockDispatch.mock.calls[0][0]).toEqual(buildLogEvent([eventA, eventB]));
+
+ const failedEvents: ProcessableEvent[] = [];
+
+ for(let i = 0; i < 5; i++) {
+ const id = `id-${i}`;
+ const event = createImpressionEvent(id);
+ failedEvents.push(event);
+ cache.set(id, { id, event });
+ }
+
+ failedEventRepeater.execute(0);
+ await exhaustMicrotasks();
+
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
+ expect(mockDispatch.mock.calls[1][0]).toEqual(buildLogEvent(failedEvents));
+
+ mockResult2.resolve({});
+ await exhaustMicrotasks();
+
+ const eventsInStore = [...cache.getAll().values()].sort((a, b) => a.id < b.id ? -1 : 1).map(e => e.event);
+ expect(eventsInStore).toEqual(expect.arrayContaining([
+ expect.objectContaining(eventA),
+ expect.objectContaining(eventB),
+ ]));
+ });
+
+ it('should dispatch events in correct batch size and separate events with differnt contexts in separate batch', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const mockDispatch: MockInstance = eventDispatcher.dispatchEvent;
+ mockDispatch.mockResolvedValue({});
+
+ const cache = getMockSyncCache();
+ const dispatchRepeater = getMockRepeater();
+ const failedEventRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ failedEventRepeater,
+ batchSize: 3,
+ eventStore: cache,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const failedEvents: ProcessableEvent[] = [];
+
+ for(let i = 0; i < 8; i++) {
+ const id = `id-${i}`;
+ const event = createImpressionEvent(id);
+
+ if (i == 2 || i == 3) {
+ event.context.accountId = 'new-account';
+ }
+
+ failedEvents.push(event);
+ cache.set(id, { id, event });
+ }
+
+ failedEventRepeater.execute(0);
+ await exhaustMicrotasks();
+
+ // events 0 1 4 5 6 7 have one context, and 2 3 have different context
+ // batches should be [0, 1], [2, 3], [4, 5, 6], [7]
+ expect(mockDispatch).toHaveBeenCalledTimes(4);
+ expect(mockDispatch.mock.calls[0][0]).toEqual(buildLogEvent([failedEvents[0], failedEvents[1]]));
+ expect(mockDispatch.mock.calls[1][0]).toEqual(buildLogEvent([failedEvents[2], failedEvents[3]]));
+ expect(mockDispatch.mock.calls[2][0]).toEqual(buildLogEvent([failedEvents[4], failedEvents[5], failedEvents[6]]));
+ expect(mockDispatch.mock.calls[3][0]).toEqual(buildLogEvent([failedEvents[7]]));
+ });
+ });
+
+ it('should emit dispatch event when dispatching events', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const dispatchRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ });
+
+ const event = createImpressionEvent('id-1');
+ const event2 = createImpressionEvent('id-2');
+
+ const dispatchListener = vi.fn();
+ processor.onDispatch(dispatchListener);
+
+ processor.start();
+ await processor.onRunning();
+
+ await processor.process(event);
+ await processor.process(event2);
+
+ await dispatchRepeater.execute(0);
+
+ expect(dispatchListener).toHaveBeenCalledTimes(1);
+ expect(dispatchListener.mock.calls[0][0]).toEqual(buildLogEvent([event, event2]));
+ });
+
+ it('should remove event handler when function returned from onDispatch is called', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const dispatchRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ });
+
+ const dispatchListener = vi.fn();
+
+ const unsub = processor.onDispatch(dispatchListener);
+
+ processor.start();
+ await processor.onRunning();
+
+ const event = createImpressionEvent('id-1');
+ const event2 = createImpressionEvent('id-2');
+
+ await processor.process(event);
+ await processor.process(event2);
+
+ await dispatchRepeater.execute(0);
+
+ expect(dispatchListener).toHaveBeenCalledTimes(1);
+ expect(dispatchListener.mock.calls[0][0]).toEqual(buildLogEvent([event, event2]));
+
+ unsub();
+
+ const event3 = createImpressionEvent('id-3');
+ const event4 = createImpressionEvent('id-4');
+
+ await dispatchRepeater.execute(0);
+ expect(dispatchListener).toHaveBeenCalledTimes(1);
+ });
+
+ describe('stop', () => {
+ it('should reject onRunning if stop is called before the processor is started', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const dispatchRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ });
+
+ processor.stop();
+
+ await expect(processor.onRunning()).rejects.toThrow();
+ });
+
+ it('should stop dispatchRepeater and failedEventRepeater', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const dispatchRepeater = getMockRepeater();
+ const failedEventRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ failedEventRepeater,
+ batchSize: 100,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ processor.stop();
+ expect(dispatchRepeater.stop).toHaveBeenCalledOnce();
+ expect(failedEventRepeater.stop).toHaveBeenCalledOnce();
+ });
+
+ it('should dispatch the events in queue using the closing dispatcher if available', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const closingEventDispatcher = getMockDispatcher();
+ closingEventDispatcher.dispatchEvent.mockResolvedValue({});
+
+ const dispatchRepeater = getMockRepeater();
+ const failedEventRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ closingEventDispatcher,
+ dispatchRepeater,
+ failedEventRepeater,
+ batchSize: 100,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 10; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+ expect(closingEventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+
+ processor.stop();
+ expect(closingEventDispatcher.dispatchEvent).toHaveBeenCalledTimes(1);
+ expect(closingEventDispatcher.dispatchEvent).toHaveBeenCalledWith(buildLogEvent(events));
+ });
+
+ it('should cancel retry of active dispatches', async () => {
+ const runWithRetrySpy = vi.spyOn(retry, 'runWithRetry');
+ const cancel1 = vi.fn();
+ const cancel2 = vi.fn();
+ runWithRetrySpy.mockReturnValueOnce({
+ cancelRetry: cancel1,
+ result: resolvablePromise().promise,
+ }).mockReturnValueOnce({
+ cancelRetry: cancel2,
+ result: resolvablePromise().promise,
+ });
+
+ const eventDispatcher = getMockDispatcher();
+ const dispatchRepeater = getMockRepeater();
+
+ const backoffController = {
+ backoff: vi.fn().mockReturnValue(1000),
+ reset: vi.fn(),
+ };
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ retryConfig: {
+ backoffProvider: () => backoffController,
+ maxRetries: 3,
+ }
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ await processor.process(createImpressionEvent('id-1'));
+ await dispatchRepeater.execute(0);
+
+ expect(runWithRetrySpy).toHaveBeenCalledTimes(1);
+
+ await processor.process(createImpressionEvent('id-2'));
+ await dispatchRepeater.execute(0);
+
+ expect(runWithRetrySpy).toHaveBeenCalledTimes(2);
+
+ processor.stop();
+
+ expect(cancel1).toHaveBeenCalledOnce();
+ expect(cancel2).toHaveBeenCalledOnce();
+
+ runWithRetrySpy.mockReset();
+ });
+
+ it('should resolve onTerminated when all active dispatch requests settles' , async () => {
+ const eventDispatcher = getMockDispatcher();
+ const dispatchRes1 = resolvablePromise();
+ const dispatchRes2 = resolvablePromise();
+ eventDispatcher.dispatchEvent.mockReturnValueOnce(dispatchRes1.promise)
+ .mockReturnValueOnce(dispatchRes2.promise);
+
+ const dispatchRepeater = getMockRepeater();
+
+ const backoffController = {
+ backoff: vi.fn().mockReturnValue(1000),
+ reset: vi.fn(),
+ };
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ batchSize: 100,
+ });
+
+ processor.start()
+ await processor.onRunning();
+
+ await processor.process(createImpressionEvent('id-1'));
+ await dispatchRepeater.execute(0);
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(1);
+
+ await processor.process(createImpressionEvent('id-2'));
+ await dispatchRepeater.execute(0);
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(2);
+
+ const onStop = vi.fn();
+ processor.onTerminated().then(onStop);
+
+ processor.stop();
+
+ await exhaustMicrotasks();
+ expect(onStop).not.toHaveBeenCalled();
+ expect(processor.getState()).toEqual(ServiceState.Stopping);
+
+ dispatchRes1.resolve();
+ dispatchRes2.reject(new Error());
+
+ await expect(processor.onTerminated()).resolves.not.toThrow();
+ });
+ });
+
+ describe('flushImmediately', () => {
+ it('should dispatch the events in queue using the closing dispatcher if available', async () => {
+ const eventDispatcher = getMockDispatcher();
+ const closingEventDispatcher = getMockDispatcher();
+ closingEventDispatcher.dispatchEvent.mockResolvedValue({});
+
+ const dispatchRepeater = getMockRepeater();
+ const failedEventRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ closingEventDispatcher,
+ dispatchRepeater,
+ failedEventRepeater,
+ batchSize: 100,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 10; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+ expect(closingEventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+
+ processor.flushImmediately();
+ expect(closingEventDispatcher.dispatchEvent).toHaveBeenCalledTimes(1);
+ expect(closingEventDispatcher.dispatchEvent).toHaveBeenCalledWith(buildLogEvent(events));
+
+ expect(processor.isRunning()).toBe(true);
+ });
+
+
+ it('should dispatch the events in queue using eventDispatcher if closingEventDispatcher is not available', async () => {
+ const eventDispatcher = getMockDispatcher();
+ eventDispatcher.dispatchEvent.mockResolvedValue({});
+
+ const dispatchRepeater = getMockRepeater();
+ const failedEventRepeater = getMockRepeater();
+
+ const processor = new BatchEventProcessor({
+ eventDispatcher,
+ dispatchRepeater,
+ failedEventRepeater,
+ batchSize: 100,
+ });
+
+ processor.start();
+ await processor.onRunning();
+
+ const events: ProcessableEvent[] = [];
+ for(let i = 0; i < 10; i++) {
+ const event = createImpressionEvent(`id-${i}`);
+ events.push(event);
+ await processor.process(event);
+ }
+
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(0);
+
+ processor.flushImmediately();
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledTimes(1);
+ expect(eventDispatcher.dispatchEvent).toHaveBeenCalledWith(buildLogEvent(events));
+
+ expect(processor.isRunning()).toBe(true);
+ });
+ });
+});
diff --git a/lib/event_processor/batch_event_processor.ts b/lib/event_processor/batch_event_processor.ts
new file mode 100644
index 000000000..86f7ff148
--- /dev/null
+++ b/lib/event_processor/batch_event_processor.ts
@@ -0,0 +1,365 @@
+/**
+ * Copyright 2024-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.
+ */
+
+import { EventProcessor, ProcessableEvent } from "./event_processor";
+import { getBatchedAsync, getBatchedSync, Store } from "../utils/cache/store";
+import { EventDispatcher, EventDispatcherResponse, LogEvent } from "./event_dispatcher/event_dispatcher";
+import { buildLogEvent } from "./event_builder/log_event";
+import { BackoffController, ExponentialBackoff, Repeater } from "../utils/repeater/repeater";
+import { LoggerFacade } from '../logging/logger';
+import { BaseService, ServiceState, StartupLog } from "../service";
+import { Consumer, Fn, Maybe, Producer } from "../utils/type";
+import { RunResult, runWithRetry } from "../utils/executor/backoff_retry_runner";
+import { isSuccessStatusCode } from "../utils/http_request_handler/http_util";
+import { EventEmitter } from "../utils/event_emitter/event_emitter";
+import { IdGenerator } from "../utils/id_generator";
+import { areEventContextsEqual } from "./event_builder/user_event";
+import { FAILED_TO_DISPATCH_EVENTS, SERVICE_NOT_RUNNING } from "error_message";
+import { OptimizelyError } from "../error/optimizly_error";
+import { sprintf } from "../utils/fns";
+import { SERVICE_STOPPED_BEFORE_RUNNING } from "../service";
+import { EVENT_STORE_FULL } from "../message/log_message";
+
+export const DEFAULT_MIN_BACKOFF = 1000;
+export const DEFAULT_MAX_BACKOFF = 32000;
+export const MAX_EVENTS_IN_STORE = 500;
+
+export type EventWithId = {
+ id: string;
+ event: ProcessableEvent;
+ notStored?: boolean;
+};
+
+export type RetryConfig = {
+ maxRetries: number;
+ backoffProvider: Producer;
+}
+
+export type BatchEventProcessorConfig = {
+ dispatchRepeater: Repeater,
+ failedEventRepeater?: Repeater,
+ batchSize: number,
+ eventStore?: Store,
+ eventDispatcher: EventDispatcher,
+ closingEventDispatcher?: EventDispatcher,
+ logger?: LoggerFacade,
+ retryConfig?: RetryConfig;
+ startupLogs?: StartupLog[];
+};
+
+type EventBatch = {
+ request: LogEvent,
+ events: EventWithId[],
+}
+
+export const LOGGER_NAME = 'BatchEventProcessor';
+
+export class BatchEventProcessor extends BaseService implements EventProcessor {
+ private eventDispatcher: EventDispatcher;
+ private closingEventDispatcher?: EventDispatcher;
+ private eventQueue: EventWithId[] = [];
+ private batchSize: number;
+ private eventStore?: Store;
+ private eventCountInStore: Maybe = undefined;
+ private eventCountWaitPromise: Promise = Promise.resolve();
+ private maxEventsInStore: number = MAX_EVENTS_IN_STORE;
+ private dispatchRepeater: Repeater;
+ private failedEventRepeater?: Repeater;
+ private idGenerator: IdGenerator = new IdGenerator();
+ private runningTask: Map> = new Map();
+ private dispatchingEvents: Map = new Map();
+ private eventEmitter: EventEmitter<{ dispatch: LogEvent }> = new EventEmitter();
+ private retryConfig?: RetryConfig;
+
+ constructor(config: BatchEventProcessorConfig) {
+ super(config.startupLogs);
+ this.eventDispatcher = config.eventDispatcher;
+ this.closingEventDispatcher = config.closingEventDispatcher;
+ this.batchSize = config.batchSize;
+ this.eventStore = config.eventStore;
+
+ this.retryConfig = config.retryConfig;
+
+ this.dispatchRepeater = config.dispatchRepeater;
+ this.dispatchRepeater.setTask(() => this.flush());
+
+ this.maxEventsInStore = Math.max(2 * config.batchSize, MAX_EVENTS_IN_STORE);
+ this.failedEventRepeater = config.failedEventRepeater;
+ this.failedEventRepeater?.setTask(() => this.retryFailedEvents());
+ if (config.logger) {
+ this.setLogger(config.logger);
+ }
+ }
+
+ setLogger(logger: LoggerFacade): void {
+ this.logger = logger;
+ this.logger.setName(LOGGER_NAME);
+ }
+
+ onDispatch(handler: Consumer): Fn {
+ return this.eventEmitter.on('dispatch', handler);
+ }
+
+ public async retryFailedEvents(): Promise {
+ if (!this.eventStore) {
+ return;
+ }
+
+ const keys = (await this.eventStore.getKeys()).filter(
+ (k) => !this.dispatchingEvents.has(k) && !this.eventQueue.find((e) => e.id === k)
+ );
+
+ const events = await (this.eventStore.operation === 'sync' ?
+ getBatchedSync(this.eventStore, keys) : getBatchedAsync(this.eventStore, keys));
+
+ const failedEvents: EventWithId[] = [];
+ events.forEach((e) => {
+ if(e) {
+ failedEvents.push(e);
+ }
+ });
+
+ if (failedEvents.length == 0) {
+ return;
+ }
+
+ failedEvents.sort((a, b) => a.id < b.id ? -1 : 1);
+
+ const batches: EventBatch[] = [];
+ let currentBatch: EventWithId[] = [];
+
+ failedEvents.forEach((event) => {
+ if (currentBatch.length === this.batchSize ||
+ (currentBatch.length > 0 && !areEventContextsEqual(currentBatch[0].event, event.event))) {
+ batches.push({
+ request: buildLogEvent(currentBatch.map((e) => e.event)),
+ events: currentBatch,
+ });
+ currentBatch = [];
+ }
+ currentBatch.push(event);
+ });
+
+ if (currentBatch.length > 0) {
+ batches.push({
+ request: buildLogEvent(currentBatch.map((e) => e.event)),
+ events: currentBatch,
+ });
+ }
+
+ batches.forEach((batch) => {
+ this.dispatchBatch(batch, false);
+ });
+ }
+
+ private createNewBatch(): EventBatch | undefined {
+ if (this.eventQueue.length == 0) {
+ return
+ }
+
+ const events: ProcessableEvent[] = [];
+ const eventWithIds: EventWithId[] = [];
+
+ this.eventQueue.forEach((event) => {
+ events.push(event.event);
+ eventWithIds.push(event);
+ });
+
+ this.eventQueue = [];
+ return { request: buildLogEvent(events), events: eventWithIds };
+ }
+
+ private async executeDispatch(request: LogEvent, closing = false): Promise {
+ const dispatcher = closing && this.closingEventDispatcher ? this.closingEventDispatcher : this.eventDispatcher;
+ return dispatcher.dispatchEvent(request).then((res) => {
+ if (res.statusCode && !isSuccessStatusCode(res.statusCode)) {
+ return Promise.reject(new OptimizelyError(FAILED_TO_DISPATCH_EVENTS, res.statusCode));
+ }
+ return Promise.resolve(res);
+ });
+ }
+
+ private dispatchBatch(batch: EventBatch, closing: boolean): void {
+ const { request, events } = batch;
+
+ events.forEach((event) => {
+ this.dispatchingEvents.set(event.id, event);
+ });
+
+ const runResult: RunResult = this.retryConfig
+ ? runWithRetry(
+ () => this.executeDispatch(request, closing), this.retryConfig.backoffProvider(), this.retryConfig.maxRetries
+ ) : {
+ result: this.executeDispatch(request, closing),
+ cancelRetry: () => {},
+ };
+
+ this.eventEmitter.emit('dispatch', request);
+
+ const taskId = this.idGenerator.getId();
+ this.runningTask.set(taskId, runResult);
+
+ runResult.result.then((res) => {
+ events.forEach((event) => {
+ this.eventStore?.remove(event.id);
+ if (!event.notStored && this.eventCountInStore) {
+ this.eventCountInStore--;
+ }
+ });
+ return Promise.resolve();
+ }).catch((err) => {
+ // if the dispatch fails, the events will still be
+ // in the store for future processing
+ this.logger?.error(err);
+ }).finally(() => {
+ this.runningTask.delete(taskId);
+ events.forEach((event) => this.dispatchingEvents.delete(event.id));
+ });
+ }
+
+ private async flush(useClosingDispatcher = false): Promise {
+ const batch = this.createNewBatch();
+ if (!batch) {
+ return;
+ }
+
+ this.dispatchRepeater.reset();
+ this.dispatchBatch(batch, useClosingDispatcher);
+ }
+
+ async process(event: ProcessableEvent): Promise {
+ if (!this.isRunning()) {
+ return Promise.reject(new OptimizelyError(SERVICE_NOT_RUNNING, 'BatchEventProcessor'));
+ }
+
+ const eventWithId: EventWithId = {
+ id: this.idGenerator.getId(),
+ event: event,
+ };
+
+ await this.storeEvent(eventWithId);
+
+ if (this.eventQueue.length > 0 && !areEventContextsEqual(this.eventQueue[0].event, event)) {
+ this.flush();
+ }
+
+ this.eventQueue.push(eventWithId);
+
+ if (this.eventQueue.length == this.batchSize) {
+ this.flush();
+ } else if (!this.dispatchRepeater.isRunning()) {
+ this.dispatchRepeater.start();
+ }
+ }
+
+ private async readEventCountInStore(store: Store): Promise {
+ if (this.eventCountInStore !== undefined) {
+ return;
+ }
+
+ try {
+ const keys = await store.getKeys();
+ this.eventCountInStore = keys.length;
+ } catch (e) {
+ this.logger?.error(e);
+ }
+ }
+
+ private async findEventCountInStore(): Promise {
+ if (this.eventStore && this.eventCountInStore === undefined) {
+ const store = this.eventStore;
+ this.eventCountWaitPromise = this.eventCountWaitPromise.then(() => this.readEventCountInStore(store));
+ return this.eventCountWaitPromise;
+ }
+ return Promise.resolve();
+ }
+
+ private async storeEvent(eventWithId: EventWithId): Promise {
+ await this.findEventCountInStore();
+ if (this.eventCountInStore !== undefined && this.eventCountInStore >= this.maxEventsInStore) {
+ this.logger?.info(EVENT_STORE_FULL, eventWithId.event.uuid);
+ eventWithId.notStored = true;
+ return;
+ }
+
+ await Promise.resolve(this.eventStore?.set(eventWithId.id, eventWithId)).then(() => {
+ if (this.eventCountInStore !== undefined) {
+ this.eventCountInStore++;
+ }
+ }).catch((e) => {
+ eventWithId.notStored = true;
+ this.logger?.error(e);
+ });
+ }
+
+ start(): void {
+ if (!this.isNew()) {
+ return;
+ }
+
+ super.start();
+ this.state = ServiceState.Running;
+
+ if(!this.disposable) {
+ this.failedEventRepeater?.start();
+ }
+
+ this.retryFailedEvents();
+ this.startPromise.resolve();
+ }
+
+ makeDisposable(): void {
+ super.makeDisposable();
+ this.batchSize = 1;
+ this.retryConfig = {
+ maxRetries: Math.min(this.retryConfig?.maxRetries ?? 5, 5),
+ backoffProvider:
+ this.retryConfig?.backoffProvider ||
+ (() => new ExponentialBackoff(DEFAULT_MIN_BACKOFF, DEFAULT_MAX_BACKOFF, 500)),
+ }
+ }
+
+ flushImmediately(): Promise {
+ if (!this.isRunning()) {
+ return Promise.resolve();
+ }
+ return this.flush(true);
+ }
+
+ stop(): void {
+ if (this.isDone()) {
+ return;
+ }
+
+ if (this.isNew()) {
+ this.startPromise.reject(new Error(
+ sprintf(SERVICE_STOPPED_BEFORE_RUNNING, 'BatchEventProcessor')
+ ));
+ }
+
+ this.state = ServiceState.Stopping;
+ this.dispatchRepeater.stop();
+ this.failedEventRepeater?.stop();
+
+ this.flush(true);
+ this.runningTask.forEach((task) => task.cancelRetry());
+
+ Promise.allSettled(Array.from(this.runningTask.values()).map((task) => task.result)).then(() => {
+ this.state = ServiceState.Terminated;
+ this.stopPromise.resolve();
+ });
+ }
+}
diff --git a/lib/event_processor/event_builder/log_event.spec.ts b/lib/event_processor/event_builder/log_event.spec.ts
new file mode 100644
index 000000000..ad3b22b94
--- /dev/null
+++ b/lib/event_processor/event_builder/log_event.spec.ts
@@ -0,0 +1,870 @@
+/**
+ * Copyright 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.
+ * 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.
+ */
+import { describe, it, expect } from 'vitest';
+
+import {
+ makeEventBatch,
+ buildLogEvent,
+} from './log_event';
+
+import { ImpressionEvent, ConversionEvent, UserEvent } from './user_event';
+import { Region } from '../../project_config/project_config';
+
+
+describe('makeEventBatch', () => {
+ it('should build a batch with single impression event when experiment and variation are defined', () => {
+ const impressionEvent: ImpressionEvent = {
+ type: 'impression',
+ timestamp: 69,
+ uuid: 'uuid',
+
+ context: {
+ accountId: 'accountId',
+ projectId: 'projectId',
+ clientName: 'node-sdk',
+ clientVersion: '3.0.0',
+ revision: 'revision',
+ botFiltering: true,
+ anonymizeIP: true,
+ },
+
+ user: {
+ id: 'userId',
+ attributes: [{ entityId: 'attr1-id', key: 'attr1-key', value: 'attr1-value' }],
+ },
+
+ layer: {
+ id: 'layerId',
+ },
+
+ experiment: {
+ id: 'expId',
+ key: 'expKey',
+ },
+
+ variation: {
+ id: 'varId',
+ key: 'varKey',
+ },
+
+ ruleKey: 'expKey',
+ flagKey: 'flagKey1',
+ ruleType: 'experiment',
+ enabled: true,
+ }
+
+ const result = makeEventBatch([impressionEvent])
+ expect(result).toEqual({
+ client_name: 'node-sdk',
+ client_version: '3.0.0',
+ account_id: 'accountId',
+ project_id: 'projectId',
+ revision: 'revision',
+ anonymize_ip: true,
+ enrich_decisions: true,
+
+ visitors: [
+ {
+ snapshots: [
+ {
+ decisions: [
+ {
+ campaign_id: 'layerId',
+ experiment_id: 'expId',
+ variation_id: 'varId',
+ metadata: {
+ flag_key: 'flagKey1',
+ rule_key: 'expKey',
+ rule_type: 'experiment',
+ variation_key: 'varKey',
+ enabled: true,
+ },
+ },
+ ],
+ events: [
+ {
+ entity_id: 'layerId',
+ timestamp: 69,
+ key: 'campaign_activated',
+ uuid: 'uuid',
+ },
+ ],
+ },
+ ],
+ visitor_id: 'userId',
+ attributes: [
+ {
+ entity_id: 'attr1-id',
+ key: 'attr1-key',
+ type: 'custom',
+ value: 'attr1-value',
+ },
+ {
+ entity_id: '$opt_bot_filtering',
+ key: '$opt_bot_filtering',
+ type: 'custom',
+ value: true,
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ it('should build a batch with simlge impression event when experiment and variation are not defined', () => {
+ const impressionEvent: ImpressionEvent = {
+ type: 'impression',
+ timestamp: 69,
+ uuid: 'uuid',
+
+ context: {
+ accountId: 'accountId',
+ projectId: 'projectId',
+ clientName: 'node-sdk',
+ clientVersion: '3.0.0',
+ revision: 'revision',
+ botFiltering: true,
+ anonymizeIP: true,
+ },
+
+ user: {
+ id: 'userId',
+ attributes: [{ entityId: 'attr1-id', key: 'attr1-key', value: 'attr1-value' }],
+ },
+
+ layer: {
+ id: null,
+ },
+
+ experiment: {
+ id: null,
+ key: '',
+ },
+
+ variation: {
+ id: null,
+ key: '',
+ },
+
+ ruleKey: '',
+ flagKey: 'flagKey1',
+ ruleType: 'rollout',
+ enabled: true,
+ }
+
+ const result = makeEventBatch([impressionEvent])
+ expect(result).toEqual({
+ client_name: 'node-sdk',
+ client_version: '3.0.0',
+ account_id: 'accountId',
+ project_id: 'projectId',
+ revision: 'revision',
+ anonymize_ip: true,
+ enrich_decisions: true,
+
+ visitors: [
+ {
+ snapshots: [
+ {
+ decisions: [
+ {
+ campaign_id: null,
+ experiment_id: "",
+ variation_id: "",
+ metadata: {
+ flag_key: 'flagKey1',
+ rule_key: '',
+ rule_type: 'rollout',
+ variation_key: '',
+ enabled: true,
+ },
+ },
+ ],
+ events: [
+ {
+ entity_id: null,
+ timestamp: 69,
+ key: 'campaign_activated',
+ uuid: 'uuid',
+ },
+ ],
+ },
+ ],
+ visitor_id: 'userId',
+ attributes: [
+ {
+ entity_id: 'attr1-id',
+ key: 'attr1-key',
+ type: 'custom',
+ value: 'attr1-value',
+ },
+ {
+ entity_id: '$opt_bot_filtering',
+ key: '$opt_bot_filtering',
+ type: 'custom',
+ value: true,
+ },
+ ],
+ },
+ ],
+ })
+ });
+
+ it('should build a batch with single conversion event when tags object is defined', () => {
+ const conversionEvent: ConversionEvent = {
+ type: 'conversion',
+ timestamp: 69,
+ uuid: 'uuid',
+
+ context: {
+ accountId: 'accountId',
+ projectId: 'projectId',
+ clientName: 'node-sdk',
+ clientVersion: '3.0.0',
+ revision: 'revision',
+ botFiltering: true,
+ anonymizeIP: true,
+ },
+
+ user: {
+ id: 'userId',
+ attributes: [{ entityId: 'attr1-id', key: 'attr1-key', value: 'attr1-value' }],
+ },
+
+ event: {
+ id: 'event-id',
+ key: 'event-key',
+ },
+
+ tags: {
+ foo: 'bar',
+ value: '123',
+ revenue: '1000',
+ },
+
+ revenue: 1000,
+ value: 123,
+ }
+
+ const result = makeEventBatch([conversionEvent])
+ expect(result).toEqual({
+ client_name: 'node-sdk',
+ client_version: '3.0.0',
+ account_id: 'accountId',
+ project_id: 'projectId',
+ revision: 'revision',
+ anonymize_ip: true,
+ enrich_decisions: true,
+
+ visitors: [
+ {
+ snapshots: [
+ {
+ events: [
+ {
+ entity_id: 'event-id',
+ timestamp: 69,
+ key: 'event-key',
+ uuid: 'uuid',
+ tags: {
+ foo: 'bar',
+ value: '123',
+ revenue: '1000',
+ },
+ revenue: 1000,
+ value: 123,
+ },
+ ],
+ },
+ ],
+ visitor_id: 'userId',
+ attributes: [
+ {
+ entity_id: 'attr1-id',
+ key: 'attr1-key',
+ type: 'custom',
+ value: 'attr1-value',
+ },
+ {
+ entity_id: '$opt_bot_filtering',
+ key: '$opt_bot_filtering',
+ type: 'custom',
+ value: true,
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ it('should build a batch with single conversion event when when tags object is undefined', () => {
+ const conversionEvent: ConversionEvent = {
+ type: 'conversion',
+ timestamp: 69,
+ uuid: 'uuid',
+
+ context: {
+ accountId: 'accountId',
+ projectId: 'projectId',
+ clientName: 'node-sdk',
+ clientVersion: '3.0.0',
+ revision: 'revision',
+ botFiltering: true,
+ anonymizeIP: true,
+ },
+
+ user: {
+ id: 'userId',
+ attributes: [{ entityId: 'attr1-id', key: 'attr1-key', value: 'attr1-value' }],
+ },
+
+ event: {
+ id: 'event-id',
+ key: 'event-key',
+ },
+
+ tags: undefined,
+
+ revenue: 1000,
+ value: 123,
+ }
+
+ const result = makeEventBatch([conversionEvent])
+ expect(result).toEqual({
+ client_name: 'node-sdk',
+ client_version: '3.0.0',
+ account_id: 'accountId',
+ project_id: 'projectId',
+ revision: 'revision',
+ anonymize_ip: true,
+ enrich_decisions: true,
+
+ visitors: [
+ {
+ snapshots: [
+ {
+ events: [
+ {
+ entity_id: 'event-id',
+ timestamp: 69,
+ key: 'event-key',
+ uuid: 'uuid',
+ tags: undefined,
+ revenue: 1000,
+ value: 123,
+ },
+ ],
+ },
+ ],
+ visitor_id: 'userId',
+ attributes: [
+ {
+ entity_id: 'attr1-id',
+ key: 'attr1-key',
+ type: 'custom',
+ value: 'attr1-value',
+ },
+ {
+ entity_id: '$opt_bot_filtering',
+ key: '$opt_bot_filtering',
+ type: 'custom',
+ value: true,
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ it('should build a batch with single conversion event when event id is null', () => {
+ const conversionEvent: ConversionEvent = {
+ type: 'conversion',
+ timestamp: 69,
+ uuid: 'uuid',
+
+ context: {
+ accountId: 'accountId',
+ projectId: 'projectId',
+ clientName: 'node-sdk',
+ clientVersion: '3.0.0',
+ revision: 'revision',
+ botFiltering: true,
+ anonymizeIP: true,
+ },
+
+ user: {
+ id: 'userId',
+ attributes: [{ entityId: 'attr1-id', key: 'attr1-key', value: 'attr1-value' }],
+ },
+
+ event: {
+ id: null,
+ key: 'event-key',
+ },
+
+ tags: undefined,
+
+ revenue: 1000,
+ value: 123,
+ }
+
+ const result = makeEventBatch([conversionEvent])
+ expect(result).toEqual({
+ client_name: 'node-sdk',
+ client_version: '3.0.0',
+ account_id: 'accountId',
+ project_id: 'projectId',
+ revision: 'revision',
+ anonymize_ip: true,
+ enrich_decisions: true,
+
+ visitors: [
+ {
+ snapshots: [
+ {
+ events: [
+ {
+ entity_id: null,
+ timestamp: 69,
+ key: 'event-key',
+ uuid: 'uuid',
+ tags: undefined,
+ revenue: 1000,
+ value: 123,
+ },
+ ],
+ },
+ ],
+ visitor_id: 'userId',
+ attributes: [
+ {
+ entity_id: 'attr1-id',
+ key: 'attr1-key',
+ type: 'custom',
+ value: 'attr1-value',
+ },
+ {
+ entity_id: '$opt_bot_filtering',
+ key: '$opt_bot_filtering',
+ type: 'custom',
+ value: true,
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ it('should include revenue and value for conversion events if they are 0', () => {
+ const conversionEvent: ConversionEvent = {
+ type: 'conversion',
+ timestamp: 69,
+ uuid: 'uuid',
+
+ context: {
+ accountId: 'accountId',
+ projectId: 'projectId',
+ clientName: 'node-sdk',
+ clientVersion: '3.0.0',
+ revision: 'revision',
+ botFiltering: true,
+ anonymizeIP: true,
+ },
+
+ user: {
+ id: 'userId',
+ attributes: [{ entityId: 'attr1-id', key: 'attr1-key', value: 'attr1-value' }],
+ },
+
+ event: {
+ id: 'event-id',
+ key: 'event-key',
+ },
+
+ tags: {
+ foo: 'bar',
+ value: 0,
+ revenue: 0,
+ },
+
+ revenue: 0,
+ value: 0,
+ }
+
+ const result = makeEventBatch([conversionEvent])
+ expect(result).toEqual({
+ client_name: 'node-sdk',
+ client_version: '3.0.0',
+ account_id: 'accountId',
+ project_id: 'projectId',
+ revision: 'revision',
+ anonymize_ip: true,
+ enrich_decisions: true,
+
+ visitors: [
+ {
+ snapshots: [
+ {
+ events: [
+ {
+ entity_id: 'event-id',
+ timestamp: 69,
+ key: 'event-key',
+ uuid: 'uuid',
+ tags: {
+ foo: 'bar',
+ value: 0,
+ revenue: 0,
+ },
+ revenue: 0,
+ value: 0,
+ },
+ ],
+ },
+ ],
+ visitor_id: 'userId',
+ attributes: [
+ {
+ entity_id: 'attr1-id',
+ key: 'attr1-key',
+ type: 'custom',
+ value: 'attr1-value',
+ },
+ {
+ entity_id: '$opt_bot_filtering',
+ key: '$opt_bot_filtering',
+ type: 'custom',
+ value: true,
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ it('should not include $opt_bot_filtering attribute if context.botFiltering is undefined', () => {
+ const conversionEvent: ConversionEvent = {
+ type: 'conversion',
+ timestamp: 69,
+ uuid: 'uuid',
+
+ context: {
+ accountId: 'accountId',
+ projectId: 'projectId',
+ clientName: 'node-sdk',
+ clientVersion: '3.0.0',
+ revision: 'revision',
+ anonymizeIP: true,
+ },
+
+ user: {
+ id: 'userId',
+ attributes: [{ entityId: 'attr1-id', key: 'attr1-key', value: 'attr1-value' }],
+ },
+
+ event: {
+ id: 'event-id',
+ key: 'event-key',
+ },
+
+ tags: {
+ foo: 'bar',
+ value: '123',
+ revenue: '1000',
+ },
+
+ revenue: 1000,
+ value: 123,
+ }
+
+ const result = makeEventBatch([conversionEvent])
+ expect(result).toEqual({
+ client_name: 'node-sdk',
+ client_version: '3.0.0',
+ account_id: 'accountId',
+ project_id: 'projectId',
+ revision: 'revision',
+ anonymize_ip: true,
+ enrich_decisions: true,
+
+ visitors: [
+ {
+ snapshots: [
+ {
+ events: [
+ {
+ entity_id: 'event-id',
+ timestamp: 69,
+ key: 'event-key',
+ uuid: 'uuid',
+ tags: {
+ foo: 'bar',
+ value: '123',
+ revenue: '1000',
+ },
+ revenue: 1000,
+ value: 123,
+ },
+ ],
+ },
+ ],
+ visitor_id: 'userId',
+ attributes: [
+ {
+ entity_id: 'attr1-id',
+ key: 'attr1-key',
+ type: 'custom',
+ value: 'attr1-value',
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ it('should batch Conversion and Impression events together', () => {
+ const conversionEvent: ConversionEvent = {
+ type: 'conversion',
+ timestamp: 69,
+ uuid: 'uuid',
+
+ context: {
+ accountId: 'accountId',
+ projectId: 'projectId',
+ clientName: 'node-sdk',
+ clientVersion: '3.0.0',
+ revision: 'revision',
+ botFiltering: true,
+ anonymizeIP: true,
+ },
+
+ user: {
+ id: 'userId',
+ attributes: [{ entityId: 'attr1-id', key: 'attr1-key', value: 'attr1-value' }],
+ },
+
+ event: {
+ id: 'event-id',
+ key: 'event-key',
+ },
+
+ tags: {
+ foo: 'bar',
+ value: '123',
+ revenue: '1000',
+ },
+
+ revenue: 1000,
+ value: 123,
+ }
+
+ const impressionEvent: ImpressionEvent = {
+ type: 'impression',
+ timestamp: 69,
+ uuid: 'uuid',
+
+ context: {
+ accountId: 'accountId',
+ projectId: 'projectId',
+ clientName: 'node-sdk',
+ clientVersion: '3.0.0',
+ revision: 'revision',
+ botFiltering: true,
+ anonymizeIP: true,
+ },
+
+ user: {
+ id: 'userId',
+ attributes: [{ entityId: 'attr1-id', key: 'attr1-key', value: 'attr1-value' }],
+ },
+
+ layer: {
+ id: 'layerId',
+ },
+
+ experiment: {
+ id: 'expId',
+ key: 'expKey',
+ },
+
+ variation: {
+ id: 'varId',
+ key: 'varKey',
+ },
+
+ ruleKey: 'expKey',
+ flagKey: 'flagKey1',
+ ruleType: 'experiment',
+ enabled: true,
+ }
+
+ const result = makeEventBatch([impressionEvent, conversionEvent])
+
+ expect(result).toEqual({
+ client_name: 'node-sdk',
+ client_version: '3.0.0',
+ account_id: 'accountId',
+ project_id: 'projectId',
+ revision: 'revision',
+ anonymize_ip: true,
+ enrich_decisions: true,
+
+ visitors: [
+ {
+ snapshots: [
+ {
+ decisions: [
+ {
+ campaign_id: 'layerId',
+ experiment_id: 'expId',
+ variation_id: 'varId',
+ metadata: {
+ flag_key: 'flagKey1',
+ rule_key: 'expKey',
+ rule_type: 'experiment',
+ variation_key: 'varKey',
+ enabled: true,
+ },
+ },
+ ],
+ events: [
+ {
+ entity_id: 'layerId',
+ timestamp: 69,
+ key: 'campaign_activated',
+ uuid: 'uuid',
+ },
+ ],
+ },
+ ],
+ visitor_id: 'userId',
+ attributes: [
+ {
+ entity_id: 'attr1-id',
+ key: 'attr1-key',
+ type: 'custom',
+ value: 'attr1-value',
+ },
+ {
+ entity_id: '$opt_bot_filtering',
+ key: '$opt_bot_filtering',
+ type: 'custom',
+ value: true,
+ },
+ ],
+ },
+ {
+ snapshots: [
+ {
+ events: [
+ {
+ entity_id: 'event-id',
+ timestamp: 69,
+ key: 'event-key',
+ uuid: 'uuid',
+ tags: {
+ foo: 'bar',
+ value: '123',
+ revenue: '1000',
+ },
+ revenue: 1000,
+ value: 123,
+ },
+ ],
+ },
+ ],
+ visitor_id: 'userId',
+ attributes: [
+ {
+ entity_id: 'attr1-id',
+ key: 'attr1-key',
+ type: 'custom',
+ value: 'attr1-value',
+ },
+ {
+ entity_id: '$opt_bot_filtering',
+ key: '$opt_bot_filtering',
+ type: 'custom',
+ value: true,
+ },
+ ],
+ },
+ ],
+ })
+ })
+})
+
+describe('buildLogEvent', () => {
+ it('should select the correct URL based on the event context region', () => {
+ const baseEvent: ImpressionEvent = {
+ type: 'impression',
+ timestamp: 69,
+ uuid: 'uuid',
+ context: {
+ accountId: 'accountId',
+ projectId: 'projectId',
+ clientName: 'node-sdk',
+ clientVersion: '3.0.0',
+ revision: 'revision',
+ botFiltering: true,
+ anonymizeIP: true
+ },
+ user: {
+ id: 'userId',
+ attributes: []
+ },
+ layer: {
+ id: 'layerId'
+ },
+ experiment: {
+ id: 'expId',
+ key: 'expKey'
+ },
+ variation: {
+ id: 'varId',
+ key: 'varKey'
+ },
+ ruleKey: 'expKey',
+ flagKey: 'flagKey1',
+ ruleType: 'experiment',
+ enabled: true
+ };
+
+ // Test for US region
+ const usEvent = {
+ ...baseEvent,
+ context: {
+ ...baseEvent.context,
+ region: 'US' as Region
+ }
+ };
+
+ const usResult = buildLogEvent([usEvent]);
+ expect(usResult.url).toBe('https://logx.optimizely.com/v1/events');
+
+ // Test for EU region
+ const euEvent = {
+ ...baseEvent,
+ context: {
+ ...baseEvent.context,
+ region: 'EU' as Region
+ }
+ };
+
+ const euResult = buildLogEvent([euEvent]);
+ expect(euResult.url).toBe('https://eu.logx.optimizely.com/v1/events');
+ });
+});
diff --git a/packages/optimizely-sdk/lib/core/event_builder/build_event_v1.ts b/lib/event_processor/event_builder/log_event.ts
similarity index 72%
rename from packages/optimizely-sdk/lib/core/event_builder/build_event_v1.ts
rename to lib/event_processor/event_builder/log_event.ts
index b1f5b271d..4d4048950 100644
--- a/packages/optimizely-sdk/lib/core/event_builder/build_event_v1.ts
+++ b/lib/event_processor/event_builder/log_event.ts
@@ -1,5 +1,5 @@
/**
- * Copyright 2021-2022, Optimizely
+ * Copyright 2021-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.
@@ -13,21 +13,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import {
- EventTags,
- ConversionEvent,
- ImpressionEvent,
-} from '../../modules/event_processor';
+import { ConversionEvent, ImpressionEvent, UserEvent } from './user_event';
-import { Event } from '../../shared_types';
+import { CONTROL_ATTRIBUTES } from '../../utils/enums';
-type ProcessableEvent = ConversionEvent | ImpressionEvent
+import { LogEvent } from '../event_dispatcher/event_dispatcher';
+import { EventTags } from '../../shared_types';
+import { Region } from '../../project_config/project_config';
const ACTIVATE_EVENT_KEY = 'campaign_activated'
const CUSTOM_ATTRIBUTE_FEATURE_TYPE = 'custom'
-const BOT_FILTERING_KEY = '$opt_bot_filtering'
-export type EventV1 = {
+export const logxEndpoint: Record = {
+ US: 'https://logx.optimizely.com/v1/events',
+ EU: 'https://eu.logx.optimizely.com/v1/events',
+}
+
+export type EventBatch = {
account_id: string
project_id: string
revision: string
@@ -73,6 +75,7 @@ type Metadata = {
rule_type: string;
variation_key: string;
enabled: boolean;
+ cmab_uuid?: string;
}
export type SnapshotEvent = {
@@ -89,10 +92,10 @@ export type SnapshotEvent = {
* Given an array of batchable Decision or ConversionEvent events it returns
* a single EventV1 with proper batching
*
- * @param {ProcessableEvent[]} events
- * @returns {EventV1}
+ * @param {UserEvent[]} events
+ * @returns {EventBatch}
*/
-export function makeBatchedEventV1(events: ProcessableEvent[]): EventV1 {
+export function makeEventBatch(events: UserEvent[]): EventBatch {
const visitors: Visitor[] = []
const data = events[0]
@@ -157,7 +160,7 @@ function makeConversionSnapshot(conversion: ConversionEvent): Snapshot {
}
function makeDecisionSnapshot(event: ImpressionEvent): Snapshot {
- const { layer, experiment, variation, ruleKey, flagKey, ruleType, enabled } = event
+ const { layer, experiment, variation, ruleKey, flagKey, ruleType, enabled, cmabUuid } = event
const layerId = layer ? layer.id : null
const experimentId = experiment?.id ?? ''
const variationId = variation?.id ?? ''
@@ -175,6 +178,7 @@ function makeDecisionSnapshot(event: ImpressionEvent): Snapshot {
rule_type: ruleType,
variation_key: variationKey,
enabled: enabled,
+ cmab_uuid: cmabUuid,
},
},
],
@@ -207,8 +211,8 @@ function makeVisitor(data: ImpressionEvent | ConversionEvent): Visitor {
if (typeof data.context.botFiltering === 'boolean') {
visitor.attributes.push({
- entity_id: BOT_FILTERING_KEY,
- key: BOT_FILTERING_KEY,
+ entity_id: CONTROL_ATTRIBUTES.BOT_FILTERING,
+ key: CONTROL_ATTRIBUTES.BOT_FILTERING,
type: CUSTOM_ATTRIBUTE_FEATURE_TYPE,
value: data.context.botFiltering,
})
@@ -216,52 +220,13 @@ function makeVisitor(data: ImpressionEvent | ConversionEvent): Visitor {
return visitor
}
-/**
- * Event for usage with v1 logtier
- *
- * @export
- * @interface EventBuilderV1
- */
-export function buildImpressionEventV1(data: ImpressionEvent): EventV1 {
- const visitor = makeVisitor(data)
- visitor.snapshots.push(makeDecisionSnapshot(data))
-
- return {
- client_name: data.context.clientName,
- client_version: data.context.clientVersion,
-
- account_id: data.context.accountId,
- project_id: data.context.projectId,
- revision: data.context.revision,
- anonymize_ip: data.context.anonymizeIP,
- enrich_decisions: true,
-
- visitors: [visitor],
- }
-}
-
-export function buildConversionEventV1(data: ConversionEvent): EventV1 {
- const visitor = makeVisitor(data)
- visitor.snapshots.push(makeConversionSnapshot(data))
-
- return {
- client_name: data.context.clientName,
- client_version: data.context.clientVersion,
-
- account_id: data.context.accountId,
- project_id: data.context.projectId,
- revision: data.context.revision,
- anonymize_ip: data.context.anonymizeIP,
- enrich_decisions: true,
-
- visitors: [visitor],
- }
-}
+export function buildLogEvent(events: UserEvent[]): LogEvent {
+ const region = events[0]?.context.region || 'US';
+ const url = logxEndpoint[region] || logxEndpoint['US'];
-export function formatEvents(events: ProcessableEvent[]): Event {
return {
- url: 'https://logx.optimizely.com/v1/events',
+ url,
httpVerb: 'POST',
- params: makeBatchedEventV1(events),
+ params: makeEventBatch(events),
}
}
diff --git a/lib/event_processor/event_builder/user_event.spec.ts b/lib/event_processor/event_builder/user_event.spec.ts
new file mode 100644
index 000000000..e8cb373b3
--- /dev/null
+++ b/lib/event_processor/event_builder/user_event.spec.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect, vi } from 'vitest';
+import { buildImpressionEvent, buildConversionEvent } from './user_event';
+import { createProjectConfig, ProjectConfig } from '../../project_config/project_config';
+import { DecisionObj } from '../../core/decision_service';
+import testData from '../../tests/test_data';
+
+describe('buildImpressionEvent', () => {
+ it('should use correct region from projectConfig in event context', () => {
+ const projectConfig = createProjectConfig(
+ testData.getTestProjectConfig(),
+ )
+
+ const experiment = projectConfig.experiments[0];
+ const variation = experiment.variations[0];
+
+ const decisionObj = {
+ experiment,
+ variation,
+ decisionSource: 'experiment',
+ } as DecisionObj;
+
+
+ const impressionEvent = buildImpressionEvent({
+ configObj: projectConfig,
+ decisionObj,
+ userId: 'test_user',
+ flagKey: 'test_flag',
+ enabled: true,
+ clientEngine: 'node-sdk',
+ clientVersion: '1.0.0',
+ });
+
+ expect(impressionEvent.context.region).toBe('US');
+
+ projectConfig.region = 'EU';
+
+ const impressionEventEU = buildImpressionEvent({
+ configObj: projectConfig,
+ decisionObj,
+ userId: 'test_user',
+ flagKey: 'test_flag',
+ enabled: true,
+ clientEngine: 'node-sdk',
+ clientVersion: '1.0.0',
+ });
+
+ expect(impressionEventEU.context.region).toBe('EU');
+ });
+});
+
+describe('buildConversionEvent', () => {
+ it('should use correct region from projectConfig in event context', () => {
+ const projectConfig = createProjectConfig(
+ testData.getTestProjectConfig(),
+ )
+
+ const conversionEvent = buildConversionEvent({
+ configObj: projectConfig,
+ userId: 'test_user',
+ eventKey: 'test_event',
+ eventTags: { revenue: 1000 },
+ clientEngine: 'node-sdk',
+ clientVersion: '1.0.0',
+ });
+
+ expect(conversionEvent.context.region).toBe('US');
+
+ projectConfig.region = 'EU';
+
+ const conversionEventEU = buildConversionEvent({
+ configObj: projectConfig,
+ userId: 'test_user',
+ eventKey: 'test_event',
+ eventTags: { revenue: 1000 },
+ clientEngine: 'node-sdk',
+ clientVersion: '1.0.0',
+ });
+
+ expect(conversionEventEU.context.region).toBe('EU');
+ });
+});
diff --git a/packages/optimizely-sdk/lib/core/event_builder/event_helpers.tests.js b/lib/event_processor/event_builder/user_event.tests.js
similarity index 95%
rename from packages/optimizely-sdk/lib/core/event_builder/event_helpers.tests.js
rename to lib/event_processor/event_builder/user_event.tests.js
index 3d722b975..30f271d0e 100644
--- a/packages/optimizely-sdk/lib/core/event_builder/event_helpers.tests.js
+++ b/lib/event_processor/event_builder/user_event.tests.js
@@ -1,5 +1,5 @@
/**
- * Copyright 2019-2020, Optimizely
+ * Copyright 2019-2020, 2024, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -17,15 +17,16 @@ import sinon from 'sinon';
import { assert } from 'chai';
import fns from '../../utils/fns';
-import * as projectConfig from '../project_config';
-import * as decision from '../decision';
-import { buildImpressionEvent, buildConversionEvent } from './event_helpers';
+import * as projectConfig from '../../project_config/project_config';
+import * as decision from '../../core/decision';
+import { buildImpressionEvent, buildConversionEvent } from './user_event';
-describe('lib/event_builder/event_helpers', function() {
+describe('user_event', function() {
var configObj;
beforeEach(function() {
configObj = {
+ region: 'US',
accountId: 'accountId',
projectId: 'projectId',
revision: '69',
@@ -106,6 +107,7 @@ describe('lib/event_builder/event_helpers', function() {
timestamp: 100,
uuid: 'uuid',
context: {
+ region: 'US',
accountId: 'accountId',
projectId: 'projectId',
revision: '69',
@@ -142,6 +144,7 @@ describe('lib/event_builder/event_helpers', function() {
flagKey: 'flagkey1',
ruleType: 'experiment',
enabled: true,
+ cmabUuid: undefined,
});
});
});
@@ -199,6 +202,7 @@ describe('lib/event_builder/event_helpers', function() {
timestamp: 100,
uuid: 'uuid',
context: {
+ region: 'US',
accountId: 'accountId',
projectId: 'projectId',
revision: '69',
@@ -235,6 +239,7 @@ describe('lib/event_builder/event_helpers', function() {
flagKey: 'flagkey1',
ruleType: 'experiment',
enabled: false,
+ cmabUuid: undefined,
});
});
});
@@ -268,6 +273,7 @@ describe('lib/event_builder/event_helpers', function() {
timestamp: 100,
uuid: 'uuid',
context: {
+ region: 'US',
accountId: 'accountId',
projectId: 'projectId',
revision: '69',
@@ -334,6 +340,7 @@ describe('lib/event_builder/event_helpers', function() {
timestamp: 100,
uuid: 'uuid',
context: {
+ region: 'US',
accountId: 'accountId',
projectId: 'projectId',
revision: '69',
diff --git a/packages/optimizely-sdk/lib/core/event_builder/event_helpers.ts b/lib/event_processor/event_builder/user_event.ts
similarity index 54%
rename from packages/optimizely-sdk/lib/core/event_builder/event_helpers.ts
rename to lib/event_processor/event_builder/user_event.ts
index e6f57fdbd..ae33d65da 100644
--- a/packages/optimizely-sdk/lib/core/event_builder/event_helpers.ts
+++ b/lib/event_processor/event_builder/user_event.ts
@@ -1,5 +1,5 @@
/**
- * Copyright 2019-2022, Optimizely
+ * Copyright 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.
@@ -13,106 +13,155 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { getLogger } from '../../modules/logging';
-
-import fns from '../../utils/fns';
+import { DecisionObj } from '../../core/decision_service';
+import * as decision from '../../core/decision';
+import { isAttributeValid } from '../../utils/attributes_validator';
import * as eventTagUtils from '../../utils/event_tag_utils';
-import * as attributesValidator from '../../utils/attributes_validator';
-import * as decision from '../decision';
-
-import { EventTags, UserAttributes } from '../../shared_types';
-import { DecisionObj } from '../decision_service';
+import fns from '../../utils/fns';
import {
getAttributeId,
getEventId,
getLayerId,
ProjectConfig,
-} from '../project_config';
+ Region,
+} from '../../project_config/project_config';
-const logger = getLogger('EVENT_BUILDER');
+import { EventTags, UserAttributes } from '../../shared_types';
+import { LoggerFacade } from '../../logging/logger';
+import { DECISION_SOURCES } from '../../common_exports';
-interface ImpressionConfig {
- decisionObj: DecisionObj;
- userId: string;
- flagKey: string;
- enabled: boolean;
- userAttributes?: UserAttributes;
- clientEngine: string;
- clientVersion: string;
- configObj: ProjectConfig;
+export type VisitorAttribute = {
+ entityId: string
+ key: string
+ value: string | number | boolean
}
-type VisitorAttribute = {
- entityId: string;
- key: string;
- value: string | number | boolean;
+type EventContext = {
+ region?: Region;
+ accountId: string;
+ projectId: string;
+ revision: string;
+ clientName: string;
+ clientVersion: string;
+ anonymizeIP: boolean;
+ botFiltering?: boolean;
}
-interface ImpressionEvent {
- type: 'impression';
+type EventType = 'impression' | 'conversion';
+
+
+export type BaseUserEvent = {
+ type: T;
timestamp: number;
uuid: string;
+ context: EventContext;
user: {
id: string;
attributes: VisitorAttribute[];
};
- context: EventContext;
+};
+
+export type ImpressionEvent = BaseUserEvent<'impression'> & {
layer: {
id: string | null;
- };
+ } | null;
+
experiment: {
id: string | null;
key: string;
} | null;
+
variation: {
id: string | null;
key: string;
} | null;
- ruleKey: string,
- flagKey: string,
- ruleType: string,
- enabled: boolean,
+ ruleKey: string;
+ flagKey: string;
+ ruleType: string;
+ enabled: boolean;
+ cmabUuid?: string;
+};
+
+export type ConversionEvent = BaseUserEvent<'conversion'> & {
+ event: {
+ id: string | null;
+ key: string;
+ }
+
+ revenue: number | null;
+ value: number | null;
+ tags?: EventTags;
}
-type EventContext = {
- accountId: string;
- projectId: string;
- revision: string;
- clientName: string;
- clientVersion: string;
- anonymizeIP: boolean;
- botFiltering: boolean | undefined;
+export type UserEvent = ImpressionEvent | ConversionEvent;
+
+export const areEventContextsEqual = (eventA: UserEvent, eventB: UserEvent): boolean => {
+ const contextA = eventA.context
+ const contextB = eventB.context
+
+ const regionA: Region = contextA.region || 'US';
+ const regionB: Region = contextB.region || 'US';
+
+ return (
+ regionA === regionB &&
+ contextA.accountId === contextB.accountId &&
+ contextA.projectId === contextB.projectId &&
+ contextA.clientName === contextB.clientName &&
+ contextA.clientVersion === contextB.clientVersion &&
+ contextA.revision === contextB.revision &&
+ contextA.anonymizeIP === contextB.anonymizeIP &&
+ contextA.botFiltering === contextB.botFiltering
+ )
}
-interface ConversionConfig {
- eventKey: string;
- eventTags?: EventTags;
+const buildBaseEvent = ({
+ configObj,
+ userId,
+ userAttributes,
+ clientEngine,
+ clientVersion,
+ type,
+}: {
+ configObj: ProjectConfig;
userId: string;
userAttributes?: UserAttributes;
clientEngine: string;
clientVersion: string;
- configObj: ProjectConfig;
-}
-
-interface ConversionEvent {
- type: 'conversion';
- timestamp: number;
- uuid: string;
- user: {
- id: string;
- attributes: VisitorAttribute[];
- };
- context: EventContext;
- event: {
- id: string | null;
- key: string;
+ type: T;
+}): BaseUserEvent => {
+ return {
+ type,
+ timestamp: fns.currentTimestamp(),
+ uuid: fns.uuid(),
+ context: {
+ region: configObj.region,
+ accountId: configObj.accountId,
+ projectId: configObj.projectId,
+ revision: configObj.revision,
+ clientName: clientEngine,
+ clientVersion: clientVersion,
+ anonymizeIP: configObj.anonymizeIP || false,
+ botFiltering: configObj.botFiltering,
+ },
+ user: {
+ id: userId,
+ attributes: buildVisitorAttributes(configObj, userAttributes),
+ },
};
- revenue: number | null;
- value: number | null;
- tags: EventTags | undefined;
+
}
+export type ImpressionConfig = {
+ decisionObj: DecisionObj;
+ userId: string;
+ flagKey: string;
+ enabled: boolean;
+ userAttributes?: UserAttributes;
+ clientEngine: string;
+ clientVersion: string;
+ configObj: ProjectConfig;
+}
/**
* Creates an ImpressionEvent object from decision data
@@ -135,28 +184,19 @@ export const buildImpressionEvent = function({
const experimentId = decision.getExperimentId(decisionObj);
const variationKey = decision.getVariationKey(decisionObj);
const variationId = decision.getVariationId(decisionObj);
-
- const layerId = experimentId !== null ? getLayerId(configObj, experimentId) : null;
+ const cmabUuid = decisionObj.cmabUuid;
+ const layerId =
+ experimentId !== null ? (ruleType === DECISION_SOURCES.HOLDOUT ? '' : getLayerId(configObj, experimentId)) : null;
return {
- type: 'impression',
- timestamp: fns.currentTimestamp(),
- uuid: fns.uuid(),
-
- user: {
- id: userId,
- attributes: buildVisitorAttributes(configObj, userAttributes),
- },
-
- context: {
- accountId: configObj.accountId,
- projectId: configObj.projectId,
- revision: configObj.revision,
- clientName: clientEngine,
- clientVersion: clientVersion,
- anonymizeIP: configObj.anonymizeIP || false,
- botFiltering: configObj.botFiltering,
- },
+ ...buildBaseEvent({
+ configObj,
+ userId,
+ userAttributes,
+ clientEngine,
+ clientVersion,
+ type: 'impression',
+ }),
layer: {
id: layerId,
@@ -176,9 +216,20 @@ export const buildImpressionEvent = function({
flagKey: flagKey,
ruleType: ruleType,
enabled: enabled,
+ cmabUuid,
};
};
+export type ConversionConfig = {
+ eventKey: string;
+ eventTags?: EventTags;
+ userId: string;
+ userAttributes?: UserAttributes;
+ clientEngine: string;
+ clientVersion: string;
+ configObj: ProjectConfig;
+}
+
/**
* Creates a ConversionEvent object from track
* @param {ConversionConfig} config
@@ -191,8 +242,8 @@ export const buildConversionEvent = function({
clientEngine,
clientVersion,
eventKey,
- eventTags,
-}: ConversionConfig): ConversionEvent {
+ eventTags,
+}: ConversionConfig, logger?: LoggerFacade): ConversionEvent {
const eventId = getEventId(configObj, eventKey);
@@ -200,24 +251,14 @@ export const buildConversionEvent = function({
const eventValue = eventTags ? eventTagUtils.getEventValue(eventTags, logger) : null;
return {
- type: 'conversion',
- timestamp: fns.currentTimestamp(),
- uuid: fns.uuid(),
-
- user: {
- id: userId,
- attributes: buildVisitorAttributes(configObj, userAttributes),
- },
-
- context: {
- accountId: configObj.accountId,
- projectId: configObj.projectId,
- revision: configObj.revision,
- clientName: clientEngine,
- clientVersion: clientVersion,
- anonymizeIP: configObj.anonymizeIP || false,
- botFiltering: configObj.botFiltering,
- },
+ ...buildBaseEvent({
+ configObj,
+ userId,
+ userAttributes,
+ clientEngine,
+ clientVersion,
+ type: 'conversion',
+ }),
event: {
id: eventId,
@@ -230,27 +271,36 @@ export const buildConversionEvent = function({
};
};
-function buildVisitorAttributes(
+
+const buildVisitorAttributes = (
configObj: ProjectConfig,
- attributes?: UserAttributes
-): VisitorAttribute[] {
- const builtAttributes: VisitorAttribute[] = [];
+ attributes?: UserAttributes,
+ logger?: LoggerFacade
+): VisitorAttribute[] => {
+ if (!attributes) {
+ return [];
+ }
+
// Omit attribute values that are not supported by the log endpoint.
- if (attributes) {
- Object.keys(attributes || {}).forEach(function(attributeKey) {
- const attributeValue = attributes[attributeKey];
- if (attributesValidator.isAttributeValid(attributeKey, attributeValue)) {
- const attributeId = getAttributeId(configObj, attributeKey, logger);
- if (attributeId) {
- builtAttributes.push({
- entityId: attributeId,
- key: attributeKey,
- value: attributes[attributeKey],
- });
- }
+ const builtAttributes: VisitorAttribute[] = [];
+ Object.keys(attributes).forEach(function(attributeKey) {
+ const attributeValue = attributes[attributeKey];
+
+ if (typeof attributeValue === 'object' || typeof attributeValue === 'undefined') {
+ return;
+ }
+
+ if (isAttributeValid(attributeKey, attributeValue)) {
+ const attributeId = getAttributeId(configObj, attributeKey, logger);
+ if (attributeId) {
+ builtAttributes.push({
+ entityId: attributeId,
+ key: attributeKey,
+ value: attributeValue,
+ });
}
- });
- }
+ }
+ });
return builtAttributes;
}
diff --git a/lib/event_processor/event_dispatcher/default_dispatcher.browser.spec.ts b/lib/event_processor/event_dispatcher/default_dispatcher.browser.spec.ts
new file mode 100644
index 000000000..82314cbc7
--- /dev/null
+++ b/lib/event_processor/event_dispatcher/default_dispatcher.browser.spec.ts
@@ -0,0 +1,49 @@
+/**
+ * Copyright 2024, 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.
+ */
+
+import { vi, expect, it, describe, afterAll } from 'vitest';
+
+vi.mock('./default_dispatcher', () => {
+ const DefaultEventDispatcher = vi.fn();
+ return { DefaultEventDispatcher };
+});
+
+vi.mock('../../utils/http_request_handler/request_handler.browser', () => {
+ const BrowserRequestHandler = vi.fn();
+ return { BrowserRequestHandler };
+});
+
+import { DefaultEventDispatcher } from './default_dispatcher';
+import { BrowserRequestHandler } from '../../utils/http_request_handler/request_handler.browser';
+import eventDispatcher from './default_dispatcher.browser';
+
+describe('eventDispatcher', () => {
+ afterAll(() => {
+ MockDefaultEventDispatcher.mockReset();
+ MockBrowserRequestHandler.mockReset();
+ });
+ const MockBrowserRequestHandler = vi.mocked(BrowserRequestHandler);
+ const MockDefaultEventDispatcher = vi.mocked(DefaultEventDispatcher);
+
+ it('creates and returns the instance by calling DefaultEventDispatcher', () => {
+ expect(Object.is(eventDispatcher, MockDefaultEventDispatcher.mock.instances[0])).toBe(true);
+ });
+
+ it('uses a BrowserRequestHandler', () => {
+ expect(Object.is(eventDispatcher, MockDefaultEventDispatcher.mock.instances[0])).toBe(true);
+ expect(Object.is(MockDefaultEventDispatcher.mock.calls[0][0], MockBrowserRequestHandler.mock.instances[0])).toBe(true);
+ });
+});
diff --git a/packages/optimizely-sdk/lib/plugins/error_handler/index.tests.js b/lib/event_processor/event_dispatcher/default_dispatcher.browser.ts
similarity index 60%
rename from packages/optimizely-sdk/lib/plugins/error_handler/index.tests.js
rename to lib/event_processor/event_dispatcher/default_dispatcher.browser.ts
index b3a632b92..d38d266aa 100644
--- a/packages/optimizely-sdk/lib/plugins/error_handler/index.tests.js
+++ b/lib/event_processor/event_dispatcher/default_dispatcher.browser.ts
@@ -1,5 +1,5 @@
/**
- * Copyright 2016, 2020 Optimizely
+ * Copyright 2024, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,16 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { assert } from 'chai';
-import { handleError } from './';
+import { BrowserRequestHandler } from "../../utils/http_request_handler/request_handler.browser";
+import { EventDispatcher } from './event_dispatcher';
+import { DefaultEventDispatcher } from './default_dispatcher';
-describe('lib/plugins/error_handler', function() {
- describe('APIs', function() {
- describe('handleError', function() {
- it('should just be a no-op function', function() {
- assert.isFunction(handleError);
- });
- });
- });
-});
+const eventDispatcher: EventDispatcher = new DefaultEventDispatcher(new BrowserRequestHandler());
+
+export default eventDispatcher;
diff --git a/lib/event_processor/event_dispatcher/default_dispatcher.node.spec.ts b/lib/event_processor/event_dispatcher/default_dispatcher.node.spec.ts
new file mode 100644
index 000000000..084fcce67
--- /dev/null
+++ b/lib/event_processor/event_dispatcher/default_dispatcher.node.spec.ts
@@ -0,0 +1,49 @@
+/**
+ * Copyright 2024, 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.
+ */
+import { vi, expect, it, describe, afterAll } from 'vitest';
+
+vi.mock('./default_dispatcher', () => {
+ const DefaultEventDispatcher = vi.fn();
+ return { DefaultEventDispatcher };
+});
+
+vi.mock('../../utils/http_request_handler/request_handler.node', () => {
+ const NodeRequestHandler = vi.fn();
+ return { NodeRequestHandler };
+});
+
+import { DefaultEventDispatcher } from './default_dispatcher';
+import { NodeRequestHandler } from '../../utils/http_request_handler/request_handler.node';
+import eventDispatcher from './default_dispatcher.node';
+
+describe('eventDispatcher', () => {
+ const MockNodeRequestHandler = vi.mocked(NodeRequestHandler);
+ const MockDefaultEventDispatcher = vi.mocked(DefaultEventDispatcher);
+
+ afterAll(() => {
+ MockDefaultEventDispatcher.mockReset();
+ MockNodeRequestHandler.mockReset();
+ })
+
+ it('creates and returns the instance by calling DefaultEventDispatcher', () => {
+ expect(Object.is(eventDispatcher, MockDefaultEventDispatcher.mock.instances[0])).toBe(true);
+ });
+
+ it('uses a NodeRequestHandler', () => {
+ expect(Object.is(eventDispatcher, MockDefaultEventDispatcher.mock.instances[0])).toBe(true);
+ expect(Object.is(MockDefaultEventDispatcher.mock.calls[0][0], MockNodeRequestHandler.mock.instances[0])).toBe(true);
+ });
+});
diff --git a/packages/optimizely-sdk/lib/core/odp/lru_cache/index.ts b/lib/event_processor/event_dispatcher/default_dispatcher.node.ts
similarity index 60%
rename from packages/optimizely-sdk/lib/core/odp/lru_cache/index.ts
rename to lib/event_processor/event_dispatcher/default_dispatcher.node.ts
index cb21e5693..65dc115af 100644
--- a/packages/optimizely-sdk/lib/core/odp/lru_cache/index.ts
+++ b/lib/event_processor/event_dispatcher/default_dispatcher.node.ts
@@ -1,5 +1,5 @@
/**
- * Copyright 2022, Optimizely
+ * Copyright 2024 Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,11 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { EventDispatcher } from './event_dispatcher';
+import { NodeRequestHandler } from '../../utils/http_request_handler/request_handler.node';
+import { DefaultEventDispatcher } from './default_dispatcher';
-import { LRUCache, ClientLRUCache, ServerLRUCache } from "./LRUCache";
+const eventDispatcher: EventDispatcher = new DefaultEventDispatcher(new NodeRequestHandler());
-export {
- LRUCache,
- ClientLRUCache,
- ServerLRUCache,
-}
\ No newline at end of file
+export default eventDispatcher;
diff --git a/lib/event_processor/event_dispatcher/default_dispatcher.spec.ts b/lib/event_processor/event_dispatcher/default_dispatcher.spec.ts
new file mode 100644
index 000000000..45a198788
--- /dev/null
+++ b/lib/event_processor/event_dispatcher/default_dispatcher.spec.ts
@@ -0,0 +1,116 @@
+/**
+ * Copyright 2024, 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.
+ */
+import { expect, vi, describe, it } from 'vitest';
+import { DefaultEventDispatcher } from './default_dispatcher';
+import { EventBatch } from '../event_builder/log_event';
+
+const getEvent = (): EventBatch => {
+ return {
+ account_id: 'string',
+ project_id: 'string',
+ revision: 'string',
+ client_name: 'string',
+ client_version: 'string',
+ anonymize_ip: true,
+ enrich_decisions: false,
+ visitors: [],
+ };
+};
+
+describe('DefaultEventDispatcher', () => {
+ it('reject the response promise if the eventObj.httpVerb is not POST', async () => {
+ const eventObj = {
+ url: 'https://cdn.com/event',
+ params: getEvent(),
+ httpVerb: 'GET' as const,
+ };
+
+ const requestHnadler = {
+ makeRequest: vi.fn().mockReturnValue({
+ abort: vi.fn(),
+ responsePromise: Promise.resolve({ statusCode: 203 }),
+ }),
+ };
+
+ const dispatcher = new DefaultEventDispatcher(requestHnadler);
+ await expect(dispatcher.dispatchEvent(eventObj)).rejects.toThrow();
+ });
+
+ it('sends correct headers and data to the requestHandler', async () => {
+ const eventObj = {
+ url: 'https://cdn.com/event',
+ params: getEvent(),
+ httpVerb: 'POST' as const,
+ };
+
+ const requestHnadler = {
+ makeRequest: vi.fn().mockReturnValue({
+ abort: vi.fn(),
+ responsePromise: Promise.resolve({ statusCode: 203 }),
+ }),
+ };
+
+ const dispatcher = new DefaultEventDispatcher(requestHnadler);
+ await dispatcher.dispatchEvent(eventObj);
+
+ expect(requestHnadler.makeRequest).toHaveBeenCalledWith(
+ eventObj.url,
+ {
+ 'content-type': 'application/json'
+ },
+ 'POST',
+ JSON.stringify(eventObj.params)
+ );
+ });
+
+ it('returns a promise that resolves with correct value if the response of the requestHandler resolves', async () => {
+ const eventObj = {
+ url: 'https://cdn.com/event',
+ params: getEvent(),
+ httpVerb: 'POST' as const,
+ };
+
+ const requestHnadler = {
+ makeRequest: vi.fn().mockReturnValue({
+ abort: vi.fn(),
+ responsePromise: Promise.resolve({ statusCode: 203 }),
+ }),
+ };
+
+ const dispatcher = new DefaultEventDispatcher(requestHnadler);
+ const response = await dispatcher.dispatchEvent(eventObj);
+
+ expect(response.statusCode).toEqual(203);
+ });
+
+ it('returns a promise that rejects if the response of the requestHandler rejects', async () => {
+ const eventObj = {
+ url: 'https://cdn.com/event',
+ params: getEvent(),
+ httpVerb: 'POST' as const,
+ };
+
+ const requestHnadler = {
+ makeRequest: vi.fn().mockReturnValue({
+ abort: vi.fn(),
+ responsePromise: Promise.reject(new Error('error')),
+ }),
+ };
+
+ const dispatcher = new DefaultEventDispatcher(requestHnadler);
+ await expect(dispatcher.dispatchEvent(eventObj)).rejects.toThrow();
+ });
+});
diff --git a/lib/event_processor/event_dispatcher/default_dispatcher.ts b/lib/event_processor/event_dispatcher/default_dispatcher.ts
new file mode 100644
index 000000000..b786ffda2
--- /dev/null
+++ b/lib/event_processor/event_dispatcher/default_dispatcher.ts
@@ -0,0 +1,45 @@
+/**
+ * Copyright 2024, 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.
+ */
+import { OptimizelyError } from '../../error/optimizly_error';
+import { ONLY_POST_REQUESTS_ARE_SUPPORTED } from 'error_message';
+import { RequestHandler } from '../../utils/http_request_handler/http';
+import { EventDispatcher, EventDispatcherResponse, LogEvent } from './event_dispatcher';
+
+export class DefaultEventDispatcher implements EventDispatcher {
+ private requestHandler: RequestHandler;
+
+ constructor(requestHandler: RequestHandler) {
+ this.requestHandler = requestHandler;
+ }
+
+ async dispatchEvent(
+ eventObj: LogEvent
+ ): Promise {
+ // Non-POST requests not supported
+ if (eventObj.httpVerb !== 'POST') {
+ return Promise.reject(new OptimizelyError(ONLY_POST_REQUESTS_ARE_SUPPORTED));
+ }
+
+ const dataString = JSON.stringify(eventObj.params);
+
+ const headers = {
+ 'content-type': 'application/json',
+ };
+
+ const abortableRequest = this.requestHandler.makeRequest(eventObj.url, headers, 'POST', dataString);
+ return abortableRequest.responsePromise;
+ }
+}
diff --git a/packages/optimizely-sdk/lib/modules/event_processor/eventDispatcher.ts b/lib/event_processor/event_dispatcher/event_dispatcher.ts
similarity index 69%
rename from packages/optimizely-sdk/lib/modules/event_processor/eventDispatcher.ts
rename to lib/event_processor/event_dispatcher/event_dispatcher.ts
index 15d261cf2..4dfda8f30 100644
--- a/packages/optimizely-sdk/lib/modules/event_processor/eventDispatcher.ts
+++ b/lib/event_processor/event_dispatcher/event_dispatcher.ts
@@ -1,5 +1,5 @@
/**
- * Copyright 2022, Optimizely
+ * Copyright 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.
@@ -13,20 +13,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { EventV1 } from "./v1/buildEventV1";
+import { EventBatch } from "../event_builder/log_event";
export type EventDispatcherResponse = {
- statusCode: number
+ statusCode?: number
}
-export type EventDispatcherCallback = (response: EventDispatcherResponse) => void
-
export interface EventDispatcher {
- dispatchEvent(event: EventV1Request, callback: EventDispatcherCallback): void
+ dispatchEvent(event: LogEvent): Promise
}
-export interface EventV1Request {
+export interface LogEvent {
url: string
httpVerb: 'POST' | 'PUT' | 'GET' | 'PATCH'
- params: EventV1,
+ params: EventBatch,
}
diff --git a/lib/event_processor/event_dispatcher/event_dispatcher_factory.ts b/lib/event_processor/event_dispatcher/event_dispatcher_factory.ts
new file mode 100644
index 000000000..383ad8380
--- /dev/null
+++ b/lib/event_processor/event_dispatcher/event_dispatcher_factory.ts
@@ -0,0 +1,26 @@
+/**
+ * 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.
+ */
+
+import { RequestHandler } from '../../utils/http_request_handler/http';
+import { DefaultEventDispatcher } from './default_dispatcher';
+import { EventDispatcher } from './event_dispatcher';
+
+import { validateRequestHandler } from '../../utils/http_request_handler/request_handler_validator';
+
+export const createEventDispatcher = (requestHander: RequestHandler): EventDispatcher => {
+ validateRequestHandler(requestHander);
+ return new DefaultEventDispatcher(requestHander);
+}
diff --git a/lib/event_processor/event_dispatcher/send_beacon_dispatcher.browser.spec.ts b/lib/event_processor/event_dispatcher/send_beacon_dispatcher.browser.spec.ts
new file mode 100644
index 000000000..06bd5bd1f
--- /dev/null
+++ b/lib/event_processor/event_dispatcher/send_beacon_dispatcher.browser.spec.ts
@@ -0,0 +1,81 @@
+/**
+ * Copyright 2023-2024, 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.
+ */
+import { describe, beforeEach, it, expect, vi, MockInstance } from 'vitest';
+
+import sendBeaconDispatcher, { Event } from './send_beacon_dispatcher.browser';
+
+describe('dispatchEvent', function() {
+ let sendBeaconSpy: MockInstance;
+
+ beforeEach(() => {
+ sendBeaconSpy = vi.fn();
+ navigator.sendBeacon = sendBeaconSpy as any;
+ });
+
+ it('should call sendBeacon with correct url, data and type', async () => {
+ const eventParams = { testParam: 'testParamValue' };
+ const eventObj: Event = {
+ url: 'https://cdn.com/event',
+ httpVerb: 'POST',
+ params: eventParams,
+ };
+
+ sendBeaconSpy.mockReturnValue(true);
+
+ sendBeaconDispatcher.dispatchEvent(eventObj)
+
+ const [url, data] = sendBeaconSpy.mock.calls[0];
+ const blob = data as Blob;
+
+ const reader = new FileReader();
+ reader.readAsBinaryString(blob);
+
+ const sentParams = await new Promise((resolve) => {
+ reader.onload = () => {
+ resolve(reader.result);
+ };
+ });
+
+
+ expect(url).toEqual(eventObj.url);
+ expect(blob.type).toEqual('application/json');
+ expect(sentParams).toEqual(JSON.stringify(eventObj.params));
+ });
+
+ it('should resolve the response on sendBeacon success', async () => {
+ const eventParams = { testParam: 'testParamValue' };
+ const eventObj: Event = {
+ url: 'https://cdn.com/event',
+ httpVerb: 'POST',
+ params: eventParams,
+ };
+
+ sendBeaconSpy.mockReturnValue(true);
+ await expect(sendBeaconDispatcher.dispatchEvent(eventObj)).resolves.not.toThrow();
+ });
+
+ it('should reject the response on sendBeacon success', async () => {
+ const eventParams = { testParam: 'testParamValue' };
+ const eventObj: Event = {
+ url: 'https://cdn.com/event',
+ httpVerb: 'POST',
+ params: eventParams,
+ };
+
+ sendBeaconSpy.mockReturnValue(false);
+ await expect(sendBeaconDispatcher.dispatchEvent(eventObj)).rejects.toThrow();
+ });
+});
diff --git a/lib/event_processor/event_dispatcher/send_beacon_dispatcher.browser.ts b/lib/event_processor/event_dispatcher/send_beacon_dispatcher.browser.ts
new file mode 100644
index 000000000..006adedd6
--- /dev/null
+++ b/lib/event_processor/event_dispatcher/send_beacon_dispatcher.browser.ts
@@ -0,0 +1,53 @@
+/**
+ * Copyright 2023-2024, 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.
+ */
+
+import { OptimizelyError } from '../../error/optimizly_error';
+import { SEND_BEACON_FAILED } from 'error_message';
+import { EventDispatcher, EventDispatcherResponse } from './event_dispatcher';
+
+export type Event = {
+ url: string;
+ httpVerb: 'POST';
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ params: any;
+}
+
+/**
+ * Sample event dispatcher implementation for tracking impression and conversions
+ * Users of the SDK can provide their own implementation
+ * @param {Event} eventObj
+ * @param {Function} callback
+ */
+export const dispatchEvent = function(
+ eventObj: Event,
+): Promise {
+ const { params, url } = eventObj;
+ const blob = new Blob([JSON.stringify(params)], {
+ type: "application/json",
+ });
+
+ const success = navigator.sendBeacon(url, blob);
+ if(success) {
+ return Promise.resolve({});
+ }
+ return Promise.reject(new OptimizelyError(SEND_BEACON_FAILED));
+}
+
+const eventDispatcher : EventDispatcher = {
+ dispatchEvent,
+}
+
+export default eventDispatcher;
diff --git a/lib/event_processor/event_processor.ts b/lib/event_processor/event_processor.ts
new file mode 100644
index 000000000..585c71f68
--- /dev/null
+++ b/lib/event_processor/event_processor.ts
@@ -0,0 +1,32 @@
+/**
+ * Copyright 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.
+ * 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.
+ */
+import { ConversionEvent, ImpressionEvent } from './event_builder/user_event'
+import { LogEvent } from './event_dispatcher/event_dispatcher'
+import { Service } from '../service'
+import { Consumer, Fn } from '../utils/type';
+import { LoggerFacade } from '../logging/logger';
+
+export const DEFAULT_FLUSH_INTERVAL = 30000 // Unit is ms - default flush interval is 30s
+export const DEFAULT_BATCH_SIZE = 10
+
+export type ProcessableEvent = ConversionEvent | ImpressionEvent
+
+export interface EventProcessor extends Service {
+ process(event: ProcessableEvent): Promise;
+ onDispatch(handler: Consumer): Fn;
+ setLogger(logger: LoggerFacade): void;
+ flushImmediately(): Promise;
+}
diff --git a/lib/event_processor/event_processor_factory.browser.spec.ts b/lib/event_processor/event_processor_factory.browser.spec.ts
new file mode 100644
index 000000000..a5d2a6af3
--- /dev/null
+++ b/lib/event_processor/event_processor_factory.browser.spec.ts
@@ -0,0 +1,179 @@
+/**
+ * Copyright 2024-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.
+ */
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+
+vi.mock('./default_dispatcher.browser', () => {
+ return { default: {} };
+});
+
+vi.mock('./event_processor_factory', async (importOriginal) => {
+ const getBatchEventProcessor = vi.fn().mockImplementation(() => {
+ return {};
+ });
+ const getOpaqueBatchEventProcessor = vi.fn().mockImplementation(() => {
+ return {};
+ });
+ const getForwardingEventProcessor = vi.fn().mockImplementation(() => {
+ return {};
+ });
+ const original: any = await importOriginal();
+ return { ...original, getBatchEventProcessor, getOpaqueBatchEventProcessor, getForwardingEventProcessor };
+});
+
+vi.mock('../utils/cache/local_storage_cache.browser', () => {
+ return { LocalStorageCache: vi.fn() };
+});
+
+vi.mock('../utils/cache/store', () => {
+ return { SyncPrefixStore: vi.fn() };
+});
+
+
+import defaultEventDispatcher from './event_dispatcher/default_dispatcher.browser';
+import { LocalStorageCache } from '../utils/cache/local_storage_cache.browser';
+import { SyncPrefixStore } from '../utils/cache/store';
+import { createForwardingEventProcessor, createBatchEventProcessor } from './event_processor_factory.browser';
+import { EVENT_STORE_PREFIX, extractEventProcessor, getForwardingEventProcessor, FAILED_EVENT_RETRY_INTERVAL } from './event_processor_factory';
+import sendBeaconEventDispatcher from './event_dispatcher/send_beacon_dispatcher.browser';
+import browserDefaultEventDispatcher from './event_dispatcher/default_dispatcher.browser';
+import { getOpaqueBatchEventProcessor } from './event_processor_factory';
+
+describe('createForwardingEventProcessor', () => {
+ const mockGetForwardingEventProcessor = vi.mocked(getForwardingEventProcessor);
+
+ beforeEach(() => {
+ mockGetForwardingEventProcessor.mockClear();
+ });
+
+ it('returns forwarding event processor by calling getForwardingEventProcessor with the provided dispatcher', () => {
+ const eventDispatcher = {
+ dispatchEvent: vi.fn(),
+ };
+
+ const processor = extractEventProcessor(createForwardingEventProcessor(eventDispatcher));
+
+ expect(Object.is(processor, mockGetForwardingEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetForwardingEventProcessor).toHaveBeenNthCalledWith(1, eventDispatcher);
+ });
+
+ it('uses the browser default event dispatcher if none is provided', () => {
+ const processor = extractEventProcessor(createForwardingEventProcessor());
+
+ expect(Object.is(processor, mockGetForwardingEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetForwardingEventProcessor).toHaveBeenNthCalledWith(1, browserDefaultEventDispatcher);
+ });
+});
+
+describe('createBatchEventProcessor', () => {
+ const mockGetOpaqueBatchEventProcessor = vi.mocked(getOpaqueBatchEventProcessor);
+ const MockLocalStorageCache = vi.mocked(LocalStorageCache);
+ const MockSyncPrefixStore = vi.mocked(SyncPrefixStore);
+
+ beforeEach(() => {
+ mockGetOpaqueBatchEventProcessor.mockClear();
+ MockLocalStorageCache.mockClear();
+ MockSyncPrefixStore.mockClear();
+ });
+
+ it('uses LocalStorageCache and SyncPrefixStore to create eventStore', () => {
+ const processor = createBatchEventProcessor({});
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ const eventStore = mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventStore;
+ expect(Object.is(eventStore, MockSyncPrefixStore.mock.results[0].value)).toBe(true);
+
+ const [cache, prefix, transformGet, transformSet] = MockSyncPrefixStore.mock.calls[0];
+ expect(Object.is(cache, MockLocalStorageCache.mock.results[0].value)).toBe(true);
+ expect(prefix).toBe(EVENT_STORE_PREFIX);
+
+ // transformGet and transformSet should be identity functions
+ expect(transformGet('value')).toBe('value');
+ expect(transformSet('value')).toBe('value');
+ });
+
+ it('uses the provided eventDispatcher', () => {
+ const eventDispatcher = {
+ dispatchEvent: vi.fn(),
+ };
+
+ const processor = createBatchEventProcessor({ eventDispatcher });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventDispatcher).toBe(eventDispatcher);
+ });
+
+ it('uses the default browser event dispatcher if none is provided', () => {
+ const processor = createBatchEventProcessor({ });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventDispatcher).toBe(defaultEventDispatcher);
+ });
+
+ it('uses the provided closingEventDispatcher', () => {
+ const closingEventDispatcher = {
+ dispatchEvent: vi.fn(),
+ };
+
+ const processor = createBatchEventProcessor({ closingEventDispatcher });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].closingEventDispatcher).toBe(closingEventDispatcher);
+ });
+
+ it('does not use any closingEventDispatcher if eventDispatcher is provided but closingEventDispatcher is not', () => {
+ const eventDispatcher = {
+ dispatchEvent: vi.fn(),
+ };
+
+ const processor = createBatchEventProcessor({ eventDispatcher });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].closingEventDispatcher).toBe(undefined);
+ });
+
+ it('uses the default sendBeacon event dispatcher if neither eventDispatcher nor closingEventDispatcher is provided', () => {
+ const processor = createBatchEventProcessor({ });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].closingEventDispatcher).toBe(sendBeaconEventDispatcher);
+ });
+
+ it('uses the provided flushInterval', () => {
+ const processor1 = createBatchEventProcessor({ flushInterval: 2000 });
+ expect(Object.is(processor1, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].flushInterval).toBe(2000);
+
+ const processor2 = createBatchEventProcessor({ });
+ expect(Object.is(processor2, mockGetOpaqueBatchEventProcessor.mock.results[1].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[1][0].flushInterval).toBe(undefined);
+ });
+
+ it('uses the provided batchSize', () => {
+ const processor1 = createBatchEventProcessor({ batchSize: 20 });
+ expect(Object.is(processor1, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].batchSize).toBe(20);
+
+ const processor2 = createBatchEventProcessor({ });
+ expect(Object.is(processor2, mockGetOpaqueBatchEventProcessor.mock.results[1].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[1][0].batchSize).toBe(undefined);
+ });
+
+ it('uses maxRetries value of 5', () => {
+ const processor = createBatchEventProcessor({ });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].retryOptions?.maxRetries).toBe(5);
+ });
+
+ it('uses the default failedEventRetryInterval', () => {
+ const processor = createBatchEventProcessor({ });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].failedEventRetryInterval).toBe(FAILED_EVENT_RETRY_INTERVAL);
+ });
+});
diff --git a/lib/event_processor/event_processor_factory.browser.ts b/lib/event_processor/event_processor_factory.browser.ts
new file mode 100644
index 000000000..e73b8bf24
--- /dev/null
+++ b/lib/event_processor/event_processor_factory.browser.ts
@@ -0,0 +1,67 @@
+/**
+ * Copyright 2024-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.
+ */
+import { EventDispatcher } from './event_dispatcher/event_dispatcher';
+import { EventProcessor } from './event_processor';
+import { EventWithId } from './batch_event_processor';
+import {
+ getOpaqueBatchEventProcessor,
+ BatchEventProcessorOptions,
+ OpaqueEventProcessor,
+ wrapEventProcessor,
+ getForwardingEventProcessor,
+} from './event_processor_factory';
+import defaultEventDispatcher from './event_dispatcher/default_dispatcher.browser';
+import sendBeaconEventDispatcher from './event_dispatcher/send_beacon_dispatcher.browser';
+import { LocalStorageCache } from '../utils/cache/local_storage_cache.browser';
+import { SyncPrefixStore } from '../utils/cache/store';
+import { EVENT_STORE_PREFIX, FAILED_EVENT_RETRY_INTERVAL } from './event_processor_factory';
+
+export const DEFAULT_EVENT_BATCH_SIZE = 10;
+export const DEFAULT_EVENT_FLUSH_INTERVAL = 1_000;
+
+export const createForwardingEventProcessor = (
+ eventDispatcher: EventDispatcher = defaultEventDispatcher,
+): OpaqueEventProcessor => {
+ return wrapEventProcessor(getForwardingEventProcessor(eventDispatcher));
+};
+
+const identity = (v: T): T => v;
+
+export const createBatchEventProcessor = (
+ options: BatchEventProcessorOptions = {}
+): OpaqueEventProcessor => {
+ const localStorageCache = new LocalStorageCache();
+ const eventStore = new SyncPrefixStore(
+ localStorageCache, EVENT_STORE_PREFIX,
+ identity,
+ identity,
+ );
+
+ return getOpaqueBatchEventProcessor({
+ eventDispatcher: options.eventDispatcher || defaultEventDispatcher,
+ closingEventDispatcher: options.closingEventDispatcher ||
+ (options.eventDispatcher ? undefined : sendBeaconEventDispatcher),
+ flushInterval: options.flushInterval,
+ batchSize: options.batchSize,
+ defaultFlushInterval: DEFAULT_EVENT_FLUSH_INTERVAL,
+ defaultBatchSize: DEFAULT_EVENT_BATCH_SIZE,
+ retryOptions: {
+ maxRetries: 5,
+ },
+ failedEventRetryInterval: FAILED_EVENT_RETRY_INTERVAL,
+ eventStore,
+ });
+};
diff --git a/lib/event_processor/event_processor_factory.node.spec.ts b/lib/event_processor/event_processor_factory.node.spec.ts
new file mode 100644
index 000000000..22b943f19
--- /dev/null
+++ b/lib/event_processor/event_processor_factory.node.spec.ts
@@ -0,0 +1,202 @@
+/**
+ * Copyright 2024-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.
+ */
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+
+vi.mock('./default_dispatcher.node', () => {
+ return { default: {} };
+});
+
+vi.mock('./event_processor_factory', async (importOriginal) => {
+ const getBatchEventProcessor = vi.fn().mockImplementation(() => {
+ return {};
+ });
+ const getOpaqueBatchEventProcessor = vi.fn().mockImplementation(() => {
+ return {};
+ });
+ const getForwardingEventProcessor = vi.fn().mockReturnValue({});
+ const original: any = await importOriginal();
+ return { ...original, getBatchEventProcessor, getOpaqueBatchEventProcessor, getForwardingEventProcessor };
+});
+
+vi.mock('../utils/cache/async_storage_cache.react_native', () => {
+ return { AsyncStorageCache: vi.fn() };
+});
+
+vi.mock('../utils/cache/store', () => {
+ return { SyncPrefixStore: vi.fn(), AsyncPrefixStore: vi.fn() };
+});
+
+import { createBatchEventProcessor, createForwardingEventProcessor } from './event_processor_factory.node';
+import nodeDefaultEventDispatcher from './event_dispatcher/default_dispatcher.node';
+import { EVENT_STORE_PREFIX, extractEventProcessor, getForwardingEventProcessor, FAILED_EVENT_RETRY_INTERVAL } from './event_processor_factory';
+import { getOpaqueBatchEventProcessor } from './event_processor_factory';
+import { AsyncStore, AsyncPrefixStore, SyncStore, SyncPrefixStore } from '../utils/cache/store';
+import { AsyncStorageCache } from '../utils/cache/async_storage_cache.react_native';
+
+describe('createForwardingEventProcessor', () => {
+ const mockGetForwardingEventProcessor = vi.mocked(getForwardingEventProcessor);
+
+ beforeEach(() => {
+ mockGetForwardingEventProcessor.mockClear();
+ });
+
+ it('returns forwarding event processor by calling getForwardingEventProcessor with the provided dispatcher', () => {
+ const eventDispatcher = {
+ dispatchEvent: vi.fn(),
+ };
+
+ const processor = extractEventProcessor(createForwardingEventProcessor(eventDispatcher));
+
+ expect(Object.is(processor, mockGetForwardingEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetForwardingEventProcessor).toHaveBeenNthCalledWith(1, eventDispatcher);
+ });
+
+ it('uses the node default event dispatcher if none is provided', () => {
+ const processor = extractEventProcessor(createForwardingEventProcessor());
+
+ expect(Object.is(processor, mockGetForwardingEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetForwardingEventProcessor).toHaveBeenNthCalledWith(1, nodeDefaultEventDispatcher);
+ });
+});
+
+describe('createBatchEventProcessor', () => {
+ const mockGetOpaqueBatchEventProcessor = vi.mocked(getOpaqueBatchEventProcessor);
+ const MockAsyncStorageCache = vi.mocked(AsyncStorageCache);
+ const MockSyncPrefixStore = vi.mocked(SyncPrefixStore);
+ const MockAsyncPrefixStore = vi.mocked(AsyncPrefixStore);
+
+ beforeEach(() => {
+ mockGetOpaqueBatchEventProcessor.mockClear();
+ MockAsyncStorageCache.mockClear();
+ MockSyncPrefixStore.mockClear();
+ MockAsyncPrefixStore.mockClear();
+ });
+
+ it('uses no default event store if no eventStore is provided', () => {
+ const processor = createBatchEventProcessor({});
+
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ const eventStore = mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventStore;
+ expect(eventStore).toBe(undefined);
+ });
+
+ it('wraps the provided eventStore in a SyncPrefixStore if a SyncCache is provided as eventStore', () => {
+ const eventStore = {
+ operation: 'sync',
+ } as SyncStore;
+
+ const processor = createBatchEventProcessor({ eventStore });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventStore).toBe(MockSyncPrefixStore.mock.results[0].value);
+ const [cache, prefix, transformGet, transformSet] = MockSyncPrefixStore.mock.calls[0];
+
+ expect(cache).toBe(eventStore);
+ expect(prefix).toBe(EVENT_STORE_PREFIX);
+
+ // transformGet and transformSet should be JSON.parse and JSON.stringify
+ expect(transformGet('{"value": 1}')).toEqual({ value: 1 });
+ expect(transformSet({ value: 1 })).toBe('{"value":1}');
+ });
+
+ it('wraps the provided eventStore in a AsyncPrefixStore if a AsyncCache is provided as eventStore', () => {
+ const eventStore = {
+ operation: 'async',
+ } as AsyncStore;
+
+ const processor = createBatchEventProcessor({ eventStore });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventStore).toBe(MockAsyncPrefixStore.mock.results[0].value);
+ const [cache, prefix, transformGet, transformSet] = MockAsyncPrefixStore.mock.calls[0];
+
+ expect(cache).toBe(eventStore);
+ expect(prefix).toBe(EVENT_STORE_PREFIX);
+
+ // transformGet and transformSet should be JSON.parse and JSON.stringify
+ expect(transformGet('{"value": 1}')).toEqual({ value: 1 });
+ expect(transformSet({ value: 1 })).toBe('{"value":1}');
+ });
+
+
+ it('uses the provided eventDispatcher', () => {
+ const eventDispatcher = {
+ dispatchEvent: vi.fn(),
+ };
+
+ const processor = createBatchEventProcessor({ eventDispatcher });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventDispatcher).toBe(eventDispatcher);
+ });
+
+ it('uses the default node event dispatcher if none is provided', () => {
+ const processor = createBatchEventProcessor({ });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventDispatcher).toBe(nodeDefaultEventDispatcher);
+ });
+
+ it('uses the provided closingEventDispatcher', () => {
+ const closingEventDispatcher = {
+ dispatchEvent: vi.fn(),
+ };
+
+ const processor = createBatchEventProcessor({ closingEventDispatcher });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].closingEventDispatcher).toBe(closingEventDispatcher);
+
+ const processor2 = createBatchEventProcessor({ });
+ expect(Object.is(processor2, mockGetOpaqueBatchEventProcessor.mock.results[1].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[1][0].closingEventDispatcher).toBe(undefined);
+ });
+
+ it('uses the provided flushInterval', () => {
+ const processor1 = createBatchEventProcessor({ flushInterval: 2000 });
+ expect(Object.is(processor1, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].flushInterval).toBe(2000);
+
+ const processor2 = createBatchEventProcessor({ });
+ expect(Object.is(processor2, mockGetOpaqueBatchEventProcessor.mock.results[1].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[1][0].flushInterval).toBe(undefined);
+ });
+
+ it('uses the provided batchSize', () => {
+ const processor1 = createBatchEventProcessor({ batchSize: 20 });
+ expect(Object.is(processor1, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].batchSize).toBe(20);
+
+ const processor2 = createBatchEventProcessor({ });
+ expect(Object.is(processor2, mockGetOpaqueBatchEventProcessor.mock.results[1].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[1][0].batchSize).toBe(undefined);
+ });
+
+ it('uses maxRetries value of 5', () => {
+ const processor = createBatchEventProcessor({ });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].retryOptions?.maxRetries).toBe(5);
+ });
+
+ it('uses no failed event retry if an eventStore is not provided', () => {
+ const processor = createBatchEventProcessor({ });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].failedEventRetryInterval).toBe(undefined);
+ });
+
+ it('uses the default failedEventRetryInterval if an eventStore is provided', () => {
+ const processor = createBatchEventProcessor({ eventStore: {} as any });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].failedEventRetryInterval).toBe(FAILED_EVENT_RETRY_INTERVAL);
+ });
+});
diff --git a/lib/event_processor/event_processor_factory.node.ts b/lib/event_processor/event_processor_factory.node.ts
new file mode 100644
index 000000000..b0ed4ffde
--- /dev/null
+++ b/lib/event_processor/event_processor_factory.node.ts
@@ -0,0 +1,56 @@
+/**
+ * Copyright 2024-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.
+ */
+import { EventDispatcher } from './event_dispatcher/event_dispatcher';
+import defaultEventDispatcher from './event_dispatcher/default_dispatcher.node';
+import {
+ BatchEventProcessorOptions,
+ FAILED_EVENT_RETRY_INTERVAL,
+ getOpaqueBatchEventProcessor,
+ getPrefixEventStore,
+ OpaqueEventProcessor,
+ wrapEventProcessor,
+ getForwardingEventProcessor,
+} from './event_processor_factory';
+
+export const DEFAULT_EVENT_BATCH_SIZE = 10;
+export const DEFAULT_EVENT_FLUSH_INTERVAL = 30_000;
+
+export const createForwardingEventProcessor = (
+ eventDispatcher: EventDispatcher = defaultEventDispatcher,
+): OpaqueEventProcessor => {
+ return wrapEventProcessor(getForwardingEventProcessor(eventDispatcher));
+};
+
+export const createBatchEventProcessor = (
+ options: BatchEventProcessorOptions = {}
+): OpaqueEventProcessor => {
+ const eventStore = options.eventStore ? getPrefixEventStore(options.eventStore) : undefined;
+
+ return getOpaqueBatchEventProcessor({
+ eventDispatcher: options.eventDispatcher || defaultEventDispatcher,
+ closingEventDispatcher: options.closingEventDispatcher,
+ flushInterval: options.flushInterval,
+ batchSize: options.batchSize,
+ defaultFlushInterval: DEFAULT_EVENT_FLUSH_INTERVAL,
+ defaultBatchSize: DEFAULT_EVENT_BATCH_SIZE,
+ retryOptions: {
+ maxRetries: 5,
+
+ },
+ failedEventRetryInterval: eventStore ? FAILED_EVENT_RETRY_INTERVAL : undefined,
+ eventStore,
+ });
+};
diff --git a/lib/event_processor/event_processor_factory.react_native.spec.ts b/lib/event_processor/event_processor_factory.react_native.spec.ts
new file mode 100644
index 000000000..630417a5e
--- /dev/null
+++ b/lib/event_processor/event_processor_factory.react_native.spec.ts
@@ -0,0 +1,265 @@
+/**
+ * Copyright 2024-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.
+ */
+
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+
+vi.mock('./default_dispatcher.browser', () => {
+ return { default: {} };
+});
+
+
+vi.mock('./event_processor_factory', async importOriginal => {
+ const getForwardingEventProcessor = vi.fn().mockReturnValue({});
+ const getBatchEventProcessor = vi.fn().mockImplementation(() => {
+ return {};
+ });
+ const getOpaqueBatchEventProcessor = vi.fn().mockImplementation(() => {
+ return {};
+ });
+ const original: any = await importOriginal();
+ return { ...original, getBatchEventProcessor, getOpaqueBatchEventProcessor, getForwardingEventProcessor };
+});
+
+vi.mock('../utils/cache/async_storage_cache.react_native', () => {
+ return { AsyncStorageCache: vi.fn() };
+});
+
+vi.mock('../utils/cache/store', () => {
+ return { SyncPrefixStore: vi.fn(), AsyncPrefixStore: vi.fn() };
+});
+
+vi.mock('@react-native-community/netinfo', () => {
+ return { NetInfoState: {}, addEventListener: vi.fn() };
+});
+let isAsyncStorageAvailable = true;
+
+await vi.hoisted(async () => {
+ await mockRequireNetInfo();
+});
+
+async function mockRequireNetInfo() {
+ const { Module } = await import('module');
+ const M: any = Module;
+
+ M._load_original = M._load;
+ M._load = (uri: string, parent: string) => {
+ if (uri === '@react-native-async-storage/async-storage') {
+ if (isAsyncStorageAvailable) return {};
+ throw new Error("Module not found: @react-native-async-storage/async-storage");
+ }
+ return M._load_original(uri, parent);
+ };
+}
+
+import { createForwardingEventProcessor, createBatchEventProcessor } from './event_processor_factory.react_native';
+import defaultEventDispatcher from './event_dispatcher/default_dispatcher.browser';
+import { EVENT_STORE_PREFIX, extractEventProcessor, getForwardingEventProcessor, FAILED_EVENT_RETRY_INTERVAL } from './event_processor_factory';
+import { getOpaqueBatchEventProcessor } from './event_processor_factory';
+import { AsyncStore, AsyncPrefixStore, SyncStore, SyncPrefixStore } from '../utils/cache/store';
+import { AsyncStorageCache } from '../utils/cache/async_storage_cache.react_native';
+import { MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE } from '../utils/import.react_native/@react-native-async-storage/async-storage';
+
+describe('createForwardingEventProcessor', () => {
+ const mockGetForwardingEventProcessor = vi.mocked(getForwardingEventProcessor);
+
+ beforeEach(() => {
+ mockGetForwardingEventProcessor.mockClear();
+ });
+
+ it('returns forwarding event processor by calling getForwardingEventProcessor with the provided dispatcher', () => {
+ const eventDispatcher = {
+ dispatchEvent: vi.fn(),
+ };
+
+ const processor = extractEventProcessor(createForwardingEventProcessor(eventDispatcher));
+
+ expect(Object.is(processor, mockGetForwardingEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetForwardingEventProcessor).toHaveBeenNthCalledWith(1, eventDispatcher);
+ });
+
+ it('uses the browser default event dispatcher if none is provided', () => {
+ const processor = extractEventProcessor(createForwardingEventProcessor());
+
+ expect(Object.is(processor, mockGetForwardingEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetForwardingEventProcessor).toHaveBeenNthCalledWith(1, defaultEventDispatcher);
+ });
+});
+
+describe('createBatchEventProcessor', () => {
+ const mockGetOpaqueBatchEventProcessor = vi.mocked(getOpaqueBatchEventProcessor);
+ const MockAsyncStorageCache = vi.mocked(AsyncStorageCache);
+ const MockSyncPrefixStore = vi.mocked(SyncPrefixStore);
+ const MockAsyncPrefixStore = vi.mocked(AsyncPrefixStore);
+
+ beforeEach(() => {
+ mockGetOpaqueBatchEventProcessor.mockClear();
+ MockAsyncStorageCache.mockClear();
+ MockSyncPrefixStore.mockClear();
+ MockAsyncPrefixStore.mockClear();
+ });
+
+ it('uses AsyncStorageCache and AsyncPrefixStore to create eventStore if no eventStore is provided', () => {
+ const processor = createBatchEventProcessor({});
+
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ const eventStore = mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventStore;
+ expect(Object.is(eventStore, MockAsyncPrefixStore.mock.results[0].value)).toBe(true);
+
+ const [cache, prefix, transformGet, transformSet] = MockAsyncPrefixStore.mock.calls[0];
+ expect(Object.is(cache, MockAsyncStorageCache.mock.results[0].value)).toBe(true);
+ expect(prefix).toBe(EVENT_STORE_PREFIX);
+
+ // transformGet and transformSet should be identity functions
+ expect(transformGet('value')).toBe('value');
+ expect(transformSet('value')).toBe('value');
+ });
+
+ it('should throw error if @react-native-async-storage/async-storage is not available', async () => {
+ isAsyncStorageAvailable = false;
+ const { AsyncStorageCache } = await vi.importActual<
+ typeof import('../utils/cache/async_storage_cache.react_native')
+ >('../utils/cache/async_storage_cache.react_native');
+
+ MockAsyncStorageCache.mockImplementationOnce(() => {
+ return new AsyncStorageCache();
+ });
+
+ expect(() => createBatchEventProcessor({})).toThrowError(
+ MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE
+ );
+
+ isAsyncStorageAvailable = true;
+ });
+
+ it('should not throw error if eventStore is provided and @react-native-async-storage/async-storage is not available', async () => {
+ isAsyncStorageAvailable = false;
+ const eventStore = {
+ operation: 'sync',
+ } as SyncStore;
+
+ const { AsyncStorageCache } = await vi.importActual<
+ typeof import('../utils/cache/async_storage_cache.react_native')
+ >('../utils/cache/async_storage_cache.react_native');
+
+ MockAsyncStorageCache.mockImplementationOnce(() => {
+ return new AsyncStorageCache();
+ });
+
+ expect(() => createBatchEventProcessor({ eventStore })).not.toThrow();
+
+ isAsyncStorageAvailable = true;
+ });
+
+ it('wraps the provided eventStore in a SyncPrefixStore if a SyncCache is provided as eventStore', () => {
+ const eventStore = {
+ operation: 'sync',
+ } as SyncStore;
+
+ const processor = createBatchEventProcessor({ eventStore });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventStore).toBe(MockSyncPrefixStore.mock.results[0].value);
+ const [cache, prefix, transformGet, transformSet] = MockSyncPrefixStore.mock.calls[0];
+
+ expect(cache).toBe(eventStore);
+ expect(prefix).toBe(EVENT_STORE_PREFIX);
+
+ // transformGet and transformSet should be JSON.parse and JSON.stringify
+ expect(transformGet('{"value": 1}')).toEqual({ value: 1 });
+ expect(transformSet({ value: 1 })).toBe('{"value":1}');
+ });
+
+ it('wraps the provided eventStore in a AsyncPrefixStore if a AsyncCache is provided as eventStore', () => {
+ const eventStore = {
+ operation: 'async',
+ } as AsyncStore;
+
+ const processor = createBatchEventProcessor({ eventStore });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventStore).toBe(MockAsyncPrefixStore.mock.results[0].value);
+ const [cache, prefix, transformGet, transformSet] = MockAsyncPrefixStore.mock.calls[0];
+
+ expect(cache).toBe(eventStore);
+ expect(prefix).toBe(EVENT_STORE_PREFIX);
+
+ // transformGet and transformSet should be JSON.parse and JSON.stringify
+ expect(transformGet('{"value": 1}')).toEqual({ value: 1 });
+ expect(transformSet({ value: 1 })).toBe('{"value":1}');
+ });
+
+ it('uses the provided eventDispatcher', () => {
+ const eventDispatcher = {
+ dispatchEvent: vi.fn(),
+ };
+
+ const processor = createBatchEventProcessor({ eventDispatcher });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventDispatcher).toBe(eventDispatcher);
+ });
+
+ it('uses the default browser event dispatcher if none is provided', () => {
+ const processor = createBatchEventProcessor({});
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventDispatcher).toBe(defaultEventDispatcher);
+ });
+
+ it('uses the provided closingEventDispatcher', () => {
+ const closingEventDispatcher = {
+ dispatchEvent: vi.fn(),
+ };
+
+ const processor = createBatchEventProcessor({ closingEventDispatcher });
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].closingEventDispatcher).toBe(closingEventDispatcher);
+
+ const processor2 = createBatchEventProcessor({});
+ expect(Object.is(processor2, mockGetOpaqueBatchEventProcessor.mock.results[1].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[1][0].closingEventDispatcher).toBe(undefined);
+ });
+
+ it('uses the provided flushInterval', () => {
+ const processor1 = createBatchEventProcessor({ flushInterval: 2000 });
+ expect(Object.is(processor1, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].flushInterval).toBe(2000);
+
+ const processor2 = createBatchEventProcessor({});
+ expect(Object.is(processor2, mockGetOpaqueBatchEventProcessor.mock.results[1].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[1][0].flushInterval).toBe(undefined);
+ });
+
+ it('uses the provided batchSize', () => {
+ const processor1 = createBatchEventProcessor({ batchSize: 20 });
+ expect(Object.is(processor1, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].batchSize).toBe(20);
+
+ const processor2 = createBatchEventProcessor({});
+ expect(Object.is(processor2, mockGetOpaqueBatchEventProcessor.mock.results[1].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[1][0].batchSize).toBe(undefined);
+ });
+
+ it('uses maxRetries value of 5', () => {
+ const processor = createBatchEventProcessor({});
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].retryOptions?.maxRetries).toBe(5);
+ });
+
+ it('uses the default failedEventRetryInterval', () => {
+ const processor = createBatchEventProcessor({});
+ expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
+ expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].failedEventRetryInterval).toBe(FAILED_EVENT_RETRY_INTERVAL);
+ });
+});
diff --git a/lib/event_processor/event_processor_factory.react_native.ts b/lib/event_processor/event_processor_factory.react_native.ts
new file mode 100644
index 000000000..b46b594a4
--- /dev/null
+++ b/lib/event_processor/event_processor_factory.react_native.ts
@@ -0,0 +1,77 @@
+/**
+ * Copyright 2024-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.
+ */
+import { EventDispatcher } from './event_dispatcher/event_dispatcher';
+import defaultEventDispatcher from './event_dispatcher/default_dispatcher.browser';
+import {
+ BatchEventProcessorOptions,
+ getOpaqueBatchEventProcessor,
+ getPrefixEventStore,
+ OpaqueEventProcessor,
+ wrapEventProcessor,
+ getForwardingEventProcessor,
+} from './event_processor_factory';
+import { EVENT_STORE_PREFIX, FAILED_EVENT_RETRY_INTERVAL } from './event_processor_factory';
+import { AsyncPrefixStore } from '../utils/cache/store';
+import { EventWithId } from './batch_event_processor';
+import { AsyncStorageCache } from '../utils/cache/async_storage_cache.react_native';
+import { ReactNativeNetInfoEventProcessor } from './batch_event_processor.react_native';
+
+export const DEFAULT_EVENT_BATCH_SIZE = 10;
+export const DEFAULT_EVENT_FLUSH_INTERVAL = 1_000;
+
+export const createForwardingEventProcessor = (
+ eventDispatcher: EventDispatcher = defaultEventDispatcher,
+): OpaqueEventProcessor => {
+ return wrapEventProcessor(getForwardingEventProcessor(eventDispatcher));
+};
+
+const identity = (v: T): T => v;
+
+const getDefaultEventStore = () => {
+ const asyncStorageCache = new AsyncStorageCache();
+
+ const eventStore = new AsyncPrefixStore(
+ asyncStorageCache,
+ EVENT_STORE_PREFIX,
+ identity,
+ identity,
+ );
+
+ return eventStore;
+}
+
+export const createBatchEventProcessor = (
+ options: BatchEventProcessorOptions = {}
+): OpaqueEventProcessor => {
+ const eventStore = options.eventStore ? getPrefixEventStore(options.eventStore) : getDefaultEventStore();
+
+ return getOpaqueBatchEventProcessor(
+ {
+ eventDispatcher: options.eventDispatcher || defaultEventDispatcher,
+ closingEventDispatcher: options.closingEventDispatcher,
+ flushInterval: options.flushInterval,
+ batchSize: options.batchSize,
+ defaultFlushInterval: DEFAULT_EVENT_FLUSH_INTERVAL,
+ defaultBatchSize: DEFAULT_EVENT_BATCH_SIZE,
+ retryOptions: {
+ maxRetries: 5,
+ },
+ failedEventRetryInterval: FAILED_EVENT_RETRY_INTERVAL,
+ eventStore,
+ },
+ ReactNativeNetInfoEventProcessor
+ );
+};
diff --git a/lib/event_processor/event_processor_factory.spec.ts b/lib/event_processor/event_processor_factory.spec.ts
new file mode 100644
index 000000000..9aaa97f55
--- /dev/null
+++ b/lib/event_processor/event_processor_factory.spec.ts
@@ -0,0 +1,417 @@
+/**
+ * Copyright 2024-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.
+ */
+
+import { describe, it, expect, beforeEach, vi, MockInstance } from 'vitest';
+import { getBatchEventProcessor } from './event_processor_factory';
+import { BatchEventProcessor, BatchEventProcessorConfig, EventWithId,DEFAULT_MAX_BACKOFF, DEFAULT_MIN_BACKOFF } from './batch_event_processor';
+import { ExponentialBackoff, IntervalRepeater } from '../utils/repeater/repeater';
+import { getMockSyncCache } from '../tests/mock/mock_cache';
+import { LogLevel } from '../logging/logger';
+
+vi.mock('./batch_event_processor');
+vi.mock('../utils/repeater/repeater');
+
+const getMockEventDispatcher = () => {
+ return {
+ dispatchEvent: vi.fn(),
+ }
+};
+
+describe('getBatchEventProcessor', () => {
+ const MockBatchEventProcessor = vi.mocked(BatchEventProcessor);
+ const MockExponentialBackoff = vi.mocked(ExponentialBackoff);
+ const MockIntervalRepeater = vi.mocked(IntervalRepeater);
+
+ beforeEach(() => {
+ MockBatchEventProcessor.mockReset();
+ MockExponentialBackoff.mockReset();
+ MockIntervalRepeater.mockReset();
+ });
+
+ it('should throw an error if provided eventDispatcher is not valid', () => {
+ expect(() => getBatchEventProcessor({
+ eventDispatcher: undefined as any,
+ defaultFlushInterval: 10000,
+ defaultBatchSize: 10,
+ })).toThrow('Invalid event dispatcher');
+
+ expect(() => getBatchEventProcessor({
+ eventDispatcher: null as any,
+ defaultFlushInterval: 10000,
+ defaultBatchSize: 10,
+ })).toThrow('Invalid event dispatcher');
+
+ expect(() => getBatchEventProcessor({
+ eventDispatcher: 'abc' as any,
+ defaultFlushInterval: 10000,
+ defaultBatchSize: 10,
+ })).toThrow('Invalid event dispatcher');
+
+ expect(() => getBatchEventProcessor({
+ eventDispatcher: {} as any,
+ defaultFlushInterval: 10000,
+ defaultBatchSize: 10,
+ })).toThrow('Invalid event dispatcher');
+
+ expect(() => getBatchEventProcessor({
+ eventDispatcher: { dispatchEvent: 'abc' } as any,
+ defaultFlushInterval: 10000,
+ defaultBatchSize: 10,
+ })).toThrow('Invalid event dispatcher');
+ });
+
+ it('should throw and error if provided event store is invalid', () => {
+ expect(() => getBatchEventProcessor({
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 10000,
+ defaultBatchSize: 10,
+ eventStore: 'abc' as any,
+ })).toThrow('Invalid store');
+
+ expect(() => getBatchEventProcessor({
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 10000,
+ defaultBatchSize: 10,
+ eventStore: 123 as any,
+ })).toThrow('Invalid store');
+
+ expect(() => getBatchEventProcessor({
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 10000,
+ defaultBatchSize: 10,
+ eventStore: {} as any,
+ })).toThrow('Invalid store method set, Invalid store method get, Invalid store method remove, Invalid store method getKeys');
+
+ expect(() => getBatchEventProcessor({
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 10000,
+ defaultBatchSize: 10,
+ eventStore: { set: 'abc', get: 'abc', remove: 'abc', getKeys: 'abc' } as any,
+ })).toThrow('Invalid store method set, Invalid store method get, Invalid store method remove, Invalid store method getKeys');
+
+ const noop = () => {};
+
+ expect(() => getBatchEventProcessor({
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 10000,
+ defaultBatchSize: 10,
+ eventStore: { set: noop, get: 'abc' } as any,
+ })).toThrow('Invalid store method get, Invalid store method remove, Invalid store method getKeys');
+
+ expect(() => getBatchEventProcessor({
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 10000,
+ defaultBatchSize: 10,
+ eventStore: { set: noop, get: noop, remove: 'abc' } as any,
+ })).toThrow('Invalid store method remove, Invalid store method getKeys');
+
+ expect(() => getBatchEventProcessor({
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 10000,
+ defaultBatchSize: 10,
+ eventStore: { set: noop, get: noop, remove: noop, getKeys: 'abc' } as any,
+ })).toThrow('Invalid store method getKeys');
+ });
+
+ it('returns an instane of BatchEventProcessor if no subclass constructor is provided', () => {
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 1000,
+ defaultBatchSize: 10,
+ };
+
+ const processor = getBatchEventProcessor(options);
+
+ expect(processor instanceof BatchEventProcessor).toBe(true);
+ });
+
+ it('returns an instane of the provided subclass constructor', () => {
+ class CustomEventProcessor extends BatchEventProcessor {
+ constructor(opts: BatchEventProcessorConfig) {
+ super(opts);
+ }
+ }
+
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 1000,
+ defaultBatchSize: 10,
+ };
+
+ const processor = getBatchEventProcessor(options, CustomEventProcessor);
+
+ expect(processor instanceof CustomEventProcessor).toBe(true);
+ });
+
+ it('does not use retry if retryOptions is not provided', () => {
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 1000,
+ defaultBatchSize: 10,
+ };
+
+ const processor = getBatchEventProcessor(options);
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+ expect(MockBatchEventProcessor.mock.calls[0][0].retryConfig).toBe(undefined);
+ });
+
+ it('uses the correct maxRetries value when retryOptions is provided', () => {
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 1000,
+ defaultBatchSize: 10,
+ retryOptions: {
+ maxRetries: 10,
+ },
+ };
+
+ const processor = getBatchEventProcessor(options);
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+ expect(MockBatchEventProcessor.mock.calls[0][0].retryConfig?.maxRetries).toBe(10);
+ });
+
+ it('uses exponential backoff with default parameters when retryOptions is provided without backoff values', () => {
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 1000,
+ defaultBatchSize: 10,
+ retryOptions: { maxRetries: 2 },
+ };
+
+ const processor = getBatchEventProcessor(options);
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+
+ expect(MockBatchEventProcessor.mock.calls[0][0].retryConfig?.maxRetries).toBe(2);
+
+ const backoffProvider = MockBatchEventProcessor.mock.calls[0][0].retryConfig?.backoffProvider;
+ expect(backoffProvider).not.toBe(undefined);
+ const backoff = backoffProvider?.();
+ expect(Object.is(backoff, MockExponentialBackoff.mock.instances[0])).toBe(true);
+ expect(MockExponentialBackoff).toHaveBeenNthCalledWith(1, DEFAULT_MIN_BACKOFF, DEFAULT_MAX_BACKOFF, 500);
+ });
+
+ it('uses exponential backoff with provided backoff values in retryOptions', () => {
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 1000,
+ defaultBatchSize: 10,
+ retryOptions: { maxRetries: 2, minBackoff: 1000, maxBackoff: 2000 },
+ };
+
+ const processor = getBatchEventProcessor(options);
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+
+ expect(MockBatchEventProcessor.mock.calls[0][0].retryConfig?.maxRetries).toBe(2);
+
+ const backoffProvider = MockBatchEventProcessor.mock.calls[0][0].retryConfig?.backoffProvider;
+
+ expect(backoffProvider).not.toBe(undefined);
+ const backoff = backoffProvider?.();
+ expect(Object.is(backoff, MockExponentialBackoff.mock.instances[0])).toBe(true);
+ expect(MockExponentialBackoff).toHaveBeenNthCalledWith(1, 1000, 2000, 500);
+ });
+
+ it('uses a IntervalRepeater with default flush interval and adds a startup log if flushInterval is not provided', () => {
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ defaultFlushInterval: 12345,
+ defaultBatchSize: 77,
+ };
+
+ const processor = getBatchEventProcessor(options);
+
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+ const usedRepeater = MockBatchEventProcessor.mock.calls[0][0].dispatchRepeater;
+ expect(Object.is(usedRepeater, MockIntervalRepeater.mock.instances[0])).toBe(true);
+ expect(MockIntervalRepeater).toHaveBeenNthCalledWith(1, 12345);
+
+ const startupLogs = MockBatchEventProcessor.mock.calls[0][0].startupLogs;
+ expect(startupLogs).toEqual(expect.arrayContaining([{
+ level: LogLevel.Warn,
+ message: 'Invalid flushInterval %s, defaulting to %s',
+ params: [undefined, 12345],
+ }]));
+ });
+
+ it('uses default flush interval and adds a startup log if flushInterval is less than 1', () => {
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ flushInterval: -1,
+ defaultFlushInterval: 12345,
+ defaultBatchSize: 77,
+ };
+
+ const processor = getBatchEventProcessor(options);
+
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+ const usedRepeater = MockBatchEventProcessor.mock.calls[0][0].dispatchRepeater;
+ expect(Object.is(usedRepeater, MockIntervalRepeater.mock.instances[0])).toBe(true);
+ expect(MockIntervalRepeater).toHaveBeenNthCalledWith(1, 12345);
+
+ const startupLogs = MockBatchEventProcessor.mock.calls[0][0].startupLogs;
+ expect(startupLogs).toEqual(expect.arrayContaining([{
+ level: LogLevel.Warn,
+ message: 'Invalid flushInterval %s, defaulting to %s',
+ params: [-1, 12345],
+ }]));
+ });
+
+ it('uses a IntervalRepeater with provided flushInterval and adds no startup log if provided flushInterval is valid', () => {
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ flushInterval: 12345,
+ defaultFlushInterval: 1000,
+ defaultBatchSize: 77,
+ };
+
+ const processor = getBatchEventProcessor(options);
+
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+ const usedRepeater = MockBatchEventProcessor.mock.calls[0][0].dispatchRepeater;
+ expect(Object.is(usedRepeater, MockIntervalRepeater.mock.instances[0])).toBe(true);
+ expect(MockIntervalRepeater).toHaveBeenNthCalledWith(1, 12345);
+
+ const startupLogs = MockBatchEventProcessor.mock.calls[0][0].startupLogs;
+ expect(startupLogs?.find((log) => log.message === 'Invalid flushInterval %s, defaulting to %s')).toBe(undefined);
+ });
+
+
+ it('uses default batch size and adds a startup log if batchSize is not provided', () => {
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ defaultBatchSize: 77,
+ defaultFlushInterval: 12345,
+ };
+
+ const processor = getBatchEventProcessor(options);
+
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+ expect(MockBatchEventProcessor.mock.calls[0][0].batchSize).toBe(77);
+
+ const startupLogs = MockBatchEventProcessor.mock.calls[0][0].startupLogs;
+ expect(startupLogs).toEqual(expect.arrayContaining([{
+ level: LogLevel.Warn,
+ message: 'Invalid batchSize %s, defaulting to %s',
+ params: [undefined, 77],
+ }]));
+ });
+
+ it('uses default size and adds a startup log if provided batchSize is less than 1', () => {
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ batchSize: -1,
+ defaultBatchSize: 77,
+ defaultFlushInterval: 12345,
+ };
+
+ const processor = getBatchEventProcessor(options);
+
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+ expect(MockBatchEventProcessor.mock.calls[0][0].batchSize).toBe(77);
+
+ const startupLogs = MockBatchEventProcessor.mock.calls[0][0].startupLogs;
+ expect(startupLogs).toEqual(expect.arrayContaining([{
+ level: LogLevel.Warn,
+ message: 'Invalid batchSize %s, defaulting to %s',
+ params: [-1, 77],
+ }]));
+ });
+
+ it('does not use a failedEventRepeater if failedEventRetryInterval is not provided', () => {
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ defaultBatchSize: 77,
+ defaultFlushInterval: 12345,
+ };
+
+ const processor = getBatchEventProcessor(options);
+
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+ expect(MockBatchEventProcessor.mock.calls[0][0].failedEventRepeater).toBe(undefined);
+ });
+
+ it('uses a IntervalRepeater with provided failedEventRetryInterval as failedEventRepeater', () => {
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ failedEventRetryInterval: 12345,
+ defaultBatchSize: 77,
+ defaultFlushInterval: 12345,
+ };
+
+ const processor = getBatchEventProcessor(options);
+
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+ expect(Object.is(MockBatchEventProcessor.mock.calls[0][0].failedEventRepeater, MockIntervalRepeater.mock.instances[1])).toBe(true);
+ expect(MockIntervalRepeater).toHaveBeenNthCalledWith(2, 12345);
+ });
+
+ it('uses the provided eventDispatcher', () => {
+ const eventDispatcher = getMockEventDispatcher();
+ const options = {
+ eventDispatcher,
+ defaultBatchSize: 77,
+ defaultFlushInterval: 12345,
+ };
+
+ const processor = getBatchEventProcessor(options);
+
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+ expect(MockBatchEventProcessor.mock.calls[0][0].eventDispatcher).toBe(eventDispatcher);
+ });
+
+ it('does not use any closingEventDispatcher if not provided', () => {
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ defaultBatchSize: 77,
+ defaultFlushInterval: 12345,
+ };
+
+ const processor = getBatchEventProcessor(options);
+
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+ expect(MockBatchEventProcessor.mock.calls[0][0].closingEventDispatcher).toBe(undefined);
+ });
+
+ it('uses the provided closingEventDispatcher', () => {
+ const closingEventDispatcher = getMockEventDispatcher();
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ closingEventDispatcher,
+ defaultBatchSize: 77,
+ defaultFlushInterval: 12345,
+ };
+
+ const processor = getBatchEventProcessor(options);
+
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+ expect(MockBatchEventProcessor.mock.calls[0][0].closingEventDispatcher).toBe(closingEventDispatcher);
+ });
+
+ it('uses the provided eventStore', () => {
+ const eventStore = getMockSyncCache();
+ const options = {
+ eventDispatcher: getMockEventDispatcher(),
+ eventStore,
+ defaultBatchSize: 77,
+ defaultFlushInterval: 12345,
+ };
+
+ const processor = getBatchEventProcessor(options);
+
+ expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
+ expect(MockBatchEventProcessor.mock.calls[0][0].eventStore).toBe(eventStore);
+ });
+});
diff --git a/lib/event_processor/event_processor_factory.ts b/lib/event_processor/event_processor_factory.ts
new file mode 100644
index 000000000..393ce436a
--- /dev/null
+++ b/lib/event_processor/event_processor_factory.ts
@@ -0,0 +1,175 @@
+/**
+ * Copyright 2024-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.
+ */
+
+import { LogLevel } from "../logging/logger";
+import { StartupLog } from "../service";
+import { ExponentialBackoff, IntervalRepeater } from "../utils/repeater/repeater";
+import { EventDispatcher } from "./event_dispatcher/event_dispatcher";
+import { EventProcessor } from "./event_processor";
+import { ForwardingEventProcessor } from "./forwarding_event_processor";
+import { BatchEventProcessor, DEFAULT_MAX_BACKOFF, DEFAULT_MIN_BACKOFF, EventWithId, RetryConfig } from "./batch_event_processor";
+import { AsyncPrefixStore, Store, SyncPrefixStore } from "../utils/cache/store";
+import { Maybe } from "../utils/type";
+import { validateStore } from "../utils/cache/store_validator";
+
+export const INVALID_EVENT_DISPATCHER = 'Invalid event dispatcher';
+
+export const FAILED_EVENT_RETRY_INTERVAL = 20 * 1000;
+export const EVENT_STORE_PREFIX = 'optly_event:';
+
+export const getPrefixEventStore = (store: Store): Store => {
+ if (store.operation === 'async') {
+ return new AsyncPrefixStore(
+ store,
+ EVENT_STORE_PREFIX,
+ JSON.parse,
+ JSON.stringify,
+ );
+ } else {
+ return new SyncPrefixStore(
+ store,
+ EVENT_STORE_PREFIX,
+ JSON.parse,
+ JSON.stringify,
+ );
+ }
+};
+
+const eventProcessorSymbol: unique symbol = Symbol();
+
+export type OpaqueEventProcessor = {
+ [eventProcessorSymbol]: unknown;
+};
+
+export type BatchEventProcessorOptions = {
+ eventDispatcher?: EventDispatcher;
+ closingEventDispatcher?: EventDispatcher;
+ flushInterval?: number;
+ batchSize?: number;
+ eventStore?: Store;
+};
+
+export type BatchEventProcessorFactoryOptions = Omit & {
+ eventDispatcher: EventDispatcher;
+ closingEventDispatcher?: EventDispatcher;
+ failedEventRetryInterval?: number;
+ defaultFlushInterval: number;
+ defaultBatchSize: number;
+ eventStore?: Store;
+ retryOptions?: {
+ maxRetries: number;
+ minBackoff?: number;
+ maxBackoff?: number;
+ };
+}
+
+export const validateEventDispatcher = (eventDispatcher: EventDispatcher): void => {
+ if (!eventDispatcher || typeof eventDispatcher !== 'object' || typeof eventDispatcher.dispatchEvent !== 'function') {
+ throw new Error(INVALID_EVENT_DISPATCHER);
+ }
+}
+
+export const getBatchEventProcessor = (
+ options: BatchEventProcessorFactoryOptions,
+ EventProcessorConstructor: typeof BatchEventProcessor = BatchEventProcessor
+ ): EventProcessor => {
+ const { eventDispatcher, closingEventDispatcher, retryOptions, eventStore } = options;
+
+ validateEventDispatcher(eventDispatcher);
+ if (closingEventDispatcher) {
+ validateEventDispatcher(closingEventDispatcher);
+ }
+
+ if (eventStore) {
+ validateStore(eventStore);
+ }
+
+ const retryConfig: RetryConfig | undefined = retryOptions ? {
+ maxRetries: retryOptions.maxRetries,
+ backoffProvider: () => {
+ const minBackoff = retryOptions?.minBackoff ?? DEFAULT_MIN_BACKOFF;
+ const maxBackoff = retryOptions?.maxBackoff ?? DEFAULT_MAX_BACKOFF;
+ return new ExponentialBackoff(minBackoff, maxBackoff, 500);
+ }
+ } : undefined;
+
+ const startupLogs: StartupLog[] = [];
+
+ const { defaultFlushInterval, defaultBatchSize } = options;
+
+ let flushInterval = defaultFlushInterval;
+ if (options.flushInterval === undefined || options.flushInterval <= 0) {
+ startupLogs.push({
+ level: LogLevel.Warn,
+ message: 'Invalid flushInterval %s, defaulting to %s',
+ params: [options.flushInterval, defaultFlushInterval],
+ });
+ } else {
+ flushInterval = options.flushInterval;
+ }
+
+ let batchSize = defaultBatchSize;
+ if (options.batchSize === undefined || options.batchSize <= 0) {
+ startupLogs.push({
+ level: LogLevel.Warn,
+ message: 'Invalid batchSize %s, defaulting to %s',
+ params: [options.batchSize, defaultBatchSize],
+ });
+ } else {
+ batchSize = options.batchSize;
+ }
+
+ const dispatchRepeater = new IntervalRepeater(flushInterval);
+ const failedEventRepeater = options.failedEventRetryInterval ?
+ new IntervalRepeater(options.failedEventRetryInterval) : undefined;
+
+ return new EventProcessorConstructor({
+ eventDispatcher,
+ closingEventDispatcher,
+ dispatchRepeater,
+ failedEventRepeater,
+ retryConfig,
+ batchSize,
+ eventStore,
+ startupLogs,
+ });
+}
+
+export const wrapEventProcessor = (eventProcessor: EventProcessor): OpaqueEventProcessor => {
+ return {
+ [eventProcessorSymbol]: eventProcessor,
+ };
+}
+
+export const getOpaqueBatchEventProcessor = (
+ options: BatchEventProcessorFactoryOptions,
+ EventProcessorConstructor: typeof BatchEventProcessor = BatchEventProcessor
+): OpaqueEventProcessor => {
+ return wrapEventProcessor(getBatchEventProcessor(options, EventProcessorConstructor));
+}
+
+export const extractEventProcessor = (eventProcessor: Maybe): Maybe => {
+ if (!eventProcessor || typeof eventProcessor !== 'object') {
+ return undefined;
+ }
+ return eventProcessor[eventProcessorSymbol] as Maybe;
+}
+
+
+export function getForwardingEventProcessor(dispatcher: EventDispatcher): EventProcessor {
+ validateEventDispatcher(dispatcher);
+ return new ForwardingEventProcessor(dispatcher);
+}
diff --git a/lib/event_processor/event_processor_factory.universal.ts b/lib/event_processor/event_processor_factory.universal.ts
new file mode 100644
index 000000000..0a3b2ec56
--- /dev/null
+++ b/lib/event_processor/event_processor_factory.universal.ts
@@ -0,0 +1,61 @@
+/**
+ * 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.
+ */
+
+import { getForwardingEventProcessor } from './event_processor_factory';
+import { EventDispatcher } from './event_dispatcher/event_dispatcher';
+
+import {
+ getOpaqueBatchEventProcessor,
+ BatchEventProcessorOptions,
+ OpaqueEventProcessor,
+ wrapEventProcessor,
+ getPrefixEventStore,
+} from './event_processor_factory';
+
+export const DEFAULT_EVENT_BATCH_SIZE = 10;
+export const DEFAULT_EVENT_FLUSH_INTERVAL = 1_000;
+
+import { FAILED_EVENT_RETRY_INTERVAL } from './event_processor_factory';
+
+export const createForwardingEventProcessor = (
+ eventDispatcher: EventDispatcher
+): OpaqueEventProcessor => {
+ return wrapEventProcessor(getForwardingEventProcessor(eventDispatcher));
+};
+
+export type UniversalBatchEventProcessorOptions = Omit & {
+ eventDispatcher: EventDispatcher;
+}
+
+export const createBatchEventProcessor = (
+ options: UniversalBatchEventProcessorOptions
+): OpaqueEventProcessor => {
+ const eventStore = options.eventStore ? getPrefixEventStore(options.eventStore) : undefined;
+
+ return getOpaqueBatchEventProcessor({
+ eventDispatcher: options.eventDispatcher,
+ closingEventDispatcher: options.closingEventDispatcher,
+ flushInterval: options.flushInterval,
+ batchSize: options.batchSize,
+ defaultFlushInterval: DEFAULT_EVENT_FLUSH_INTERVAL,
+ defaultBatchSize: DEFAULT_EVENT_BATCH_SIZE,
+ retryOptions: {
+ maxRetries: 5,
+ },
+ failedEventRetryInterval: FAILED_EVENT_RETRY_INTERVAL,
+ eventStore: eventStore,
+ });
+};
diff --git a/lib/event_processor/forwarding_event_processor.spec.ts b/lib/event_processor/forwarding_event_processor.spec.ts
new file mode 100644
index 000000000..65d571cb9
--- /dev/null
+++ b/lib/event_processor/forwarding_event_processor.spec.ts
@@ -0,0 +1,120 @@
+/**
+ * Copyright 2021, 2024-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.
+ */
+import { expect, describe, it, vi } from 'vitest';
+
+import { EventDispatcher } from './event_dispatcher/event_dispatcher';
+import { buildLogEvent, makeEventBatch } from './event_builder/log_event';
+import { createImpressionEvent } from '../tests/mock/create_event';
+import { ServiceState } from '../service';
+import { ForwardingEventProcessor } from './forwarding_event_processor';
+
+const getMockEventDispatcher = (): EventDispatcher => {
+ return {
+ dispatchEvent: vi.fn().mockResolvedValue({ statusCode: 200 }),
+ };
+};
+
+describe('ForwardingEventProcessor', () => {
+ it('should resolve onRunning() when start is called', async () => {
+ const dispatcher = getMockEventDispatcher();
+
+ const processor = new ForwardingEventProcessor(dispatcher);
+
+ processor.start();
+ await expect(processor.onRunning()).resolves.not.toThrow();
+ });
+
+ it('should dispatch event immediately when process is called', async() => {
+ const dispatcher = getMockEventDispatcher();
+ const mockDispatch = vi.mocked(dispatcher.dispatchEvent);
+
+ const processor = new ForwardingEventProcessor(dispatcher);
+
+ processor.start();
+ await processor.onRunning();
+
+ const event = createImpressionEvent();
+ processor.process(event);
+ expect(dispatcher.dispatchEvent).toHaveBeenCalledOnce();
+ const data = mockDispatch.mock.calls[0][0].params;
+ expect(data).toEqual(makeEventBatch([event]));
+ });
+
+ it('should emit dispatch event when event is dispatched', async() => {
+ const dispatcher = getMockEventDispatcher();
+
+ const processor = new ForwardingEventProcessor(dispatcher);
+
+ processor.start();
+ await processor.onRunning();
+
+ const listener = vi.fn();
+ processor.onDispatch(listener);
+
+ const event = createImpressionEvent();
+ processor.process(event);
+ expect(dispatcher.dispatchEvent).toHaveBeenCalledOnce();
+ expect(dispatcher.dispatchEvent).toHaveBeenCalledWith(buildLogEvent([event]));
+ expect(listener).toHaveBeenCalledOnce();
+ expect(listener).toHaveBeenCalledWith(buildLogEvent([event]));
+ });
+
+ it('should remove dispatch listener when the function returned from onDispatch is called', async() => {
+ const dispatcher = getMockEventDispatcher();
+
+ const processor = new ForwardingEventProcessor(dispatcher);
+
+ processor.start();
+ await processor.onRunning();
+
+ const listener = vi.fn();
+ const unsub = processor.onDispatch(listener);
+
+ let event = createImpressionEvent();
+ processor.process(event);
+ expect(dispatcher.dispatchEvent).toHaveBeenCalledOnce();
+ expect(dispatcher.dispatchEvent).toHaveBeenCalledWith(buildLogEvent([event]));
+ expect(listener).toHaveBeenCalledOnce();
+ expect(listener).toHaveBeenCalledWith(buildLogEvent([event]));
+
+ unsub();
+ event = createImpressionEvent('id-a');
+ processor.process(event);
+ expect(listener).toHaveBeenCalledOnce();
+ });
+
+ it('should resolve onTerminated promise when stop is called', async () => {
+ const dispatcher = getMockEventDispatcher();
+ const processor = new ForwardingEventProcessor(dispatcher);
+ processor.start();
+ await processor.onRunning();
+
+ expect(processor.getState()).toEqual(ServiceState.Running);
+
+ processor.stop();
+ await expect(processor.onTerminated()).resolves.not.toThrow();
+ });
+
+ it('should reject onRunning promise when stop is called in New state', async () => {
+ const dispatcher = getMockEventDispatcher();
+ const processor = new ForwardingEventProcessor(dispatcher);
+
+ expect(processor.getState()).toEqual(ServiceState.New);
+
+ processor.stop();
+ await expect(processor.onRunning()).rejects.toThrow();
+ });
+ });
diff --git a/lib/event_processor/forwarding_event_processor.ts b/lib/event_processor/forwarding_event_processor.ts
new file mode 100644
index 000000000..f578992c7
--- /dev/null
+++ b/lib/event_processor/forwarding_event_processor.ts
@@ -0,0 +1,76 @@
+/**
+ * Copyright 2021-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.
+ */
+
+
+import { LogEvent } from './event_dispatcher/event_dispatcher';
+import { EventProcessor, ProcessableEvent } from './event_processor';
+
+import { EventDispatcher } from '../shared_types';
+import { buildLogEvent } from './event_builder/log_event';
+import { BaseService, ServiceState } from '../service';
+import { EventEmitter } from '../utils/event_emitter/event_emitter';
+import { Consumer, Fn } from '../utils/type';
+import { SERVICE_STOPPED_BEFORE_RUNNING } from '../service';
+import { sprintf } from '../utils/fns';
+
+export class ForwardingEventProcessor extends BaseService implements EventProcessor {
+ private dispatcher: EventDispatcher;
+ private eventEmitter: EventEmitter<{ dispatch: LogEvent }>;
+
+ constructor(dispatcher: EventDispatcher) {
+ super();
+ this.dispatcher = dispatcher;
+ this.eventEmitter = new EventEmitter();
+ }
+
+ process(event: ProcessableEvent): Promise {
+ const formattedEvent = buildLogEvent([event]);
+ const res = this.dispatcher.dispatchEvent(formattedEvent);
+ this.eventEmitter.emit('dispatch', formattedEvent);
+ return res;
+ }
+
+ start(): void {
+ if (!this.isNew()) {
+ return;
+ }
+ this.state = ServiceState.Running;
+ this.startPromise.resolve();
+ }
+
+ stop(): void {
+ if (this.isDone()) {
+ return;
+ }
+
+ if (this.isNew()) {
+ this.startPromise.reject(new Error(
+ sprintf(SERVICE_STOPPED_BEFORE_RUNNING, 'ForwardingEventProcessor'))
+ );
+ }
+
+ this.state = ServiceState.Terminated;
+ this.stopPromise.resolve();
+ }
+
+ onDispatch(handler: Consumer): Fn {
+ return this.eventEmitter.on('dispatch', handler);
+ }
+
+ flushImmediately(): Promise {
+ return Promise.resolve();
+ }
+}
diff --git a/lib/export_types.ts b/lib/export_types.ts
new file mode 100644
index 000000000..b620fbb8e
--- /dev/null
+++ b/lib/export_types.ts
@@ -0,0 +1,105 @@
+/**
+ * Copyright 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.
+ * 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.
+ */
+
+// config manager related types
+export type {
+ StaticConfigManagerConfig,
+ PollingConfigManagerConfig,
+ OpaqueConfigManager,
+} from './project_config/config_manager_factory';
+
+// event processor related types
+export type {
+ LogEvent,
+ EventDispatcherResponse,
+ EventDispatcher,
+} from './event_processor/event_dispatcher/event_dispatcher';
+
+export type {
+ BatchEventProcessorOptions,
+ OpaqueEventProcessor,
+} from './event_processor/event_processor_factory';
+
+// Odp manager related types
+export type {
+ OdpManagerOptions,
+ OpaqueOdpManager,
+} from './odp/odp_manager_factory';
+
+export type {
+ UserAgentParser,
+} from './odp/ua_parser/user_agent_parser';
+
+// Vuid manager related types
+export type {
+ VuidManagerOptions,
+ OpaqueVuidManager,
+} from './vuid/vuid_manager_factory';
+
+// Logger related types
+export type {
+ LogHandler,
+} from './logging/logger';
+
+export type {
+ OpaqueLevelPreset,
+ LoggerConfig,
+ OpaqueLogger,
+} from './logging/logger_factory';
+
+// Error related types
+export type { ErrorHandler } from './error/error_handler';
+export type { OpaqueErrorNotifier } from './error/error_notifier_factory';
+
+export type { SyncCache, AsyncCache, Cache, SyncCacheWithRemove, AsyncCacheWithRemove, CacheWithRemove } from './utils/cache/cache';
+export type { SyncStore, AsyncStore, Store } from './utils/cache/store'
+
+export type {
+ NotificationType,
+ NotificationPayload,
+ ActivateListenerPayload as ActivateNotificationPayload,
+ DecisionListenerPayload as DecisionNotificationPayload,
+ TrackListenerPayload as TrackNotificationPayload,
+ LogEventListenerPayload as LogEventNotificationPayload,
+ OptimizelyConfigUpdateListenerPayload as OptimizelyConfigUpdateNotificationPayload,
+} from './notification_center/type';
+
+export type {
+ UserAttributeValue,
+ UserAttributes,
+ OptimizelyConfig,
+ FeatureVariableValue,
+ OptimizelyVariable,
+ OptimizelyVariation,
+ OptimizelyExperiment,
+ OptimizelyFeature,
+ OptimizelyDecisionContext,
+ OptimizelyForcedDecision,
+ EventTags,
+ Event,
+ DatafileOptions,
+ UserProfileService,
+ UserProfile,
+ ListenerPayload,
+ OptimizelyDecision,
+ OptimizelyUserContext,
+ Config,
+ Client,
+ ActivateListenerPayload,
+ TrackListenerPayload,
+ NotificationCenter,
+ OptimizelySegmentOption,
+} from './shared_types';
diff --git a/lib/feature_toggle.ts b/lib/feature_toggle.ts
new file mode 100644
index 000000000..54da8afff
--- /dev/null
+++ b/lib/feature_toggle.ts
@@ -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.
+ */
+
+/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
+
+/**
+ * This module contains feature flags that control the availability of features under development.
+ * Each flag represents a feature that is not yet ready for production release. These flags
+ * serve multiple purposes in our development workflow:
+ *
+ * When a new feature is in development, it can be safely merged into the main branch
+ * while remaining disabled in production. This allows continuous integration without
+ * affecting the stability of production releases. The feature code will be automatically
+ * removed in production builds through tree-shaking when the flag is disabled.
+ *
+ * During development and testing, these flags can be easily mocked to enable/disable
+ * specific features. Once a feature is complete and ready for release, its corresponding
+ * flag and all associated checks can be removed from the codebase.
+ */
+
+export const holdout = () => false as const;
+
+export type IfActive boolean, Y, N = unknown> = ReturnType extends true ? Y : N;
diff --git a/lib/index.browser.tests.js b/lib/index.browser.tests.js
new file mode 100644
index 000000000..645739cdb
--- /dev/null
+++ b/lib/index.browser.tests.js
@@ -0,0 +1,353 @@
+/**
+ * Copyright 2016-2020, 2022-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.
+ */
+import { assert } from 'chai';
+import sinon from 'sinon';
+import Optimizely from './optimizely';
+import testData from './tests/test_data';
+import packageJSON from '../package.json';
+import * as optimizelyFactory from './index.browser';
+import configValidator from './utils/config_validator';
+import { getMockProjectConfigManager } from './tests/mock/mock_project_config_manager';
+import { createProjectConfig } from './project_config/project_config';
+import { wrapConfigManager } from './project_config/config_manager_factory';
+import { wrapLogger } from './logging/logger_factory';
+
+class MockLocalStorage {
+ store = {};
+
+ constructor() {}
+
+ getItem(key) {
+ return this.store[key];
+ }
+
+ setItem(key, value) {
+ this.store[key] = value.toString();
+ }
+
+ clear() {
+ this.store = {};
+ }
+
+ removeItem(key) {
+ delete this.store[key];
+ }
+}
+
+if (!global.window) {
+ try {
+ global.window = {
+ localStorage: new MockLocalStorage(),
+ };
+ } catch (e) {
+ console.error("Unable to overwrite global.window");
+ }
+}
+
+const pause = timeoutMilliseconds => {
+ return new Promise(resolve => setTimeout(resolve, timeoutMilliseconds));
+};
+
+var getLogger = () => ({
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => getLogger(),
+ setName: () => {},
+})
+
+describe('javascript-sdk (Browser)', function() {
+ var clock;
+
+ before(() => {
+ window.addEventListener = () => {};
+ // sinon.spy(window, 'addEventListener')
+ });
+
+ beforeEach(function() {
+ clock = sinon.useFakeTimers(new Date());
+ });
+
+ afterEach(function() {
+ clock.restore();
+ });
+
+ describe('APIs', function() {
+ // it('should expose logger, errorHandler, eventDispatcher and enums', function() {
+ // assert.isDefined(optimizelyFactory.logging);
+ // assert.isDefined(optimizelyFactory.logging.createLogger);
+ // assert.isDefined(optimizelyFactory.logging.createNoOpLogger);
+ // assert.isDefined(optimizelyFactory.errorHandler);
+ // assert.isDefined(optimizelyFactory.eventDispatcher);
+ // assert.isDefined(optimizelyFactory.enums);
+ // });
+
+ describe('createInstance', function() {
+ var fakeErrorHandler = { handleError: function() {} };
+ var fakeEventDispatcher = { dispatchEvent: function() {} };
+ var mockLogger;
+
+ beforeEach(function() {
+ mockLogger = getLogger();
+ sinon.stub(mockLogger, 'error');
+
+ global.XMLHttpRequest = sinon.useFakeXMLHttpRequest();
+ });
+
+ afterEach(function() {
+ mockLogger.error.restore();
+ delete global.XMLHttpRequest;
+ });
+
+
+ // TODO: pending event handling should be part of the event processor
+ // logic, not the dispatcher. Refactor accordingly.
+ // it('should invoke resendPendingEvents at most once', function() {
+ // var optlyInstance = optimizelyFactory.createInstance({
+ // projectConfigManager: wrapConfigManager(getMockProjectConfigManager()),
+ // errorHandler: fakeErrorHandler,
+ // logger: silentLogger,
+ // });
+
+ // sinon.assert.calledOnce(LocalStoragePendingEventsDispatcher.prototype.sendPendingEvents);
+
+ // optlyInstance = optimizelyFactory.createInstance({
+ // projectConfigManager: wrapConfigManager(getMockProjectConfigManager()),
+ // errorHandler: fakeErrorHandler,
+ // logger: silentLogger,
+ // });
+ // optlyInstance.onReady().catch(function() {});
+
+ // sinon.assert.calledOnce(LocalStoragePendingEventsDispatcher.prototype.sendPendingEvents);
+ // });
+
+ // it('should not throw if the provided config is not valid', function() {
+ // configValidator.validate.throws(new Error('INVALID_CONFIG_OR_SOMETHING'));
+ // assert.doesNotThrow(function() {
+ // var optlyInstance = optimizelyFactory.createInstance({
+ // projectConfigManager: wrapConfigManager(getMockProjectConfigManager()),
+ // logger: wrapLogger(mockLogger),
+ // });
+ // });
+ // });
+
+ it('should create an instance of optimizely', function() {
+ var optlyInstance = optimizelyFactory.createInstance({
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager()),
+ errorHandler: fakeErrorHandler,
+ logger: wrapLogger(mockLogger),
+ });
+
+ assert.instanceOf(optlyInstance, Optimizely);
+ assert.equal(optlyInstance.clientVersion, '6.2.0');
+ });
+
+ it('should set the JavaScript client engine and version', function() {
+ var optlyInstance = optimizelyFactory.createInstance({
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager()),
+ errorHandler: fakeErrorHandler,
+ logger: wrapLogger(mockLogger),
+ });
+
+ assert.equal('javascript-sdk', optlyInstance.clientEngine);
+ assert.equal(packageJSON.version, optlyInstance.clientVersion);
+ });
+
+ it('should allow passing of "react-sdk" as the clientEngine', function() {
+ var optlyInstance = optimizelyFactory.createInstance({
+ clientEngine: 'react-sdk',
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager()),
+ errorHandler: fakeErrorHandler,
+ logger: wrapLogger(mockLogger),
+ });
+ assert.equal('react-sdk', optlyInstance.clientEngine);
+ });
+
+ it('should activate with provided event dispatcher', function() {
+ var optlyInstance = optimizelyFactory.createInstance({
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({
+ initConfig: createProjectConfig(testData.getTestProjectConfig()),
+ })),
+ logger: wrapLogger(mockLogger),
+ });
+ var activate = optlyInstance.activate('testExperiment', 'testUser');
+ assert.strictEqual(activate, 'control');
+ });
+
+ it('should be able to set and get a forced variation', function() {
+ var optlyInstance = optimizelyFactory.createInstance({
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({
+ initConfig: createProjectConfig(testData.getTestProjectConfig()),
+ })),
+ logger: wrapLogger(mockLogger),
+ });
+
+ var didSetVariation = optlyInstance.setForcedVariation('testExperiment', 'testUser', 'control');
+ assert.strictEqual(didSetVariation, true);
+
+ var variation = optlyInstance.getForcedVariation('testExperiment', 'testUser');
+ assert.strictEqual(variation, 'control');
+ });
+
+ it('should be able to set and unset a forced variation', function() {
+ var optlyInstance = optimizelyFactory.createInstance({
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({
+ initConfig: createProjectConfig(testData.getTestProjectConfig()),
+ })),
+ logger: wrapLogger(mockLogger),
+ });
+
+ var didSetVariation = optlyInstance.setForcedVariation('testExperiment', 'testUser', 'control');
+ assert.strictEqual(didSetVariation, true);
+
+ var variation = optlyInstance.getForcedVariation('testExperiment', 'testUser');
+ assert.strictEqual(variation, 'control');
+
+ var didSetVariation2 = optlyInstance.setForcedVariation('testExperiment', 'testUser', null);
+ assert.strictEqual(didSetVariation2, true);
+
+ var variation2 = optlyInstance.getForcedVariation('testExperiment', 'testUser');
+ assert.strictEqual(variation2, null);
+ });
+
+ it('should be able to set multiple experiments for one user', function() {
+ var optlyInstance = optimizelyFactory.createInstance({
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({
+ initConfig: createProjectConfig(testData.getTestProjectConfig()),
+ })),
+ logger: wrapLogger(mockLogger),
+ });
+
+ var didSetVariation = optlyInstance.setForcedVariation('testExperiment', 'testUser', 'control');
+ assert.strictEqual(didSetVariation, true);
+
+ var didSetVariation2 = optlyInstance.setForcedVariation(
+ 'testExperimentLaunched',
+ 'testUser',
+ 'controlLaunched'
+ );
+ assert.strictEqual(didSetVariation2, true);
+
+ var variation = optlyInstance.getForcedVariation('testExperiment', 'testUser');
+ assert.strictEqual(variation, 'control');
+
+ var variation2 = optlyInstance.getForcedVariation('testExperimentLaunched', 'testUser');
+ assert.strictEqual(variation2, 'controlLaunched');
+ });
+
+ it('should be able to set multiple experiments for one user, and unset one', function() {
+ var optlyInstance = optimizelyFactory.createInstance({
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({
+ initConfig: createProjectConfig(testData.getTestProjectConfig()),
+ })),
+ logger: wrapLogger(mockLogger),
+ });
+
+ var didSetVariation = optlyInstance.setForcedVariation('testExperiment', 'testUser', 'control');
+ assert.strictEqual(didSetVariation, true);
+
+ var didSetVariation2 = optlyInstance.setForcedVariation(
+ 'testExperimentLaunched',
+ 'testUser',
+ 'controlLaunched'
+ );
+ assert.strictEqual(didSetVariation2, true);
+
+ var didSetVariation2 = optlyInstance.setForcedVariation('testExperimentLaunched', 'testUser', null);
+ assert.strictEqual(didSetVariation2, true);
+
+ var variation = optlyInstance.getForcedVariation('testExperiment', 'testUser');
+ assert.strictEqual(variation, 'control');
+
+ var variation2 = optlyInstance.getForcedVariation('testExperimentLaunched', 'testUser');
+ assert.strictEqual(variation2, null);
+ });
+
+ it('should be able to set multiple experiments for one user, and reset one', function() {
+ var optlyInstance = optimizelyFactory.createInstance({
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({
+ initConfig: createProjectConfig(testData.getTestProjectConfig()),
+ })),
+ logger: wrapLogger(mockLogger),
+ });
+
+ var didSetVariation = optlyInstance.setForcedVariation('testExperiment', 'testUser', 'control');
+ assert.strictEqual(didSetVariation, true);
+
+ var didSetVariation2 = optlyInstance.setForcedVariation(
+ 'testExperimentLaunched',
+ 'testUser',
+ 'controlLaunched'
+ );
+ assert.strictEqual(didSetVariation2, true);
+
+ var didSetVariation2 = optlyInstance.setForcedVariation(
+ 'testExperimentLaunched',
+ 'testUser',
+ 'variationLaunched'
+ );
+ assert.strictEqual(didSetVariation2, true);
+
+ var variation = optlyInstance.getForcedVariation('testExperiment', 'testUser');
+ assert.strictEqual(variation, 'control');
+
+ var variation2 = optlyInstance.getForcedVariation('testExperimentLaunched', 'testUser');
+ assert.strictEqual(variation2, 'variationLaunched');
+ });
+
+ it('should override bucketing when setForcedVariation is called', function() {
+ var optlyInstance = optimizelyFactory.createInstance({
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({
+ initConfig: createProjectConfig(testData.getTestProjectConfig()),
+ })),
+ logger: wrapLogger(mockLogger),
+ });
+
+ var didSetVariation = optlyInstance.setForcedVariation('testExperiment', 'testUser', 'control');
+ assert.strictEqual(didSetVariation, true);
+
+ var variation = optlyInstance.getVariation('testExperiment', 'testUser');
+ assert.strictEqual(variation, 'control');
+
+ var didSetVariation2 = optlyInstance.setForcedVariation('testExperiment', 'testUser', 'variation');
+ assert.strictEqual(didSetVariation2, true);
+
+ var variation = optlyInstance.getVariation('testExperiment', 'testUser');
+ assert.strictEqual(variation, 'variation');
+ });
+
+ it('should override bucketing when setForcedVariation is called for a not running experiment', function() {
+ var optlyInstance = optimizelyFactory.createInstance({
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({
+ initConfig: createProjectConfig(testData.getTestProjectConfig()),
+ })),
+ logger: wrapLogger(mockLogger),
+ });
+
+ var didSetVariation = optlyInstance.setForcedVariation(
+ 'testExperimentNotRunning',
+ 'testUser',
+ 'controlNotRunning'
+ );
+ assert.strictEqual(didSetVariation, true);
+
+ var variation = optlyInstance.getVariation('testExperimentNotRunning', 'testUser');
+ assert.strictEqual(variation, null);
+ });
+ });
+ });
+});
diff --git a/lib/index.browser.ts b/lib/index.browser.ts
new file mode 100644
index 000000000..0f644a844
--- /dev/null
+++ b/lib/index.browser.ts
@@ -0,0 +1,64 @@
+/**
+ * Copyright 2016-2017, 2019-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.
+ */
+import { Config, Client } from './shared_types';
+import sendBeaconEventDispatcher from './event_processor/event_dispatcher/send_beacon_dispatcher.browser';
+import { getOptimizelyInstance } from './client_factory';
+import { EventDispatcher } from './event_processor/event_dispatcher/event_dispatcher';
+import { JAVASCRIPT_CLIENT_ENGINE } from './utils/enums';
+import { BrowserRequestHandler } from './utils/http_request_handler/request_handler.browser';
+
+/**
+ * Creates an instance of the Optimizely class
+ * @param {Config} config
+ * @return {Client|null} the Optimizely client object
+ * null on error
+ */
+export const createInstance = function(config: Config): Client {
+ const client = getOptimizelyInstance({
+ ...config,
+ requestHandler: new BrowserRequestHandler(),
+ });
+
+ if (client) {
+ const unloadEvent = 'onpagehide' in window ? 'pagehide' : 'unload';
+ window.addEventListener(
+ unloadEvent,
+ () => {
+ client.flushImmediately();
+ },
+ );
+ }
+
+ return client;
+};
+
+export const getSendBeaconEventDispatcher = (): EventDispatcher | undefined => {
+ return sendBeaconEventDispatcher;
+};
+
+export { default as eventDispatcher } from './event_processor/event_dispatcher/default_dispatcher.browser';
+
+export { createPollingProjectConfigManager } from './project_config/config_manager_factory.browser';
+export { createForwardingEventProcessor, createBatchEventProcessor } from './event_processor/event_processor_factory.browser';
+
+export { createOdpManager } from './odp/odp_manager_factory.browser';
+export { createVuidManager } from './vuid/vuid_manager_factory.browser';
+
+export * from './common_exports';
+
+export * from './export_types';
+
+export const clientEngine: string = JAVASCRIPT_CLIENT_ENGINE;
diff --git a/packages/optimizely-sdk/lib/index.browser.umdtests.js b/lib/index.browser.umdtests.js
similarity index 98%
rename from packages/optimizely-sdk/lib/index.browser.umdtests.js
rename to lib/index.browser.umdtests.js
index f8be89aca..a13f5046b 100644
--- a/packages/optimizely-sdk/lib/index.browser.umdtests.js
+++ b/lib/index.browser.umdtests.js
@@ -23,6 +23,7 @@ import Optimizely from './optimizely';
import testData from './tests/test_data';
import packageJSON from '../package.json';
import eventDispatcher from './plugins/event_dispatcher/index.browser';
+import { INVALID_CONFIG_OR_SOMETHING } from './exception_messages';
describe('javascript-sdk', function() {
describe('APIs', function() {
@@ -92,7 +93,7 @@ describe('javascript-sdk', function() {
});
it('should not throw if the provided config is not valid', function() {
- configValidator.validate.throws(new Error('Invalid config or something'));
+ configValidator.validate.throws(new Error(INVALID_CONFIG_OR_SOMETHING));
assert.doesNotThrow(function() {
var optlyInstance = window.optimizelySdk.createInstance({
datafile: {},
diff --git a/lib/index.node.tests.js b/lib/index.node.tests.js
new file mode 100644
index 000000000..b52a0f15c
--- /dev/null
+++ b/lib/index.node.tests.js
@@ -0,0 +1,213 @@
+/**
+ * Copyright 2016-2020, 2022-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.
+ */
+import { assert } from 'chai';
+import sinon from 'sinon';
+
+import Optimizely from './optimizely';
+import testData from './tests/test_data';
+import * as optimizelyFactory from './index.node';
+import configValidator from './utils/config_validator';
+import { getMockProjectConfigManager } from './tests/mock/mock_project_config_manager';
+import { wrapConfigManager } from './project_config/config_manager_factory';
+import { wrapLogger } from './logging/logger_factory';
+
+var createLogger = () => ({
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => createLogger(),
+})
+
+describe('optimizelyFactory', function() {
+ describe('APIs', function() {
+ // it('should expose logger, errorHandler, eventDispatcher and enums', function() {
+ // assert.isDefined(optimizelyFactory.logging);
+ // assert.isDefined(optimizelyFactory.logging.createLogger);
+ // assert.isDefined(optimizelyFactory.logging.createNoOpLogger);
+ // assert.isDefined(optimizelyFactory.errorHandler);
+ // assert.isDefined(optimizelyFactory.eventDispatcher);
+ // assert.isDefined(optimizelyFactory.enums);
+ // });
+
+ describe('createInstance', function() {
+ var fakeErrorHandler = { handleError: function() {} };
+ var fakeEventDispatcher = { dispatchEvent: function() {} };
+ var fakeLogger = createLogger();
+
+ beforeEach(function() {
+ sinon.stub(fakeLogger, 'error');
+ });
+
+ afterEach(function() {
+ fakeLogger.error.restore();
+ });
+
+ // it('should not throw if the provided config is not valid and log an error if logger is passed in', function() {
+ // configValidator.validate.throws(new Error('Invalid config or something'));
+ // var localLogger = loggerPlugin.createLogger({ logLevel: enums.LOG_LEVEL.INFO });
+ // assert.doesNotThrow(function() {
+ // var optlyInstance = optimizelyFactory.createInstance({
+ // projectConfigManager: getMockProjectConfigManager(),
+ // logger: localLogger,
+ // });
+ // });
+ // sinon.assert.calledWith(localLogger.log, enums.LOG_LEVEL.ERROR);
+ // });
+
+ // it('should not throw if the provided config is not valid', function() {
+ // configValidator.validate.throws(new Error('INVALID_CONFIG_OR_SOMETHING'));
+ // assert.doesNotThrow(function() {
+ // var optlyInstance = optimizelyFactory.createInstance({
+ // projectConfigManager: wrapConfigManager(getMockProjectConfigManager()),
+ // logger: wrapLogger(fakeLogger),
+ // });
+ // });
+ // // sinon.assert.calledOnce(fakeLogger.error);
+ // });
+
+ it('should create an instance of optimizely', function() {
+ var optlyInstance = optimizelyFactory.createInstance({
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager()),
+ // errorHandler: fakeErrorHandler,
+ // eventDispatcher: fakeEventDispatcher,
+ // logger: fakeLogger,
+ });
+
+ assert.instanceOf(optlyInstance, Optimizely);
+ assert.equal(optlyInstance.clientVersion, '6.2.0');
+ });
+ // TODO: user will create and inject an event processor
+ // these tests will be refactored accordingly
+ // describe('event processor configuration', function() {
+ // var eventProcessorSpy;
+ // beforeEach(function() {
+ // eventProcessorSpy = sinon.stub(eventProcessor, 'createEventProcessor').callThrough();
+ // });
+
+ // afterEach(function() {
+ // eventProcessor.createEventProcessor.restore();
+ // });
+
+ // it('should ignore invalid event flush interval and use default instead', function() {
+ // optimizelyFactory.createInstance({
+ // projectConfigManager: getMockProjectConfigManager({
+ // initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()),
+ // }),
+ // errorHandler: fakeErrorHandler,
+ // eventDispatcher: fakeEventDispatcher,
+ // logger: fakeLogger,
+ // eventFlushInterval: ['invalid', 'flush', 'interval'],
+ // });
+ // sinon.assert.calledWithExactly(
+ // eventProcessorSpy,
+ // sinon.match({
+ // flushInterval: 30000,
+ // })
+ // );
+ // });
+
+ // it('should use default event flush interval when none is provided', function() {
+ // optimizelyFactory.createInstance({
+ // projectConfigManager: getMockProjectConfigManager({
+ // initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()),
+ // }),
+ // errorHandler: fakeErrorHandler,
+ // eventDispatcher: fakeEventDispatcher,
+ // logger: fakeLogger,
+ // });
+ // sinon.assert.calledWithExactly(
+ // eventProcessorSpy,
+ // sinon.match({
+ // flushInterval: 30000,
+ // })
+ // );
+ // });
+
+ // it('should use provided event flush interval when valid', function() {
+ // optimizelyFactory.createInstance({
+ // projectConfigManager: getMockProjectConfigManager({
+ // initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()),
+ // }),
+ // errorHandler: fakeErrorHandler,
+ // eventDispatcher: fakeEventDispatcher,
+ // logger: fakeLogger,
+ // eventFlushInterval: 10000,
+ // });
+ // sinon.assert.calledWithExactly(
+ // eventProcessorSpy,
+ // sinon.match({
+ // flushInterval: 10000,
+ // })
+ // );
+ // });
+
+ // it('should ignore invalid event batch size and use default instead', function() {
+ // optimizelyFactory.createInstance({
+ // projectConfigManager: getMockProjectConfigManager({
+ // initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()),
+ // }),
+ // errorHandler: fakeErrorHandler,
+ // eventDispatcher: fakeEventDispatcher,
+ // logger: fakeLogger,
+ // eventBatchSize: null,
+ // });
+ // sinon.assert.calledWithExactly(
+ // eventProcessorSpy,
+ // sinon.match({
+ // batchSize: 10,
+ // })
+ // );
+ // });
+
+ // it('should use default event batch size when none is provided', function() {
+ // optimizelyFactory.createInstance({
+ // projectConfigManager: getMockProjectConfigManager({
+ // initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()),
+ // }),
+ // errorHandler: fakeErrorHandler,
+ // eventDispatcher: fakeEventDispatcher,
+ // logger: fakeLogger,
+ // });
+ // sinon.assert.calledWithExactly(
+ // eventProcessorSpy,
+ // sinon.match({
+ // batchSize: 10,
+ // })
+ // );
+ // });
+
+ // it('should use provided event batch size when valid', function() {
+ // optimizelyFactory.createInstance({
+ // projectConfigManager: getMockProjectConfigManager({
+ // initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()),
+ // }),
+ // errorHandler: fakeErrorHandler,
+ // eventDispatcher: fakeEventDispatcher,
+ // logger: fakeLogger,
+ // eventBatchSize: 300,
+ // });
+ // sinon.assert.calledWithExactly(
+ // eventProcessorSpy,
+ // sinon.match({
+ // batchSize: 300,
+ // })
+ // );
+ // });
+ // });
+ });
+ });
+});
diff --git a/lib/index.node.ts b/lib/index.node.ts
new file mode 100644
index 000000000..02d162ed6
--- /dev/null
+++ b/lib/index.node.ts
@@ -0,0 +1,54 @@
+/**
+ * Copyright 2016-2017, 2019-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.
+ */
+import { NODE_CLIENT_ENGINE } from './utils/enums';
+import { Client, Config } from './shared_types';
+import { getOptimizelyInstance, OptimizelyFactoryConfig } from './client_factory';
+import { EventDispatcher } from './event_processor/event_dispatcher/event_dispatcher';
+import { NodeRequestHandler } from './utils/http_request_handler/request_handler.node';
+
+/**
+ * Creates an instance of the Optimizely class
+ * @param {Config} config
+ * @return {Client|null} the Optimizely client object
+ * null on error
+ */
+export const createInstance = function(config: Config): Client {
+ const nodeConfig: OptimizelyFactoryConfig = {
+ ...config,
+ clientEngine: config.clientEngine || NODE_CLIENT_ENGINE,
+ requestHandler: new NodeRequestHandler(),
+ }
+
+ return getOptimizelyInstance(nodeConfig);
+};
+
+export const getSendBeaconEventDispatcher = function(): EventDispatcher | undefined {
+ return undefined;
+};
+
+export { default as eventDispatcher } from './event_processor/event_dispatcher/default_dispatcher.node';
+
+export { createPollingProjectConfigManager } from './project_config/config_manager_factory.node';
+export { createForwardingEventProcessor, createBatchEventProcessor } from './event_processor/event_processor_factory.node';
+
+export { createOdpManager } from './odp/odp_manager_factory.node';
+export { createVuidManager } from './vuid/vuid_manager_factory.node';
+
+export * from './common_exports';
+
+export * from './export_types';
+
+export const clientEngine: string = NODE_CLIENT_ENGINE;
diff --git a/lib/index.react_native.spec.ts b/lib/index.react_native.spec.ts
new file mode 100644
index 000000000..d8ed60bea
--- /dev/null
+++ b/lib/index.react_native.spec.ts
@@ -0,0 +1,174 @@
+/**
+ * Copyright 2019-2020, 2022-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.
+ */
+import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest';
+
+import Optimizely from './optimizely';
+import testData from './tests/test_data';
+import packageJSON from '../package.json';
+import * as optimizelyFactory from './index.react_native';
+import configValidator from './utils/config_validator';
+import { getMockProjectConfigManager } from './tests/mock/mock_project_config_manager';
+import { createProjectConfig } from './project_config/project_config';
+import { getMockLogger } from './tests/mock/mock_logger';
+import { wrapConfigManager } from './project_config/config_manager_factory';
+import { wrapLogger } from './logging/logger_factory';
+
+vi.mock('@react-native-community/netinfo');
+vi.mock('react-native-get-random-values')
+vi.mock('fast-text-encoding')
+
+describe('javascript-sdk/react-native', () => {
+ beforeEach(() => {
+ vi.spyOn(optimizelyFactory.eventDispatcher, 'dispatchEvent');
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ describe('APIs', () => {
+ // it('should expose logger, errorHandler, eventDispatcher and enums', () => {
+ // expect(optimizelyFactory.eventDispatcher).toBeDefined();
+ // expect(optimizelyFactory.enums).toBeDefined();
+ // });
+
+ describe('createInstance', () => {
+ const fakeErrorHandler = { handleError: function() {} };
+ const fakeEventDispatcher = { dispatchEvent: async function() {
+ return Promise.resolve({});
+ } };
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ let mockLogger;
+
+ beforeEach(() => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ mockLogger = getMockLogger();
+ vi.spyOn(console, 'error');
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ // it('should not throw if the provided config is not valid', () => {
+ // vi.spyOn(configValidator, 'validate').mockImplementation(() => {
+ // throw new Error('Invalid config or something');
+ // });
+ // expect(function() {
+ // const optlyInstance = optimizelyFactory.createInstance({
+ // projectConfigManager: wrapConfigManager(getMockProjectConfigManager()),
+ // // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // // @ts-ignore
+ // logger: wrapLogger(mockLogger),
+ // });
+ // }).not.toThrow();
+ // });
+
+ it('should create an instance of optimizely', () => {
+ const optlyInstance = optimizelyFactory.createInstance({
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager()),
+ // errorHandler: fakeErrorHandler,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ // logger: mockLogger,
+ });
+
+ expect(optlyInstance).toBeInstanceOf(Optimizely);
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ expect(optlyInstance.clientVersion).toEqual('6.2.0');
+ });
+
+ it('should set the React Native JS client engine and javascript SDK version', () => {
+ const optlyInstance = optimizelyFactory.createInstance({
+ projectConfigManager: wrapConfigManager(getMockProjectConfigManager()),
+ // errorHandler: fakeErrorHandler,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ // logger: mockLogger,
+ });
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ expect('react-native-js-sdk').toEqual(optlyInstance.clientEngine);
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ expect(packageJSON.version).toEqual(optlyInstance.clientVersion);
+ });
+
+ // it('should allow passing of "react-sdk" as the clientEngine and convert it to "react-native-sdk"', () => {
+ // const optlyInstance = optimizelyFactory.createInstance({
+ // clientEngine: 'react-sdk',
+ // projectConfigManager: getMockProjectConfigManager(),
+ // errorHandler: fakeErrorHandler,
+ // // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // // @ts-ignore
+ // logger: mockLogger,
+ // });
+ // // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // // @ts-ignore
+ // expect('react-native-sdk').toEqual(optlyInstance.clientEngine);
+ // });
+
+ // describe('when passing in logLevel', () => {
+ // beforeEach(() => {
+ // vi.spyOn(logging, 'setLogLevel');
+ // });
+
+ // afterEach(() => {
+ // vi.resetAllMocks();
+ // });
+
+ // it('should call logging.setLogLevel', () => {
+ // optimizelyFactory.createInstance({
+ // projectConfigManager: getMockProjectConfigManager({
+ // initConfig: createProjectConfig(testData.getTestProjectConfig()),
+ // }),
+ // logLevel: optimizelyFactory.enums.LOG_LEVEL.ERROR,
+ // });
+ // expect(logging.setLogLevel).toBeCalledTimes(1);
+ // expect(logging.setLogLevel).toBeCalledWith(optimizelyFactory.enums.LOG_LEVEL.ERROR);
+ // });
+ // });
+
+ // describe('when passing in logger', () => {
+ // beforeEach(() => {
+ // vi.spyOn(logging, 'setLogHandler');
+ // });
+
+ // afterEach(() => {
+ // vi.resetAllMocks();
+ // });
+
+ // it('should call logging.setLogHandler with the supplied logger', () => {
+ // const fakeLogger = { log: function() {} };
+ // optimizelyFactory.createInstance({
+ // projectConfigManager: getMockProjectConfigManager({
+ // initConfig: createProjectConfig(testData.getTestProjectConfig()),
+ // }),
+ // // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // // @ts-ignore
+ // logger: fakeLogger,
+ // });
+ // expect(logging.setLogHandler).toBeCalledTimes(1);
+ // expect(logging.setLogHandler).toBeCalledWith(fakeLogger);
+ // });
+ // });
+ });
+ });
+});
diff --git a/lib/index.react_native.ts b/lib/index.react_native.ts
new file mode 100644
index 000000000..c393261b7
--- /dev/null
+++ b/lib/index.react_native.ts
@@ -0,0 +1,57 @@
+/**
+ * Copyright 2019-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.
+ */
+import 'fast-text-encoding';
+import 'react-native-get-random-values';
+
+import { Client, Config } from './shared_types';
+import { getOptimizelyInstance, OptimizelyFactoryConfig } from './client_factory';
+import { REACT_NATIVE_JS_CLIENT_ENGINE } from './utils/enums';
+import { EventDispatcher } from './event_processor/event_dispatcher/event_dispatcher';
+import { BrowserRequestHandler } from './utils/http_request_handler/request_handler.browser';
+
+/**
+ * Creates an instance of the Optimizely class
+ * @param {Config} config
+ * @return {Client|null} the Optimizely client object
+ * null on error
+ */
+export const createInstance = function(config: Config): Client {
+ const rnConfig: OptimizelyFactoryConfig = {
+ ...config,
+ clientEngine: config.clientEngine || REACT_NATIVE_JS_CLIENT_ENGINE,
+ requestHandler: new BrowserRequestHandler(),
+ }
+
+ return getOptimizelyInstance(rnConfig);
+};
+
+export const getSendBeaconEventDispatcher = function(): EventDispatcher | undefined {
+ return undefined;
+};
+
+export { default as eventDispatcher } from './event_processor/event_dispatcher/default_dispatcher.browser';
+
+export { createPollingProjectConfigManager } from './project_config/config_manager_factory.react_native';
+export { createForwardingEventProcessor, createBatchEventProcessor } from './event_processor/event_processor_factory.react_native';
+
+export { createOdpManager } from './odp/odp_manager_factory.react_native';
+export { createVuidManager } from './vuid/vuid_manager_factory.react_native';
+
+export * from './common_exports';
+
+export * from './export_types';
+
+export const clientEngine: string = REACT_NATIVE_JS_CLIENT_ENGINE;
diff --git a/lib/index.universal.ts b/lib/index.universal.ts
new file mode 100644
index 000000000..11c39c1d1
--- /dev/null
+++ b/lib/index.universal.ts
@@ -0,0 +1,137 @@
+/**
+ * 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.
+ */
+import { Client, Config } from './shared_types';
+import { getOptimizelyInstance } from './client_factory';
+import { JAVASCRIPT_CLIENT_ENGINE } from './utils/enums';
+
+import { RequestHandler } from './utils/http_request_handler/http';
+
+export type UniversalConfig = Config & {
+ requestHandler: RequestHandler;
+}
+
+/**
+ * Creates an instance of the Optimizely class
+ * @param {Config} config
+ * @return {Client|null} the Optimizely client object
+ * null on error
+ */
+export const createInstance = function(config: UniversalConfig): Client {
+ return getOptimizelyInstance(config);
+};
+
+export { createEventDispatcher } from './event_processor/event_dispatcher/event_dispatcher_factory';
+
+export { createPollingProjectConfigManager } from './project_config/config_manager_factory.universal';
+
+export { createForwardingEventProcessor, createBatchEventProcessor } from './event_processor/event_processor_factory.universal';
+
+export { createOdpManager } from './odp/odp_manager_factory.universal';
+
+// TODO: decide on vuid manager API for universal
+// export { createVuidManager } from './vuid/vuid_manager_factory.node';
+
+export * from './common_exports';
+
+export const clientEngine: string = JAVASCRIPT_CLIENT_ENGINE;
+
+// type exports
+export type { RequestHandler } from './utils/http_request_handler/http';
+
+// config manager related types
+export type {
+ StaticConfigManagerConfig,
+ OpaqueConfigManager,
+} from './project_config/config_manager_factory';
+
+export type { UniversalPollingConfigManagerConfig } from './project_config/config_manager_factory.universal';
+
+// event processor related types
+export type {
+ LogEvent,
+ EventDispatcherResponse,
+ EventDispatcher,
+} from './event_processor/event_dispatcher/event_dispatcher';
+
+export type { UniversalBatchEventProcessorOptions } from './event_processor/event_processor_factory.universal';
+
+// odp manager related types
+export type {
+ UniversalOdpManagerOptions,
+} from './odp/odp_manager_factory.universal';
+
+export type {
+ UserAgentParser,
+} from './odp/ua_parser/user_agent_parser';
+
+export type {
+ OpaqueEventProcessor,
+} from './event_processor/event_processor_factory';
+
+// Logger related types
+export type {
+ LogHandler,
+} from './logging/logger';
+
+export type {
+ OpaqueLevelPreset,
+ LoggerConfig,
+ OpaqueLogger,
+} from './logging/logger_factory';
+
+// Error related types
+export type { ErrorHandler } from './error/error_handler';
+export type { OpaqueErrorNotifier } from './error/error_notifier_factory';
+
+export type { SyncCache, AsyncCache, Cache, SyncCacheWithRemove, AsyncCacheWithRemove, CacheWithRemove } from './utils/cache/cache';
+export type { SyncStore, AsyncStore, Store } from './utils/cache/store'
+
+export type {
+ NotificationType,
+ NotificationPayload,
+ ActivateListenerPayload as ActivateNotificationPayload,
+ DecisionListenerPayload as DecisionNotificationPayload,
+ TrackListenerPayload as TrackNotificationPayload,
+ LogEventListenerPayload as LogEventNotificationPayload,
+ OptimizelyConfigUpdateListenerPayload as OptimizelyConfigUpdateNotificationPayload,
+} from './notification_center/type';
+
+export type {
+ UserAttributeValue,
+ UserAttributes,
+ OptimizelyConfig,
+ FeatureVariableValue,
+ OptimizelyVariable,
+ OptimizelyVariation,
+ OptimizelyExperiment,
+ OptimizelyFeature,
+ OptimizelyDecisionContext,
+ OptimizelyForcedDecision,
+ EventTags,
+ Event,
+ DatafileOptions,
+ UserProfileService,
+ UserProfile,
+ ListenerPayload,
+ OptimizelyDecision,
+ OptimizelyUserContext,
+ Config,
+ Client,
+ ActivateListenerPayload,
+ TrackListenerPayload,
+ NotificationCenter,
+ OptimizelySegmentOption,
+} from './shared_types';
diff --git a/lib/logging/logger.spec.ts b/lib/logging/logger.spec.ts
new file mode 100644
index 000000000..59edd3f96
--- /dev/null
+++ b/lib/logging/logger.spec.ts
@@ -0,0 +1,386 @@
+/**
+ * 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.
+ */
+import { describe, beforeEach, afterEach, it, expect, vi, afterAll } from 'vitest';
+
+import { ConsoleLogHandler, LogLevel, OptimizelyLogger } from './logger';
+import { OptimizelyError } from '../error/optimizly_error';
+
+describe('ConsoleLogHandler', () => {
+ const logSpy = vi.spyOn(console, 'log');
+ const debugSpy = vi.spyOn(console, 'debug');
+ const infoSpy = vi.spyOn(console, 'info');
+ const warnSpy = vi.spyOn(console, 'warn');
+ const errorSpy = vi.spyOn(console, 'error');
+
+ beforeEach(() => {
+ logSpy.mockClear();
+ debugSpy.mockClear();
+ infoSpy.mockClear();
+ warnSpy.mockClear();
+ vi.useFakeTimers().setSystemTime(0);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ afterAll(() => {
+ logSpy.mockRestore();
+ debugSpy.mockRestore();
+ infoSpy.mockRestore();
+ warnSpy.mockRestore();
+ errorSpy.mockRestore();
+
+ vi.useRealTimers();
+ });
+
+ it('should call console.info for LogLevel.Info', () => {
+ const logger = new ConsoleLogHandler();
+ logger.log(LogLevel.Info, 'test');
+
+ expect(infoSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call console.debug for LogLevel.Debug', () => {
+ const logger = new ConsoleLogHandler();
+ logger.log(LogLevel.Debug, 'test');
+
+ expect(debugSpy).toHaveBeenCalledTimes(1);
+ });
+
+
+ it('should call console.warn for LogLevel.Warn', () => {
+ const logger = new ConsoleLogHandler();
+ logger.log(LogLevel.Warn, 'test');
+
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call console.error for LogLevel.Error', () => {
+ const logger = new ConsoleLogHandler();
+ logger.log(LogLevel.Error, 'test');
+
+ expect(errorSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should format the log message', () => {
+ const logger = new ConsoleLogHandler();
+ logger.log(LogLevel.Info, 'info message');
+ logger.log(LogLevel.Debug, 'debug message');
+ logger.log(LogLevel.Warn, 'warn message');
+ logger.log(LogLevel.Error, 'error message');
+
+ expect(infoSpy).toHaveBeenCalledWith('[OPTIMIZELY] - INFO 1970-01-01T00:00:00.000Z info message');
+ expect(debugSpy).toHaveBeenCalledWith('[OPTIMIZELY] - DEBUG 1970-01-01T00:00:00.000Z debug message');
+ expect(warnSpy).toHaveBeenCalledWith('[OPTIMIZELY] - WARN 1970-01-01T00:00:00.000Z warn message');
+ expect(errorSpy).toHaveBeenCalledWith('[OPTIMIZELY] - ERROR 1970-01-01T00:00:00.000Z error message');
+ });
+
+ it('should use the prefix if provided', () => {
+ const logger = new ConsoleLogHandler('PREFIX');
+ logger.log(LogLevel.Info, 'info message');
+ logger.log(LogLevel.Debug, 'debug message');
+ logger.log(LogLevel.Warn, 'warn message');
+ logger.log(LogLevel.Error, 'error message');
+
+ expect(infoSpy).toHaveBeenCalledWith('PREFIX - INFO 1970-01-01T00:00:00.000Z info message');
+ expect(debugSpy).toHaveBeenCalledWith('PREFIX - DEBUG 1970-01-01T00:00:00.000Z debug message');
+ expect(warnSpy).toHaveBeenCalledWith('PREFIX - WARN 1970-01-01T00:00:00.000Z warn message');
+ expect(errorSpy).toHaveBeenCalledWith('PREFIX - ERROR 1970-01-01T00:00:00.000Z error message');
+ });
+});
+
+
+const mockMessageResolver = (prefix = '') => {
+ return {
+ resolve: vi.fn().mockImplementation((message) => `${prefix} ${message}`),
+ };
+}
+
+const mockLogHandler = () => {
+ return {
+ log: vi.fn(),
+ };
+}
+
+describe('OptimizelyLogger', () => {
+ it('should only log error when level is set to error', () => {
+ const logHandler = mockLogHandler();
+ const messageResolver = mockMessageResolver();
+
+ const logger = new OptimizelyLogger({
+ logHandler,
+ errorMsgResolver: messageResolver,
+ level: LogLevel.Error,
+ });
+
+ logger.error('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(1);
+ expect(logHandler.log.mock.calls[0][0]).toBe(LogLevel.Error);
+
+ logger.warn('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(1);
+
+ logger.info('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(1);
+
+ logger.debug('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(1);
+ });
+
+ it('should only log warn and error when level is set to warn', () => {
+ const logHandler = mockLogHandler();
+ const messageResolver = mockMessageResolver();
+
+ const logger = new OptimizelyLogger({
+ logHandler,
+ errorMsgResolver: messageResolver,
+ level: LogLevel.Warn,
+ });
+
+ logger.error('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(1);
+ expect(logHandler.log.mock.calls[0][0]).toBe(LogLevel.Error);
+
+ logger.warn('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(2);
+ expect(logHandler.log.mock.calls[1][0]).toBe(LogLevel.Warn);
+
+ logger.info('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(2);
+
+ logger.debug('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(2);
+ });
+
+ it('should only log info, warn and error when level is set to info', () => {
+ const logHandler = mockLogHandler();
+ const messageResolver = mockMessageResolver();
+
+ const logger = new OptimizelyLogger({
+ logHandler,
+ infoMsgResolver: messageResolver,
+ errorMsgResolver: messageResolver,
+ level: LogLevel.Info,
+ });
+
+ logger.error('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(1);
+ expect(logHandler.log.mock.calls[0][0]).toBe(LogLevel.Error);
+
+ logger.warn('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(2);
+ expect(logHandler.log.mock.calls[1][0]).toBe(LogLevel.Warn);
+
+ logger.info('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(3);
+ expect(logHandler.log.mock.calls[2][0]).toBe(LogLevel.Info);
+
+ logger.debug('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(3);
+ });
+
+ it('should log all levels when level is set to debug', () => {
+ const logHandler = mockLogHandler();
+ const messageResolver = mockMessageResolver();
+
+ const logger = new OptimizelyLogger({
+ logHandler,
+ infoMsgResolver: messageResolver,
+ errorMsgResolver: messageResolver,
+ level: LogLevel.Debug,
+ });
+
+ logger.error('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(1);
+ expect(logHandler.log.mock.calls[0][0]).toBe(LogLevel.Error);
+
+ logger.warn('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(2);
+ expect(logHandler.log.mock.calls[1][0]).toBe(LogLevel.Warn);
+
+ logger.info('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(3);
+ expect(logHandler.log.mock.calls[2][0]).toBe(LogLevel.Info);
+
+ logger.debug('test');
+ expect(logHandler.log).toHaveBeenCalledTimes(4);
+ expect(logHandler.log.mock.calls[3][0]).toBe(LogLevel.Debug);
+ });
+
+ it('should skip logging debug/info levels if not infoMessageResolver is available', () => {
+ const logHandler = mockLogHandler();
+ const messageResolver = mockMessageResolver();
+
+ const logger = new OptimizelyLogger({
+ logHandler,
+ errorMsgResolver: messageResolver,
+ level: LogLevel.Debug,
+ });
+
+ logger.info('test');
+ logger.debug('test');
+ expect(logHandler.log).not.toHaveBeenCalled();
+ });
+
+ it('should resolve debug/info messages using the infoMessageResolver', () => {
+ const logHandler = mockLogHandler();
+
+ const logger = new OptimizelyLogger({
+ logHandler,
+ infoMsgResolver: mockMessageResolver('info'),
+ errorMsgResolver: mockMessageResolver('err'),
+ level: LogLevel.Debug,
+ });
+
+ logger.debug('msg one');
+ logger.info('msg two');
+ expect(logHandler.log).toHaveBeenCalledTimes(2);
+ expect(logHandler.log).toHaveBeenNthCalledWith(1, LogLevel.Debug, 'info msg one');
+ expect(logHandler.log).toHaveBeenNthCalledWith(2, LogLevel.Info, 'info msg two');
+ });
+
+ it('should resolve warn/error messages using the infoMessageResolver', () => {
+ const logHandler = mockLogHandler();
+
+ const logger = new OptimizelyLogger({
+ logHandler,
+ infoMsgResolver: mockMessageResolver('info'),
+ errorMsgResolver: mockMessageResolver('err'),
+ level: LogLevel.Debug,
+ });
+
+ logger.warn('msg one');
+ logger.error('msg two');
+ expect(logHandler.log).toHaveBeenCalledTimes(2);
+ expect(logHandler.log).toHaveBeenNthCalledWith(1, LogLevel.Warn, 'err msg one');
+ expect(logHandler.log).toHaveBeenNthCalledWith(2, LogLevel.Error, 'err msg two');
+ });
+
+ it('should use the provided name as message prefix', () => {
+ const logHandler = mockLogHandler();
+
+ const logger = new OptimizelyLogger({
+ name: 'EventManager',
+ logHandler,
+ infoMsgResolver: mockMessageResolver('info'),
+ errorMsgResolver: mockMessageResolver('err'),
+ level: LogLevel.Debug,
+ });
+
+ logger.warn('msg one');
+ logger.error('msg two');
+ logger.debug('msg three');
+ logger.info('msg four');
+ expect(logHandler.log).toHaveBeenCalledTimes(4);
+ expect(logHandler.log).toHaveBeenNthCalledWith(1, LogLevel.Warn, 'EventManager: err msg one');
+ expect(logHandler.log).toHaveBeenNthCalledWith(2, LogLevel.Error, 'EventManager: err msg two');
+ expect(logHandler.log).toHaveBeenNthCalledWith(3, LogLevel.Debug, 'EventManager: info msg three');
+ expect(logHandler.log).toHaveBeenNthCalledWith(4, LogLevel.Info, 'EventManager: info msg four');
+ });
+
+ it('should format the message with the give parameters', () => {
+ const logHandler = mockLogHandler();
+
+ const logger = new OptimizelyLogger({
+ name: 'EventManager',
+ logHandler,
+ infoMsgResolver: mockMessageResolver('info'),
+ errorMsgResolver: mockMessageResolver('err'),
+ level: LogLevel.Debug,
+ });
+
+ logger.warn('msg %s, %s', 'one', 1);
+ logger.error('msg %s', 'two');
+ logger.debug('msg three', 9999);
+ logger.info('msg four%s%s', '!', '!');
+ expect(logHandler.log).toHaveBeenCalledTimes(4);
+ expect(logHandler.log).toHaveBeenNthCalledWith(1, LogLevel.Warn, 'EventManager: err msg one, 1');
+ expect(logHandler.log).toHaveBeenNthCalledWith(2, LogLevel.Error, 'EventManager: err msg two');
+ expect(logHandler.log).toHaveBeenNthCalledWith(3, LogLevel.Debug, 'EventManager: info msg three');
+ expect(logHandler.log).toHaveBeenNthCalledWith(4, LogLevel.Info, 'EventManager: info msg four!!');
+ });
+
+ it('should log the message of the error object and ignore other arguments if first argument is an error object \
+ other that OptimizelyError', () => {
+ const logHandler = mockLogHandler();
+
+ const logger = new OptimizelyLogger({
+ name: 'EventManager',
+ logHandler,
+ infoMsgResolver: mockMessageResolver('info'),
+ errorMsgResolver: mockMessageResolver('err'),
+ level: LogLevel.Debug,
+ });
+ logger.debug(new Error('msg debug %s'), 'a');
+ logger.info(new Error('msg info %s'), 'b');
+ logger.warn(new Error('msg warn %s'), 'c');
+ logger.error(new Error('msg error %s'), 'd');
+
+ expect(logHandler.log).toHaveBeenCalledTimes(4);
+ expect(logHandler.log).toHaveBeenNthCalledWith(1, LogLevel.Debug, 'EventManager: msg debug %s');
+ expect(logHandler.log).toHaveBeenNthCalledWith(2, LogLevel.Info, 'EventManager: msg info %s');
+ expect(logHandler.log).toHaveBeenNthCalledWith(3, LogLevel.Warn, 'EventManager: msg warn %s');
+ expect(logHandler.log).toHaveBeenNthCalledWith(4, LogLevel.Error, 'EventManager: msg error %s');
+ });
+
+ it('should resolve and log the message of an OptimizelyError using error resolver and ignore other arguments', () => {
+ const logHandler = mockLogHandler();
+
+ const logger = new OptimizelyLogger({
+ name: 'EventManager',
+ logHandler,
+ infoMsgResolver: mockMessageResolver('info'),
+ errorMsgResolver: mockMessageResolver('err'),
+ level: LogLevel.Debug,
+ });
+
+ const err = new OptimizelyError('msg %s %s', 1, 2);
+ logger.debug(err, 'a');
+ logger.info(err, 'a');
+ logger.warn(err, 'a');
+ logger.error(err, 'a');
+
+ expect(logHandler.log).toHaveBeenCalledTimes(4);
+ expect(logHandler.log).toHaveBeenNthCalledWith(1, LogLevel.Debug, 'EventManager: err msg 1 2');
+ expect(logHandler.log).toHaveBeenNthCalledWith(2, LogLevel.Info, 'EventManager: err msg 1 2');
+ expect(logHandler.log).toHaveBeenNthCalledWith(3, LogLevel.Warn, 'EventManager: err msg 1 2');
+ expect(logHandler.log).toHaveBeenNthCalledWith(4, LogLevel.Error, 'EventManager: err msg 1 2');
+ });
+
+ it('should return a new logger with the new name but same level, handler and resolvers when child() is called', () => {
+ const logHandler = mockLogHandler();
+
+ const logger = new OptimizelyLogger({
+ name: 'EventManager',
+ logHandler,
+ infoMsgResolver: mockMessageResolver('info'),
+ errorMsgResolver: mockMessageResolver('err'),
+ level: LogLevel.Info,
+ });
+
+ const childLogger = logger.child('ChildLogger');
+ childLogger.debug('msg one %s', 1);
+ childLogger.info('msg two %s', 2);
+ childLogger.warn('msg three %s', 3);
+ childLogger.error('msg four %s', 4);
+
+ expect(logHandler.log).toHaveBeenCalledTimes(3);
+ expect(logHandler.log).toHaveBeenNthCalledWith(1, LogLevel.Info, 'ChildLogger: info msg two 2');
+ expect(logHandler.log).toHaveBeenNthCalledWith(2, LogLevel.Warn, 'ChildLogger: err msg three 3');
+ expect(logHandler.log).toHaveBeenNthCalledWith(3, LogLevel.Error, 'ChildLogger: err msg four 4');
+ });
+});
diff --git a/lib/logging/logger.ts b/lib/logging/logger.ts
new file mode 100644
index 000000000..8414d544a
--- /dev/null
+++ b/lib/logging/logger.ts
@@ -0,0 +1,167 @@
+/**
+ * Copyright 2019, 2024, 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.
+ */
+import { OptimizelyError } from '../error/optimizly_error';
+import { MessageResolver } from '../message/message_resolver';
+import { sprintf } from '../utils/fns'
+
+export enum LogLevel {
+ Debug,
+ Info,
+ Warn,
+ Error,
+}
+
+export const LogLevelToUpper: Record = {
+ [LogLevel.Debug]: 'DEBUG',
+ [LogLevel.Info]: 'INFO',
+ [LogLevel.Warn]: 'WARN',
+ [LogLevel.Error]: 'ERROR',
+};
+
+export const LogLevelToLower: Record = {
+ [LogLevel.Debug]: 'debug',
+ [LogLevel.Info]: 'info',
+ [LogLevel.Warn]: 'warn',
+ [LogLevel.Error]: 'error',
+};
+
+export interface LoggerFacade {
+ info(message: string | Error, ...args: any[]): void;
+ debug(message: string | Error, ...args: any[]): void;
+ warn(message: string | Error, ...args: any[]): void;
+ error(message: string | Error, ...args: any[]): void;
+ child(name?: string): LoggerFacade;
+ setName(name: string): void;
+}
+
+export interface LogHandler {
+ log(level: LogLevel, message: string, ...args: any[]): void
+}
+
+export class ConsoleLogHandler implements LogHandler {
+ private prefix: string
+
+ constructor(prefix?: string) {
+ this.prefix = prefix || '[OPTIMIZELY]'
+ }
+
+ log(level: LogLevel, message: string) : void {
+ const log = `${this.prefix} - ${LogLevelToUpper[level]} ${this.getTime()} ${message}`
+ this.consoleLog(level, log)
+ }
+
+ private getTime(): string {
+ return new Date().toISOString()
+ }
+
+ private consoleLog(logLevel: LogLevel, log: string) : void {
+ const methodName: string = LogLevelToLower[logLevel];
+
+ const method: any = console[methodName as keyof Console] || console.log;
+ method.call(console, log);
+ }
+}
+
+type OptimizelyLoggerConfig = {
+ logHandler: LogHandler,
+ infoMsgResolver?: MessageResolver,
+ errorMsgResolver: MessageResolver,
+ level: LogLevel,
+ name?: string,
+};
+
+export class OptimizelyLogger implements LoggerFacade {
+ private name?: string;
+ private prefix = '';
+ private logHandler: LogHandler;
+ private infoResolver?: MessageResolver;
+ private errorResolver: MessageResolver;
+ private level: LogLevel;
+
+ constructor(config: OptimizelyLoggerConfig) {
+ this.logHandler = config.logHandler;
+ this.infoResolver = config.infoMsgResolver;
+ this.errorResolver = config.errorMsgResolver;
+ this.level = config.level;
+ if (config.name) {
+ this.setName(config.name);
+ }
+ }
+
+ child(name?: string): OptimizelyLogger {
+ return new OptimizelyLogger({
+ logHandler: this.logHandler,
+ infoMsgResolver: this.infoResolver,
+ errorMsgResolver: this.errorResolver,
+ level: this.level,
+ name,
+ });
+ }
+
+ setName(name: string): void {
+ this.name = name;
+ this.prefix = `${name}: `;
+ }
+
+ info(message: string | Error, ...args: any[]): void {
+ this.log(LogLevel.Info, message, args)
+ }
+
+ debug(message: string | Error, ...args: any[]): void {
+ this.log(LogLevel.Debug, message, args)
+ }
+
+ warn(message: string | Error, ...args: any[]): void {
+ this.log(LogLevel.Warn, message, args)
+ }
+
+ error(message: string | Error, ...args: any[]): void {
+ this.log(LogLevel.Error, message, args)
+ }
+
+ private handleLog(level: LogLevel, message: string, args: any[]) {
+ const log = args.length > 0 ? `${this.prefix}${sprintf(message, ...args)}`
+ : `${this.prefix}${message}`;
+
+ this.logHandler.log(level, log);
+ }
+
+ private log(level: LogLevel, message: string | Error, args: any[]): void {
+ if (level < this.level) {
+ return;
+ }
+
+ if (message instanceof Error) {
+ if (message instanceof OptimizelyError) {
+ message.setMessage(this.errorResolver);
+ }
+ this.handleLog(level, message.message, []);
+ return;
+ }
+
+ let resolver = this.errorResolver;
+
+ if (level < LogLevel.Warn) {
+ if (!this.infoResolver) {
+ return;
+ }
+ resolver = this.infoResolver;
+ }
+
+ const resolvedMessage = resolver.resolve(message);
+ this.handleLog(level, resolvedMessage, args);
+ }
+}
diff --git a/lib/logging/logger_factory.spec.ts b/lib/logging/logger_factory.spec.ts
new file mode 100644
index 000000000..bc7671008
--- /dev/null
+++ b/lib/logging/logger_factory.spec.ts
@@ -0,0 +1,105 @@
+/**
+ * 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.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+
+vi.mock('./logger', async (importOriginal) => {
+ const actual = await importOriginal()
+
+ const MockLogger = vi.fn();
+ const MockConsoleLogHandler = vi.fn();
+
+ return { ...actual as any, OptimizelyLogger: MockLogger, ConsoleLogHandler: MockConsoleLogHandler };
+});
+
+import { OptimizelyLogger, ConsoleLogHandler, LogLevel } from './logger';
+import { createLogger, extractLogger, INFO } from './logger_factory';
+import { errorResolver, infoResolver } from '../message/message_resolver';
+
+describe('createLogger', () => {
+ const MockedOptimizelyLogger = vi.mocked(OptimizelyLogger);
+ const MockedConsoleLogHandler = vi.mocked(ConsoleLogHandler);
+
+ beforeEach(() => {
+ MockedConsoleLogHandler.mockClear();
+ MockedOptimizelyLogger.mockClear();
+ });
+
+ it('should throw an error if the provided logHandler is not a valid LogHandler', () => {
+ expect(() => createLogger({
+ level: INFO,
+ logHandler: {} as any,
+ })).toThrow('Invalid log handler');
+
+ expect(() => createLogger({
+ level: INFO,
+ logHandler: { log: 'abc' } as any,
+ })).toThrow('Invalid log handler');
+
+ expect(() => createLogger({
+ level: INFO,
+ logHandler: 'abc' as any,
+ })).toThrow('Invalid log handler');
+ });
+
+ it('should throw an error if the level is not a valid level preset', () => {
+ expect(() => createLogger({
+ level: null as any,
+ })).toThrow('Invalid level preset');
+
+ expect(() => createLogger({
+ level: undefined as any,
+ })).toThrow('Invalid level preset');
+
+ expect(() => createLogger({
+ level: 'abc' as any,
+ })).toThrow('Invalid level preset');
+
+ expect(() => createLogger({
+ level: 123 as any,
+ })).toThrow('Invalid level preset');
+
+ expect(() => createLogger({
+ level: {} as any,
+ })).toThrow('Invalid level preset');
+ });
+
+ it('should use the passed in options and a default name Optimizely', () => {
+ const mockLogHandler = { log: vi.fn() };
+
+ const logger = extractLogger(createLogger({
+ level: INFO,
+ logHandler: mockLogHandler,
+ }));
+
+ expect(logger).toBe(MockedOptimizelyLogger.mock.instances[0]);
+ const { name, level, infoMsgResolver, errorMsgResolver, logHandler } = MockedOptimizelyLogger.mock.calls[0][0];
+ expect(name).toBe('Optimizely');
+ expect(level).toBe(LogLevel.Info);
+ expect(infoMsgResolver).toBe(infoResolver);
+ expect(errorMsgResolver).toBe(errorResolver);
+ expect(logHandler).toBe(mockLogHandler);
+ });
+
+ it('should use a ConsoleLogHandler if no logHandler is provided', () => {
+ const logger = extractLogger(createLogger({
+ level: INFO,
+ }));
+
+ expect(logger).toBe(MockedOptimizelyLogger.mock.instances[0]);
+ const { logHandler } = MockedOptimizelyLogger.mock.calls[0][0];
+ expect(logHandler).toBe(MockedConsoleLogHandler.mock.instances[0]);
+ });
+});
\ No newline at end of file
diff --git a/lib/logging/logger_factory.ts b/lib/logging/logger_factory.ts
new file mode 100644
index 000000000..2aee1b535
--- /dev/null
+++ b/lib/logging/logger_factory.ts
@@ -0,0 +1,129 @@
+/**
+ * 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.
+ */
+import { ConsoleLogHandler, LogHandler, LogLevel, OptimizelyLogger } from './logger';
+import { errorResolver, infoResolver, MessageResolver } from '../message/message_resolver';
+import { Maybe } from '../utils/type';
+
+export const INVALID_LOG_HANDLER = 'Invalid log handler';
+export const INVALID_LEVEL_PRESET = 'Invalid level preset';
+
+type LevelPreset = {
+ level: LogLevel,
+ infoResolver?: MessageResolver,
+ errorResolver: MessageResolver,
+}
+
+const debugPreset: LevelPreset = {
+ level: LogLevel.Debug,
+ infoResolver,
+ errorResolver,
+};
+
+const infoPreset: LevelPreset = {
+ level: LogLevel.Info,
+ infoResolver,
+ errorResolver,
+}
+
+const warnPreset: LevelPreset = {
+ level: LogLevel.Warn,
+ errorResolver,
+}
+
+const errorPreset: LevelPreset = {
+ level: LogLevel.Error,
+ errorResolver,
+}
+
+const levelPresetSymbol = Symbol();
+
+export type OpaqueLevelPreset = {
+ [levelPresetSymbol]: unknown;
+};
+
+export const DEBUG: OpaqueLevelPreset = {
+ [levelPresetSymbol]: debugPreset,
+};
+
+export const INFO: OpaqueLevelPreset = {
+ [levelPresetSymbol]: infoPreset,
+};
+
+export const WARN: OpaqueLevelPreset = {
+ [levelPresetSymbol]: warnPreset,
+};
+
+export const ERROR: OpaqueLevelPreset = {
+ [levelPresetSymbol]: errorPreset,
+};
+
+export const extractLevelPreset = (preset: OpaqueLevelPreset): LevelPreset => {
+ if (!preset || typeof preset !== 'object' || !preset[levelPresetSymbol]) {
+ throw new Error(INVALID_LEVEL_PRESET);
+ }
+ return preset[levelPresetSymbol] as LevelPreset;
+}
+
+const loggerSymbol = Symbol();
+
+export type OpaqueLogger = {
+ [loggerSymbol]: unknown;
+};
+
+export type LoggerConfig = {
+ level: OpaqueLevelPreset,
+ logHandler?: LogHandler,
+};
+
+const validateLogHandler = (logHandler: any) => {
+ if (typeof logHandler !== 'object' || typeof logHandler.log !== 'function') {
+ throw new Error(INVALID_LOG_HANDLER);
+ }
+}
+
+export const createLogger = (config: LoggerConfig): OpaqueLogger => {
+ const { level, infoResolver, errorResolver } = extractLevelPreset(config.level);
+
+ if (config.logHandler) {
+ validateLogHandler(config.logHandler);
+ }
+
+ const loggerName = 'Optimizely';
+
+ return {
+ [loggerSymbol]: new OptimizelyLogger({
+ name: loggerName,
+ level,
+ infoMsgResolver: infoResolver,
+ errorMsgResolver: errorResolver,
+ logHandler: config.logHandler || new ConsoleLogHandler(),
+ }),
+ };
+};
+
+export const wrapLogger = (logger: OptimizelyLogger): OpaqueLogger => {
+ return {
+ [loggerSymbol]: logger,
+ };
+};
+
+export const extractLogger = (logger: Maybe): Maybe => {
+ if (!logger || typeof logger !== 'object') {
+ return undefined;
+ }
+
+ return logger[loggerSymbol] as Maybe;
+};
diff --git a/lib/message/error_message.ts b/lib/message/error_message.ts
new file mode 100644
index 000000000..720baa377
--- /dev/null
+++ b/lib/message/error_message.ts
@@ -0,0 +1,100 @@
+/**
+ * Copyright 2024-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.
+ */
+export const NOTIFICATION_LISTENER_EXCEPTION = 'Notification listener for (%s) threw exception: %s';
+export const CONDITION_EVALUATOR_ERROR = 'Error evaluating audience condition of type %s: %s';
+export const EXPERIMENT_KEY_NOT_IN_DATAFILE = 'Experiment key %s is not in datafile.';
+export const FEATURE_NOT_IN_DATAFILE = 'Feature key %s is not in datafile.';
+export const INVALID_ATTRIBUTES = 'Provided attributes are in an invalid format.';
+export const INVALID_BUCKETING_ID = 'Unable to generate hash for bucketing ID %s: %s';
+export const INVALID_DATAFILE = 'Datafile is invalid - property %s: %s';
+export const INVALID_DATAFILE_MALFORMED = 'Datafile is invalid because it is malformed.';
+export const INVALID_CONFIG = 'Provided Optimizely config is in an invalid format.';
+export const INVALID_JSON = 'JSON object is not valid.';
+export const INVALID_EVENT_TAGS = 'Provided event tags are in an invalid format.';
+export const INVALID_EXPERIMENT_KEY =
+ 'Experiment key %s is not in datafile. It is either invalid, paused, or archived.';
+export const INVALID_EXPERIMENT_ID = 'Experiment ID %s is not in datafile.';
+export const INVALID_GROUP_ID = 'Group ID %s is not in datafile.';
+export const INVALID_USER_ID = 'Provided user ID is in an invalid format.';
+export const INVALID_USER_PROFILE_SERVICE = 'Provided user profile service instance is in an invalid format: %s.';
+export const MISSING_INTEGRATION_KEY =
+ 'Integration key missing from datafile. All integrations should include a key.';
+export const NO_DATAFILE_SPECIFIED = 'No datafile specified. Cannot start optimizely.';
+export const NO_JSON_PROVIDED = 'No JSON object to validate against schema.';
+export const NO_EVENT_PROCESSOR = 'No event processor is provided';
+export const NO_VARIATION_FOR_EXPERIMENT_KEY = 'No variation key %s defined in datafile for experiment %s.';
+export const ODP_CONFIG_NOT_AVAILABLE = 'ODP config is not available.';
+export const ODP_EVENT_FAILED = 'ODP event send failed.';
+export const ODP_EVENTS_SHOULD_HAVE_ATLEAST_ONE_KEY_VALUE = 'ODP events should have at least one key-value pair in identifiers.';
+export const ODP_EVENT_FAILED_ODP_MANAGER_MISSING = 'ODP Event failed to send. (ODP Manager not available).';
+export const ODP_NOT_INTEGRATED = 'ODP is not integrated';
+export const UNDEFINED_ATTRIBUTE = 'Provided attribute: %s has an undefined value.';
+export const UNRECOGNIZED_ATTRIBUTE =
+ 'Unrecognized attribute %s provided. Pruning before sending event to Optimizely.';
+export const UNABLE_TO_CAST_VALUE = 'Unable to cast value %s to type %s, returning null.';
+export const USER_NOT_IN_FORCED_VARIATION =
+ 'User %s is not in the forced variation map. Cannot remove their forced variation.';
+export const USER_PROFILE_LOOKUP_ERROR = 'Error while looking up user profile for user ID "%s": %s.';
+export const USER_PROFILE_SAVE_ERROR = 'Error while saving user profile for user ID "%s": %s.';
+export const VARIABLE_KEY_NOT_IN_DATAFILE =
+ 'Variable with key "%s" associated with feature with key "%s" is not in datafile.';
+export const VARIATION_ID_NOT_IN_DATAFILE = 'Variation ID %s is not in the datafile.';
+export const INVALID_INPUT_FORMAT = 'Provided %s is in an invalid format.';
+export const INVALID_DATAFILE_VERSION =
+ 'This version of the JavaScript SDK does not support the given datafile version: %s';
+export const INVALID_VARIATION_KEY = 'Provided variation key is in an invalid format.';
+export const ERROR_FETCHING_DATAFILE = 'Error fetching datafile: %s';
+export const DATAFILE_FETCH_REQUEST_FAILED = 'Datafile fetch request failed with status: %s';
+export const EVENT_DATA_INVALID = 'Event data invalid.';
+export const EVENT_ACTION_INVALID = 'Event action invalid.';
+export const FAILED_TO_SEND_ODP_EVENTS = 'failed to send odp events';
+export const UNABLE_TO_GET_VUID_VUID_MANAGER_NOT_AVAILABLE = 'Unable to get VUID - VuidManager is not available'
+export const UNKNOWN_CONDITION_TYPE =
+ 'Audience condition %s has an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.';
+export const UNKNOWN_MATCH_TYPE =
+ 'Audience condition %s uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK.';
+export const UNRECOGNIZED_DECIDE_OPTION = 'Unrecognized decide option %s provided.';
+export const NO_PROJECT_CONFIG_FAILURE = 'No project config available. Failing %s.';
+export const EVENT_KEY_NOT_FOUND = 'Event key %s is not in datafile.';
+export const NOT_TRACKING_USER = 'Not tracking user %s.';
+export const VARIABLE_REQUESTED_WITH_WRONG_TYPE =
+ 'Requested variable type "%s", but variable is of type "%s". Use correct API to retrieve value. Returning None.';
+export const UNEXPECTED_RESERVED_ATTRIBUTE_PREFIX =
+ 'Attribute %s unexpectedly has reserved prefix %s; using attribute ID instead of reserved attribute name.';
+export const BUCKETING_ID_NOT_STRING = 'BucketingID attribute is not a string. Defaulted to userId';
+export const UNEXPECTED_CONDITION_VALUE =
+ 'Audience condition %s evaluated to UNKNOWN because the condition value is not supported.';
+export const UNEXPECTED_TYPE =
+ 'Audience condition %s evaluated to UNKNOWN because a value of type "%s" was passed for user attribute "%s".';
+export const OUT_OF_BOUNDS =
+ 'Audience condition %s evaluated to UNKNOWN because the number value for user attribute "%s" is not in the range [-2^53, +2^53].';
+export const REQUEST_TIMEOUT = 'Request timeout';
+export const REQUEST_ERROR = 'Request error';
+export const NO_STATUS_CODE_IN_RESPONSE = 'No status code in response';
+export const UNSUPPORTED_PROTOCOL = 'Unsupported protocol: %s';
+export const RETRY_CANCELLED = 'Retry cancelled';
+export const ONLY_POST_REQUESTS_ARE_SUPPORTED = 'Only POST requests are supported';
+export const SEND_BEACON_FAILED = 'sendBeacon failed';
+export const FAILED_TO_DISPATCH_EVENTS = 'Failed to dispatch events, status: %s';
+export const ODP_EVENT_MANAGER_STOPPED = "ODP event manager stopped before it could start";
+export const UNABLE_TO_ATTACH_UNLOAD = 'unable to bind optimizely.close() to page unload event: "%s"';
+export const UNABLE_TO_PARSE_AND_SKIPPED_HEADER = 'Unable to parse & skipped header item';
+export const CMAB_FETCH_FAILED = 'CMAB decision fetch failed with status: %s';
+export const INVALID_CMAB_FETCH_RESPONSE = 'Invalid CMAB fetch response';
+export const PROMISE_NOT_ALLOWED = "Promise value is not allowed in sync operation";
+export const SERVICE_NOT_RUNNING = "%s not running";
+
+export const messages: string[] = [];
diff --git a/lib/message/log_message.ts b/lib/message/log_message.ts
new file mode 100644
index 000000000..aaf5a9e36
--- /dev/null
+++ b/lib/message/log_message.ts
@@ -0,0 +1,71 @@
+/**
+ * Copyright 2024, 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.
+ */
+
+export const FEATURE_ENABLED_FOR_USER = 'Feature %s is enabled for user %s.';
+export const FEATURE_NOT_ENABLED_FOR_USER = 'Feature %s is not enabled for user %s.';
+export const FAILED_TO_PARSE_VALUE = 'Failed to parse event value "%s" from event tags.';
+export const FAILED_TO_PARSE_REVENUE = 'Failed to parse revenue value "%s" from event tags.';
+export const INVALID_CLIENT_ENGINE = 'Invalid client engine passed: %s. Defaulting to node-sdk.';
+export const INVALID_DEFAULT_DECIDE_OPTIONS = 'Provided default decide options is not an array.';
+export const INVALID_DECIDE_OPTIONS = 'Provided decide options is not an array. Using default decide options.';
+export const NOT_ACTIVATING_USER = 'Not activating user %s for experiment %s.';
+export const PARSED_REVENUE_VALUE = 'Parsed revenue value "%s" from event tags.';
+export const PARSED_NUMERIC_VALUE = 'Parsed event value "%s" from event tags.';
+export const SAVED_USER_VARIATION = 'Saved user profile for user "%s".';
+export const SAVED_VARIATION_NOT_FOUND =
+ 'User %s was previously bucketed into variation with ID %s for experiment %s, but no matching variation was found.';
+export const SHOULD_NOT_DISPATCH_ACTIVATE = 'Experiment %s is not in "Running" state. Not activating user.';
+export const SKIPPING_JSON_VALIDATION = 'Skipping JSON schema validation.';
+export const TRACK_EVENT = 'Tracking event %s for user %s.';
+export const USER_MAPPED_TO_FORCED_VARIATION =
+ 'Set variation %s for experiment %s and user %s in the forced variation map.';
+export const USER_HAS_NO_FORCED_VARIATION = 'User %s is not in the forced variation map.';
+export const USER_RECEIVED_DEFAULT_VARIABLE_VALUE =
+ 'User "%s" is not in any variation or rollout rule. Returning default value for variable "%s" of feature flag "%s".';
+export const FEATURE_NOT_ENABLED_RETURN_DEFAULT_VARIABLE_VALUE =
+ 'Feature "%s" is not enabled for user %s. Returning the default variable value "%s".';
+export const VARIABLE_NOT_USED_RETURN_DEFAULT_VARIABLE_VALUE =
+ 'Variable "%s" is not used in variation "%s". Returning default value.';
+export const USER_RECEIVED_VARIABLE_VALUE = 'Got variable value "%s" for variable "%s" of feature flag "%s"';
+export const VALID_DATAFILE = 'Datafile is valid.';
+export const VALID_USER_PROFILE_SERVICE = 'Valid user profile service provided.';
+export const VARIATION_REMOVED_FOR_USER = 'Variation mapped to experiment %s has been removed for user %s.';
+
+export const VALID_BUCKETING_ID = 'BucketingId is valid: "%s"';
+export const EVALUATING_AUDIENCE = 'Starting to evaluate audience "%s" with conditions: %s.';
+export const AUDIENCE_EVALUATION_RESULT = 'Audience "%s" evaluated to %s.';
+export const MISSING_ATTRIBUTE_VALUE =
+ 'Audience condition %s evaluated to UNKNOWN because no value was passed for user attribute "%s".';
+export const UNEXPECTED_TYPE_NULL =
+ 'Audience condition %s evaluated to UNKNOWN because a null value was passed for user attribute "%s".';
+export const UPDATED_OPTIMIZELY_CONFIG = 'Updated Optimizely config to revision %s (project id %s)';
+export const ADDING_AUTHORIZATION_HEADER_WITH_BEARER_TOKEN = 'Adding Authorization header with Bearer Token';
+export const MAKING_DATAFILE_REQ_TO_URL_WITH_HEADERS = 'Making datafile request to url %s with headers: %s';
+export const RESPONSE_STATUS_CODE = 'Response status code: %s';
+export const SAVED_LAST_MODIFIED_HEADER_VALUE_FROM_RESPONSE = 'Saved last modified header value from response: %s';
+export const USER_HAS_NO_FORCED_VARIATION_FOR_EXPERIMENT =
+ 'No experiment %s mapped to user %s in the forced variation map.';
+export const INVALID_EXPERIMENT_KEY_INFO =
+ 'Experiment key %s is not in datafile. It is either invalid, paused, or archived.';
+export const EVENT_STORE_FULL = 'Event store is full. Not saving event with id %d.';
+export const IGNORE_CMAB_CACHE = 'Ignoring CMAB cache for user %s and rule %s.';
+export const RESET_CMAB_CACHE = 'Resetting CMAB cache for user %s and rule %s.';
+export const INVALIDATE_CMAB_CACHE = 'Invalidating CMAB cache for user %s and rule %s.';
+export const CMAB_CACHE_HIT = 'Cache hit for user %s and rule %s.';
+export const CMAB_CACHE_ATTRIBUTES_MISMATCH = 'CMAB cache attributes mismatch for user %s and rule %s, fetching new decision.';
+export const CMAB_CACHE_MISS = 'Cache miss for user %s and rule %s.';
+
+export const messages: string[] = [];
diff --git a/lib/message/message_resolver.ts b/lib/message/message_resolver.ts
new file mode 100644
index 000000000..07a0cefdf
--- /dev/null
+++ b/lib/message/message_resolver.ts
@@ -0,0 +1,20 @@
+import { messages as infoMessages } from 'log_message';
+import { messages as errorMessages } from 'error_message';
+
+export interface MessageResolver {
+ resolve(baseMessage: string): string;
+}
+
+export const infoResolver: MessageResolver = {
+ resolve(baseMessage: string): string {
+ const messageNum = parseInt(baseMessage);
+ return infoMessages[messageNum] || baseMessage;
+ }
+};
+
+export const errorResolver: MessageResolver = {
+ resolve(baseMessage: string): string {
+ const messageNum = parseInt(baseMessage);
+ return errorMessages[messageNum] || baseMessage;
+ }
+};
diff --git a/lib/notification_center/index.spec.ts b/lib/notification_center/index.spec.ts
new file mode 100644
index 000000000..4ba54a0c3
--- /dev/null
+++ b/lib/notification_center/index.spec.ts
@@ -0,0 +1,606 @@
+/**
+ * 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.
+ */
+
+import { describe, beforeEach, it, vi, expect } from 'vitest';
+import { createNotificationCenter, DefaultNotificationCenter } from './';
+import {
+ ActivateListenerPayload,
+ DecisionListenerPayload,
+ LogEventListenerPayload,
+ NOTIFICATION_TYPES,
+ TrackListenerPayload,
+ OptimizelyConfigUpdateListenerPayload,
+} from './type';
+import { getMockLogger } from '../tests/mock/mock_logger';
+import { LoggerFacade } from '../logging/logger';
+
+describe('addNotificationListener', () => {
+ let logger: LoggerFacade;
+ let notificationCenterInstance: DefaultNotificationCenter;
+
+ beforeEach(() => {
+ logger = getMockLogger();
+ notificationCenterInstance = createNotificationCenter({ logger });
+ });
+
+ it('should return -1 if notification type is not a valid type', () => {
+ const INVALID_LISTENER_TYPE = 'INVALID_LISTENER_TYPE' as const;
+ const mockFn = vi.fn();
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const listenerId = notificationCenterInstance.addNotificationListener(INVALID_LISTENER_TYPE, mockFn);
+
+ expect(listenerId).toBe(-1);
+ });
+
+ it('should return an id (listernId) > 0 of the notification listener if callback is not already added', () => {
+ const activateCallback = vi.fn();
+ const decisionCallback = vi.fn();
+ const logEventCallback = vi.fn();
+ const configUpdateCallback = vi.fn();
+ const trackCallback = vi.fn();
+ // store a listenerId for each type
+ const activateListenerId = notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.ACTIVATE,
+ activateCallback
+ );
+ const decisionListenerId = notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.DECISION,
+ decisionCallback
+ );
+ const logEventListenerId = notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.LOG_EVENT,
+ logEventCallback
+ );
+ const configUpdateListenerId = notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallback
+ );
+ const trackListenerId = notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallback);
+
+ expect(activateListenerId).toBeGreaterThan(0);
+ expect(decisionListenerId).toBeGreaterThan(0);
+ expect(logEventListenerId).toBeGreaterThan(0);
+ expect(configUpdateListenerId).toBeGreaterThan(0);
+ expect(trackListenerId).toBeGreaterThan(0);
+ });
+});
+
+describe('removeNotificationListener', () => {
+ let logger: LoggerFacade;
+ let notificationCenterInstance: DefaultNotificationCenter;
+
+ beforeEach(() => {
+ logger = getMockLogger();
+ notificationCenterInstance = createNotificationCenter({ logger });
+ });
+
+ it('should return false if listernId does not exist', () => {
+ const notListenerId = notificationCenterInstance.removeNotificationListener(5);
+
+ expect(notListenerId).toBe(false);
+ });
+
+ it('should return true when eixsting listener is removed', () => {
+ const activateCallback = vi.fn();
+ const decisionCallback = vi.fn();
+ const logEventCallback = vi.fn();
+ const configUpdateCallback = vi.fn();
+ const trackCallback = vi.fn();
+ // add listeners for each type
+ const activateListenerId = notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.ACTIVATE,
+ activateCallback
+ );
+ const decisionListenerId = notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.DECISION,
+ decisionCallback
+ );
+ const logEventListenerId = notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.LOG_EVENT,
+ logEventCallback
+ );
+ const configListenerId = notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallback
+ );
+ const trackListenerId = notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallback);
+ // remove listeners for each type
+ const activateListenerRemoved = notificationCenterInstance.removeNotificationListener(activateListenerId);
+ const decisionListenerRemoved = notificationCenterInstance.removeNotificationListener(decisionListenerId);
+ const logEventListenerRemoved = notificationCenterInstance.removeNotificationListener(logEventListenerId);
+ const trackListenerRemoved = notificationCenterInstance.removeNotificationListener(trackListenerId);
+ const configListenerRemoved = notificationCenterInstance.removeNotificationListener(configListenerId);
+
+ expect(activateListenerRemoved).toBe(true);
+ expect(decisionListenerRemoved).toBe(true);
+ expect(logEventListenerRemoved).toBe(true);
+ expect(trackListenerRemoved).toBe(true);
+ expect(configListenerRemoved).toBe(true);
+ });
+ it('should only remove the specified listener', () => {
+ const activateCallbackSpy1 = vi.fn();
+ const activateCallbackSpy2 = vi.fn();
+ const decisionCallbackSpy1 = vi.fn();
+ const decisionCallbackSpy2 = vi.fn();
+ const logEventCallbackSpy1 = vi.fn();
+ const logEventCallbackSpy2 = vi.fn();
+ const configUpdateCallbackSpy1 = vi.fn();
+ const configUpdateCallbackSpy2 = vi.fn();
+ const trackCallbackSpy1 = vi.fn();
+ const trackCallbackSpy2 = vi.fn();
+ // register listeners for each type
+ const activateListenerId1 = notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.ACTIVATE,
+ activateCallbackSpy1
+ );
+ const decisionListenerId1 = notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.DECISION,
+ decisionCallbackSpy1
+ );
+ const logeventlistenerId1 = notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.LOG_EVENT,
+ logEventCallbackSpy1
+ );
+ const configUpdateListenerId1 = notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallbackSpy1
+ );
+ const trackListenerId1 = notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.TRACK,
+ trackCallbackSpy1
+ );
+ // register second listeners for each type
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallbackSpy2
+ );
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy2);
+ // remove first listener
+ const activateListenerRemoved1 = notificationCenterInstance.removeNotificationListener(activateListenerId1);
+ const decisionListenerRemoved1 = notificationCenterInstance.removeNotificationListener(decisionListenerId1);
+ const logEventListenerRemoved1 = notificationCenterInstance.removeNotificationListener(logeventlistenerId1);
+ const configUpdateListenerRemoved1 = notificationCenterInstance.removeNotificationListener(configUpdateListenerId1);
+ const trackListenerRemoved1 = notificationCenterInstance.removeNotificationListener(trackListenerId1);
+ // send notifications
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {} as ActivateListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {} as DecisionListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {} as LogEventListenerPayload);
+ notificationCenterInstance.sendNotifications(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ ({} as unknown) as OptimizelyConfigUpdateListenerPayload
+ );
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {} as TrackListenerPayload);
+
+ expect(activateListenerRemoved1).toBe(true);
+ expect(activateCallbackSpy1).not.toHaveBeenCalled();
+ expect(activateCallbackSpy2).toHaveBeenCalledTimes(1);
+ expect(decisionListenerRemoved1).toBe(true);
+ expect(decisionCallbackSpy1).not.toHaveBeenCalled();
+ expect(decisionCallbackSpy2).toHaveBeenCalledTimes(1);
+ expect(logEventListenerRemoved1).toBe(true);
+ expect(logEventCallbackSpy1).not.toHaveBeenCalled();
+ expect(logEventCallbackSpy2).toHaveBeenCalledTimes(1);
+ expect(configUpdateListenerRemoved1).toBe(true);
+ expect(configUpdateCallbackSpy1).not.toHaveBeenCalled();
+ expect(configUpdateCallbackSpy2).toHaveBeenCalledTimes(1);
+ expect(trackListenerRemoved1).toBe(true);
+ expect(trackCallbackSpy1).not.toHaveBeenCalled();
+ expect(trackCallbackSpy2).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('clearAllNotificationListeners', () => {
+ let logger: LoggerFacade;
+ let notificationCenterInstance: DefaultNotificationCenter;
+
+ beforeEach(() => {
+ logger = getMockLogger();
+ notificationCenterInstance = createNotificationCenter({ logger });
+ });
+
+ it('should remove all notification listeners for all types', () => {
+ const activateCallbackSpy1 = vi.fn();
+ const decisionCallbackSpy1 = vi.fn();
+ const logEventCallbackSpy1 = vi.fn();
+ const configUpdateCallbackSpy1 = vi.fn();
+ const trackCallbackSpy1 = vi.fn();
+ // add a listener for each notification type
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallbackSpy1
+ );
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ // remove all listeners
+ notificationCenterInstance.clearAllNotificationListeners();
+ // trigger send notifications
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {} as ActivateListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {} as DecisionListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {} as LogEventListenerPayload);
+ notificationCenterInstance.sendNotifications(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ ({} as unknown) as OptimizelyConfigUpdateListenerPayload
+ );
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {} as TrackListenerPayload);
+
+ expect(activateCallbackSpy1).not.toHaveBeenCalled();
+ expect(decisionCallbackSpy1).not.toHaveBeenCalled();
+ expect(logEventCallbackSpy1).not.toHaveBeenCalled();
+ expect(configUpdateCallbackSpy1).not.toHaveBeenCalled();
+ expect(trackCallbackSpy1).not.toHaveBeenCalled();
+ });
+});
+
+describe('clearNotificationListeners', () => {
+ let logger: LoggerFacade;
+ let notificationCenterInstance: DefaultNotificationCenter;
+
+ beforeEach(() => {
+ logger = getMockLogger();
+ notificationCenterInstance = createNotificationCenter({ logger });
+ });
+
+ it('should remove all notification listeners for the ACTIVATE type', () => {
+ const activateCallbackSpy1 = vi.fn();
+ const activateCallbackSpy2 = vi.fn();
+ //add 2 different listeners for ACTIVATE
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy2);
+ // remove ACTIVATE listeners
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.ACTIVATE);
+ // trigger send notifications
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {} as ActivateListenerPayload);
+
+ expect(activateCallbackSpy1).not.toHaveBeenCalled();
+ expect(activateCallbackSpy2).not.toHaveBeenCalled();
+ });
+
+ it('should remove all notification listeners for the DECISION type', () => {
+ const decisionCallbackSpy1 = vi.fn();
+ const decisionCallbackSpy2 = vi.fn();
+ //add 2 different listeners for DECISION
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy2);
+ // remove DECISION listeners
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.DECISION);
+ // trigger send notifications
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {} as DecisionListenerPayload);
+
+ expect(decisionCallbackSpy1).not.toHaveBeenCalled();
+ expect(decisionCallbackSpy2).not.toHaveBeenCalled();
+ });
+
+ it('should remove all notification listeners for the LOG_EVENT type', () => {
+ const logEventCallbackSpy1 = vi.fn();
+ const logEventCallbackSpy2 = vi.fn();
+ //add 2 different listeners for LOG_EVENT
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy2);
+ // remove LOG_EVENT listeners
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.LOG_EVENT);
+ // trigger send notifications
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {} as LogEventListenerPayload);
+
+ expect(logEventCallbackSpy1).not.toHaveBeenCalled();
+ expect(logEventCallbackSpy2).not.toHaveBeenCalled();
+ });
+
+ it('should remove all notification listeners for the OPTIMIZELY_CONFIG_UPDATE type', () => {
+ const configUpdateCallbackSpy1 = vi.fn();
+ const configUpdateCallbackSpy2 = vi.fn();
+ //add 2 different listeners for OPTIMIZELY_CONFIG_UPDATE
+ notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallbackSpy1
+ );
+ notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallbackSpy2
+ );
+ // remove OPTIMIZELY_CONFIG_UPDATE listeners
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE);
+ // trigger send notifications
+ notificationCenterInstance.sendNotifications(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ ({} as unknown) as OptimizelyConfigUpdateListenerPayload
+ );
+
+ expect(configUpdateCallbackSpy1).not.toHaveBeenCalled();
+ expect(configUpdateCallbackSpy2).not.toHaveBeenCalled();
+ });
+
+ it('should remove all notification listeners for the TRACK type', () => {
+ const trackCallbackSpy1 = vi.fn();
+ const trackCallbackSpy2 = vi.fn();
+ //add 2 different listeners for TRACK
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy2);
+ // remove TRACK listeners
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.TRACK);
+ // trigger send notifications
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {} as TrackListenerPayload);
+
+ expect(trackCallbackSpy1).not.toHaveBeenCalled();
+ expect(trackCallbackSpy2).not.toHaveBeenCalled();
+ });
+
+ it('should only remove ACTIVATE type listeners and not any other types', () => {
+ const activateCallbackSpy1 = vi.fn();
+ const activateCallbackSpy2 = vi.fn();
+ const decisionCallbackSpy1 = vi.fn();
+ const logEventCallbackSpy1 = vi.fn();
+ const configUpdateCallbackSpy1 = vi.fn();
+ const trackCallbackSpy1 = vi.fn();
+ //add 2 different listeners for ACTIVATE
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy2);
+ // add a listener for each notification type
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallbackSpy1
+ );
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ // remove only ACTIVATE type
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.ACTIVATE);
+ // trigger send notifications
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {} as ActivateListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {} as DecisionListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {} as LogEventListenerPayload);
+ notificationCenterInstance.sendNotifications(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ ({} as unknown) as OptimizelyConfigUpdateListenerPayload
+ );
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {} as TrackListenerPayload);
+
+ expect(activateCallbackSpy1).not.toHaveBeenCalled();
+ expect(activateCallbackSpy2).not.toHaveBeenCalled();
+ expect(decisionCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(logEventCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(configUpdateCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(trackCallbackSpy1).toHaveBeenCalledTimes(1);
+ });
+
+ it('should only remove DECISION type listeners and not any other types', () => {
+ const decisionCallbackSpy1 = vi.fn();
+ const decisionCallbackSpy2 = vi.fn();
+ const activateCallbackSpy1 = vi.fn();
+ const logEventCallbackSpy1 = vi.fn();
+ const configUpdateCallbackSpy1 = vi.fn();
+ const trackCallbackSpy1 = vi.fn();
+ // add 2 different listeners for DECISION
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy2);
+ // add a listener for each notification type
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallbackSpy1
+ );
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ // remove only DECISION type
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.DECISION);
+ // trigger send notifications
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {} as ActivateListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {} as DecisionListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {} as LogEventListenerPayload);
+ notificationCenterInstance.sendNotifications(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ ({} as unknown) as OptimizelyConfigUpdateListenerPayload
+ );
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {} as TrackListenerPayload);
+
+ expect(decisionCallbackSpy1).not.toHaveBeenCalled();
+ expect(decisionCallbackSpy2).not.toHaveBeenCalled();
+ expect(activateCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(logEventCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(configUpdateCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(trackCallbackSpy1).toHaveBeenCalledTimes(1);
+ });
+
+ it('should only remove LOG_EVENT type listeners and not any other types', () => {
+ const logEventCallbackSpy1 = vi.fn();
+ const logEventCallbackSpy2 = vi.fn();
+ const activateCallbackSpy1 = vi.fn();
+ const decisionCallbackSpy1 = vi.fn();
+ const configUpdateCallbackSpy1 = vi.fn();
+ const trackCallbackSpy1 = vi.fn();
+ // add 2 different listeners for LOG_EVENT
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy2);
+ // add a listener for each notification type
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallbackSpy1
+ );
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ // remove only LOG_EVENT type
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.LOG_EVENT);
+ // trigger send notifications
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {} as ActivateListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {} as DecisionListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {} as LogEventListenerPayload);
+ notificationCenterInstance.sendNotifications(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ ({} as unknown) as OptimizelyConfigUpdateListenerPayload
+ );
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {} as TrackListenerPayload);
+
+ expect(logEventCallbackSpy1).not.toHaveBeenCalled();
+ expect(logEventCallbackSpy2).not.toHaveBeenCalled();
+ expect(activateCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(decisionCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(configUpdateCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(trackCallbackSpy1).toHaveBeenCalledTimes(1);
+ });
+
+ it('should only remove OPTIMIZELY_CONFIG_UPDATE type listeners and not any other types', () => {
+ const configUpdateCallbackSpy1 = vi.fn();
+ const configUpdateCallbackSpy2 = vi.fn();
+ const activateCallbackSpy1 = vi.fn();
+ const decisionCallbackSpy1 = vi.fn();
+ const logEventCallbackSpy1 = vi.fn();
+ const trackCallbackSpy1 = vi.fn();
+ // add 2 different listeners for OPTIMIZELY_CONFIG_UPDATE
+ notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallbackSpy1
+ );
+ notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallbackSpy2
+ );
+ // add a listener for each notification type
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ // remove only OPTIMIZELY_CONFIG_UPDATE type
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE);
+ // trigger send notifications
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {} as ActivateListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {} as DecisionListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {} as LogEventListenerPayload);
+ notificationCenterInstance.sendNotifications(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ ({} as unknown) as OptimizelyConfigUpdateListenerPayload
+ );
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {} as TrackListenerPayload);
+
+ expect(configUpdateCallbackSpy1).not.toHaveBeenCalled();
+ expect(configUpdateCallbackSpy2).not.toHaveBeenCalled();
+ expect(activateCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(decisionCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(logEventCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(trackCallbackSpy1).toHaveBeenCalledTimes(1);
+ });
+
+ it('should only remove TRACK type listeners and not any other types', () => {
+ const trackCallbackSpy1 = vi.fn();
+ const trackCallbackSpy2 = vi.fn();
+ const activateCallbackSpy1 = vi.fn();
+ const decisionCallbackSpy1 = vi.fn();
+ const logEventCallbackSpy1 = vi.fn();
+ const configUpdateCallbackSpy1 = vi.fn();
+ // add 2 different listeners for TRACK
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy2);
+ // add a listener for each notification type
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallbackSpy1
+ );
+ // remove only TRACK type
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.TRACK);
+ // trigger send notifications
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {} as ActivateListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {} as DecisionListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {} as LogEventListenerPayload);
+ notificationCenterInstance.sendNotifications(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ ({} as unknown) as OptimizelyConfigUpdateListenerPayload
+ );
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {} as TrackListenerPayload);
+
+ expect(trackCallbackSpy1).not.toHaveBeenCalled();
+ expect(trackCallbackSpy2).not.toHaveBeenCalled();
+ expect(activateCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(decisionCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(logEventCallbackSpy1).toHaveBeenCalledTimes(1);
+ expect(configUpdateCallbackSpy1).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('sendNotifications', () => {
+ let logger: LoggerFacade;
+ let notificationCenterInstance: DefaultNotificationCenter;
+
+ beforeEach(() => {
+ logger = getMockLogger();
+ notificationCenterInstance = createNotificationCenter({ logger });
+ });
+ it('should call the listener callback with exact arguments', () => {
+ const activateCallbackSpy1 = vi.fn();
+ const decisionCallbackSpy1 = vi.fn();
+ const logEventCallbackSpy1 = vi.fn();
+ const configUpdateCallbackSpy1 = vi.fn();
+ const trackCallbackSpy1 = vi.fn();
+ // listener object data for each type
+ const activateData = {
+ experiment: {},
+ userId: '',
+ attributes: {},
+ variation: {},
+ logEvent: {},
+ };
+ const decisionData = {
+ type: '',
+ userId: 'use1',
+ attributes: {},
+ decisionInfo: {},
+ };
+ const logEventData = {
+ url: '',
+ httpVerb: '',
+ params: {},
+ };
+ const configUpdateData = {};
+ const trackData = {
+ eventKey: '',
+ userId: '',
+ attributes: {},
+ eventTags: {},
+ };
+ // add listeners
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ configUpdateCallbackSpy1
+ );
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ // send notifications
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, activateData as ActivateListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, decisionData as DecisionListenerPayload);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, logEventData as LogEventListenerPayload);
+ notificationCenterInstance.sendNotifications(
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ (configUpdateData as unknown) as OptimizelyConfigUpdateListenerPayload
+ );
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, trackData as TrackListenerPayload);
+
+ expect(activateCallbackSpy1).toHaveBeenCalledWith(activateData);
+ expect(decisionCallbackSpy1).toHaveBeenCalledWith(decisionData);
+ expect(logEventCallbackSpy1).toHaveBeenCalledWith(logEventData);
+ expect(configUpdateCallbackSpy1).toHaveBeenCalledWith(configUpdateData);
+ expect(trackCallbackSpy1).toHaveBeenCalledWith(trackData);
+ });
+});
diff --git a/packages/optimizely-sdk/lib/core/notification_center/index.tests.js b/lib/notification_center/index.tests.js
similarity index 59%
rename from packages/optimizely-sdk/lib/core/notification_center/index.tests.js
rename to lib/notification_center/index.tests.js
index 79dc2fd5f..11e6da2bb 100644
--- a/packages/optimizely-sdk/lib/core/notification_center/index.tests.js
+++ b/lib/notification_center/index.tests.js
@@ -1,45 +1,49 @@
-/****************************************************************************
- * Copyright 2020, 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. *
- * 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 2020, 2024, 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.
+ */
import sinon from 'sinon';
import { assert } from 'chai';
import { createNotificationCenter } from './';
-import * as enums from '../../utils/enums';
-import { createLogger } from '../../plugins/logger';
-import errorHandler from '../../plugins/error_handler';
+import * as enums from '../utils/enums';
+import { NOTIFICATION_TYPES } from './type';
var LOG_LEVEL = enums.LOG_LEVEL;
+var createLogger = () => ({
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => createLogger(),
+})
+
describe('lib/core/notification_center', function() {
describe('APIs', function() {
var mockLogger = createLogger({ logLevel: LOG_LEVEL.INFO });
- var mockErrorHandler = errorHandler.handleError;
var mockLoggerStub;
- var mockErrorHandlerStub;
+
var notificationCenterInstance;
var sandbox;
beforeEach(function() {
sandbox = sinon.sandbox.create();
mockLoggerStub = sandbox.stub(mockLogger, 'log');
- mockErrorHandlerStub = sandbox.stub(mockErrorHandler, 'handleError');
notificationCenterInstance = createNotificationCenter({
logger: mockLoggerStub,
- errorHandler: mockErrorHandlerStub,
});
});
@@ -62,47 +66,6 @@ describe('lib/core/notification_center', function() {
});
context('the listener type is a valid type', function() {
- it('should return -1 if that same callback is already added', function() {
- var activateCallback;
- var decisionCallback;
- var logEventCallback;
- var configUpdateCallback;
- var trackCallback;
- // add a listener for each type
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.ACTIVATE, activateCallback);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionCallback);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventCallback);
- notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
- configUpdateCallback
- );
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.TRACK, trackCallback);
- // assertions
- assert.strictEqual(
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.ACTIVATE, activateCallback),
- -1
- );
- assert.strictEqual(
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionCallback),
- -1
- );
- assert.strictEqual(
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventCallback),
- -1
- );
- assert.strictEqual(
- notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
- configUpdateCallback
- ),
- -1
- );
- assert.strictEqual(
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.TRACK, trackCallback),
- -1
- );
- });
-
it('should return an id (listenerId) > 0 of the notification listener if callback is not already added', function() {
var activateCallback;
var decisionCallback;
@@ -111,23 +74,23 @@ describe('lib/core/notification_center', function() {
var trackCallback;
// store a listenerId for each type
var activateListenerId = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.ACTIVATE,
+ NOTIFICATION_TYPES.ACTIVATE,
activateCallback
);
var decisionListenerId = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.DECISION,
+ NOTIFICATION_TYPES.DECISION,
decisionCallback
);
var logEventListenerId = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.LOG_EVENT,
+ NOTIFICATION_TYPES.LOG_EVENT,
logEventCallback
);
var configUpdateListenerId = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallback
);
var trackListenerId = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.TRACK,
+ NOTIFICATION_TYPES.TRACK,
trackCallback
);
// assertions
@@ -157,23 +120,23 @@ describe('lib/core/notification_center', function() {
var trackCallback;
// add listeners for each type
var activateListenerId = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.ACTIVATE,
+ NOTIFICATION_TYPES.ACTIVATE,
activateCallback
);
var decisionListenerId = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.DECISION,
+ NOTIFICATION_TYPES.DECISION,
decisionCallback
);
var logEventListenerId = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.LOG_EVENT,
+ NOTIFICATION_TYPES.LOG_EVENT,
logEventCallback
);
var configListenerId = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallback
);
var trackListenerId = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.TRACK,
+ NOTIFICATION_TYPES.TRACK,
trackCallback
);
// remove listeners for each type
@@ -204,34 +167,34 @@ describe('lib/core/notification_center', function() {
var trackCallbackSpy2 = sinon.spy();
// register listeners for each type
var activateListenerId1 = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.ACTIVATE,
+ NOTIFICATION_TYPES.ACTIVATE,
activateCallbackSpy1
);
var decisionListenerId1 = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.DECISION,
+ NOTIFICATION_TYPES.DECISION,
decisionCallbackSpy1
);
var logeventlistenerId1 = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.LOG_EVENT,
+ NOTIFICATION_TYPES.LOG_EVENT,
logEventCallbackSpy1
);
var configUpdateListenerId1 = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallbackSpy1
);
var trackListenerId1 = notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.TRACK,
+ NOTIFICATION_TYPES.TRACK,
trackCallbackSpy1
);
// register second listeners for each type
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy2);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionCallbackSpy2);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy2);
notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallbackSpy2
);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.TRACK, trackCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy2);
// remove first listener
var activateListenerRemoved1 = notificationCenterInstance.removeNotificationListener(activateListenerId1);
var decisionListenerRemoved1 = notificationCenterInstance.removeNotificationListener(decisionListenerId1);
@@ -241,11 +204,11 @@ describe('lib/core/notification_center', function() {
);
var trackListenerRemoved1 = notificationCenterInstance.removeNotificationListener(trackListenerId1);
// send notifications
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.ACTIVATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.DECISION, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.LOG_EVENT, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.TRACK, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {});
// Assertions
assert.strictEqual(activateListenerRemoved1, true);
sinon.assert.notCalled(activateCallbackSpy1);
@@ -274,22 +237,22 @@ describe('lib/core/notification_center', function() {
var configUpdateCallbackSpy1 = sinon.spy();
var trackCallbackSpy1 = sinon.spy();
// add a listener for each notification type
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallbackSpy1
);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
// remove all listeners
notificationCenterInstance.clearAllNotificationListeners();
// trigger send notifications
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.ACTIVATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.DECISION, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.LOG_EVENT, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.TRACK, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {});
// check that none of the now removed listeners were called
sinon.assert.notCalled(activateCallbackSpy1);
sinon.assert.notCalled(decisionCallbackSpy1);
@@ -305,12 +268,12 @@ describe('lib/core/notification_center', function() {
var activateCallbackSpy1 = sinon.spy();
var activateCallbackSpy2 = sinon.spy();
//add 2 different listeners for ACTIVATE
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy2);
// remove ACTIVATE listeners
- notificationCenterInstance.clearNotificationListeners(enums.NOTIFICATION_TYPES.ACTIVATE);
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.ACTIVATE);
// trigger send notifications
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.ACTIVATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {});
// check that none of the ACTIVATE listeners were called
sinon.assert.notCalled(activateCallbackSpy1);
sinon.assert.notCalled(activateCallbackSpy2);
@@ -320,12 +283,12 @@ describe('lib/core/notification_center', function() {
var decisionCallbackSpy1 = sinon.spy();
var decisionCallbackSpy2 = sinon.spy();
//add 2 different listeners for DECISION
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy2);
// remove DECISION listeners
- notificationCenterInstance.clearAllNotificationListeners(enums.NOTIFICATION_TYPES.DECISION);
+ notificationCenterInstance.clearAllNotificationListeners(NOTIFICATION_TYPES.DECISION);
// trigger send notifications
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.DECISION, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {});
// check that none of the DECISION listeners were called
sinon.assert.notCalled(decisionCallbackSpy1);
sinon.assert.notCalled(decisionCallbackSpy2);
@@ -335,12 +298,12 @@ describe('lib/core/notification_center', function() {
var logEventCallbackSpy1 = sinon.spy();
var logEventCallbackSpy2 = sinon.spy();
//add 2 different listeners for LOG_EVENT
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy2);
// remove LOG_EVENT listeners
- notificationCenterInstance.clearAllNotificationListeners(enums.NOTIFICATION_TYPES.LOG_EVENT);
+ notificationCenterInstance.clearAllNotificationListeners(NOTIFICATION_TYPES.LOG_EVENT);
// trigger send notifications
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.LOG_EVENT, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {});
// check that none of the LOG_EVENT listeners were called
sinon.assert.notCalled(logEventCallbackSpy1);
sinon.assert.notCalled(logEventCallbackSpy2);
@@ -351,17 +314,17 @@ describe('lib/core/notification_center', function() {
var configUpdateCallbackSpy2 = sinon.spy();
//add 2 different listeners for OPTIMIZELY_CONFIG_UPDATE
notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallbackSpy1
);
notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallbackSpy2
);
// remove OPTIMIZELY_CONFIG_UPDATE listeners
- notificationCenterInstance.clearAllNotificationListeners(enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE);
+ notificationCenterInstance.clearAllNotificationListeners(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE);
// trigger send notifications
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
// check that none of the OPTIMIZELY_CONFIG_UPDATE listeners were called
sinon.assert.notCalled(configUpdateCallbackSpy1);
sinon.assert.notCalled(configUpdateCallbackSpy2);
@@ -371,12 +334,12 @@ describe('lib/core/notification_center', function() {
var trackCallbackSpy1 = sinon.spy();
var trackCallbackSpy2 = sinon.spy();
//add 2 different listeners for TRACK
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.TRACK, trackCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy2);
// remove TRACK listeners
- notificationCenterInstance.clearAllNotificationListeners(enums.NOTIFICATION_TYPES.TRACK);
+ notificationCenterInstance.clearAllNotificationListeners(NOTIFICATION_TYPES.TRACK);
// trigger send notifications
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.TRACK, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {});
// check that none of the TRACK listeners were called
sinon.assert.notCalled(trackCallbackSpy1);
sinon.assert.notCalled(trackCallbackSpy2);
@@ -392,24 +355,24 @@ describe('lib/core/notification_center', function() {
var configUpdateCallbackSpy1 = sinon.spy();
var trackCallbackSpy1 = sinon.spy();
//add 2 different listeners for ACTIVATE
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy2);
// add a listener for each notification type
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallbackSpy1
);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
// remove only ACTIVATE type
- notificationCenterInstance.clearNotificationListeners(enums.NOTIFICATION_TYPES.ACTIVATE);
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.ACTIVATE);
// trigger send notifications
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.ACTIVATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.DECISION, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.LOG_EVENT, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.TRACK, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {});
// check that ACTIVATE listeners were note called
sinon.assert.notCalled(activateCallbackSpy1);
sinon.assert.notCalled(activateCallbackSpy2);
@@ -428,24 +391,24 @@ describe('lib/core/notification_center', function() {
var configUpdateCallbackSpy1 = sinon.spy();
var trackCallbackSpy1 = sinon.spy();
// add 2 different listeners for DECISION
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy2);
// add a listener for each notification type
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallbackSpy1
);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
// remove only DECISION type
- notificationCenterInstance.clearNotificationListeners(enums.NOTIFICATION_TYPES.DECISION);
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.DECISION);
// trigger send notifications
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.ACTIVATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.DECISION, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.LOG_EVENT, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.TRACK, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {});
// check that DECISION listeners were not called
sinon.assert.notCalled(decisionCallbackSpy1);
sinon.assert.notCalled(decisionCallbackSpy2);
@@ -464,24 +427,24 @@ describe('lib/core/notification_center', function() {
var configUpdateCallbackSpy1 = sinon.spy();
var trackCallbackSpy1 = sinon.spy();
// add 2 different listeners for LOG_EVENT
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy2);
// add a listener for each notification type
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallbackSpy1
);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
// remove only LOG_EVENT type
- notificationCenterInstance.clearNotificationListeners(enums.NOTIFICATION_TYPES.LOG_EVENT);
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.LOG_EVENT);
// trigger send notifications
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.ACTIVATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.DECISION, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.LOG_EVENT, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.TRACK, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {});
// check that LOG_EVENT listeners were not called
sinon.assert.notCalled(logEventCallbackSpy1);
sinon.assert.notCalled(logEventCallbackSpy2);
@@ -501,26 +464,26 @@ describe('lib/core/notification_center', function() {
var trackCallbackSpy1 = sinon.spy();
// add 2 different listeners for OPTIMIZELY_CONFIG_UPDATE
notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallbackSpy1
);
notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallbackSpy2
);
// add a listener for each notification type
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
// remove only OPTIMIZELY_CONFIG_UPDATE type
- notificationCenterInstance.clearNotificationListeners(enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE);
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE);
// trigger send notifications
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.ACTIVATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.DECISION, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.LOG_EVENT, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.TRACK, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {});
// check that OPTIMIZELY_CONFIG_UPDATE listeners were not called
sinon.assert.notCalled(configUpdateCallbackSpy1);
sinon.assert.notCalled(configUpdateCallbackSpy2);
@@ -539,24 +502,24 @@ describe('lib/core/notification_center', function() {
var logEventCallbackSpy1 = sinon.spy();
var configUpdateCallbackSpy1 = sinon.spy();
// add 2 different listeners for TRACK
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.TRACK, trackCallbackSpy2);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy2);
// add a listener for each notification type
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallbackSpy1
);
// remove only TRACK type
- notificationCenterInstance.clearNotificationListeners(enums.NOTIFICATION_TYPES.TRACK);
+ notificationCenterInstance.clearNotificationListeners(NOTIFICATION_TYPES.TRACK);
// trigger send notifications
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.ACTIVATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.DECISION, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.LOG_EVENT, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.TRACK, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, {});
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, {});
// check that TRACK listeners were not called
sinon.assert.notCalled(trackCallbackSpy1);
sinon.assert.notCalled(trackCallbackSpy2);
@@ -604,23 +567,23 @@ describe('lib/core/notification_center', function() {
eventTags: {},
};
// add listeners
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.ACTIVATE, activateCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.DECISION, decisionCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.LOG_EVENT, logEventCallbackSpy1);
notificationCenterInstance.addNotificationListener(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateCallbackSpy1
);
- notificationCenterInstance.addNotificationListener(enums.NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
+ notificationCenterInstance.addNotificationListener(NOTIFICATION_TYPES.TRACK, trackCallbackSpy1);
// send notifications
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.ACTIVATE, activateData);
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.DECISION, decisionData);
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.LOG_EVENT, logEventData);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, activateData);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.DECISION, decisionData);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.LOG_EVENT, logEventData);
notificationCenterInstance.sendNotifications(
- enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
configUpdateData
);
- notificationCenterInstance.sendNotifications(enums.NOTIFICATION_TYPES.TRACK, trackData);
+ notificationCenterInstance.sendNotifications(NOTIFICATION_TYPES.TRACK, trackData);
// assertions
sinon.assert.calledWithExactly(activateCallbackSpy1, activateData);
sinon.assert.calledWithExactly(decisionCallbackSpy1, decisionData);
diff --git a/lib/notification_center/index.ts b/lib/notification_center/index.ts
new file mode 100644
index 000000000..7b17ba658
--- /dev/null
+++ b/lib/notification_center/index.ts
@@ -0,0 +1,164 @@
+/**
+ * Copyright 2020, 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.
+ * 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.
+ */
+import { LoggerFacade } from '../logging/logger';
+import { objectValues } from '../utils/fns';
+
+import { NOTIFICATION_TYPES } from './type';
+import { NotificationType, NotificationPayload } from './type';
+import { Consumer, Fn } from '../utils/type';
+import { EventEmitter } from '../utils/event_emitter/event_emitter';
+import { NOTIFICATION_LISTENER_EXCEPTION } from 'error_message';
+import { ErrorReporter } from '../error/error_reporter';
+import { ErrorNotifier } from '../error/error_notifier';
+
+interface NotificationCenterOptions {
+ logger?: LoggerFacade;
+ errorNotifier?: ErrorNotifier;
+}
+
+export interface NotificationCenter {
+ addNotificationListener(
+ notificationType: N,
+ callback: Consumer
+ ): number
+ removeNotificationListener(listenerId: number): boolean;
+ clearAllNotificationListeners(): void;
+ clearNotificationListeners(notificationType: NotificationType): void;
+}
+
+export interface NotificationSender {
+ sendNotifications(
+ notificationType: N,
+ notificationData: NotificationPayload[N]
+ ): void;
+}
+
+/**
+ * NotificationCenter allows registration and triggering of callback functions using
+ * notification event types defined in NOTIFICATION_TYPES of utils/enums/index.js:
+ * - ACTIVATE: An impression event will be sent to Optimizely.
+ * - TRACK a conversion event will be sent to Optimizely
+ */
+export class DefaultNotificationCenter implements NotificationCenter, NotificationSender {
+ private errorReporter: ErrorReporter;
+
+ private removerId = 1;
+ private eventEmitter: EventEmitter = new EventEmitter();
+ private removers: Map = new Map();
+
+ /**
+ * @constructor
+ * @param {NotificationCenterOptions} options
+ * @param {LogHandler} options.logger An instance of a logger to log messages with
+ * @param {ErrorHandler} options.errorHandler An instance of errorHandler to handle any unexpected error
+ */
+ constructor(options: NotificationCenterOptions) {
+ this.errorReporter = new ErrorReporter(options.logger, options.errorNotifier);
+ }
+
+ /**
+ * Add a notification callback to the notification center
+ * @param {string} notificationType One of the values from NOTIFICATION_TYPES in utils/enums/index.js
+ * @param {NotificationListener} callback Function that will be called when the event is triggered
+ * @returns {number} If the callback was successfully added, returns a listener ID which can be used
+ * to remove the callback by calling removeNotificationListener. The ID is a number greater than 0.
+ * If there was an error and the listener was not added, addNotificationListener returns -1. This
+ * can happen if the first argument is not a valid notification type, or if the same callback
+ * function was already added as a listener by a prior call to this function.
+ */
+ addNotificationListener(
+ notificationType: N,
+ callback: Consumer
+ ): number {
+ const notificationTypeValues: string[] = objectValues(NOTIFICATION_TYPES);
+ const isNotificationTypeValid = notificationTypeValues.indexOf(notificationType) > -1;
+ if (!isNotificationTypeValid) {
+ return -1;
+ }
+
+ const returnId = this.removerId++;
+ const remover = this.eventEmitter.on(
+ notificationType, this.wrapWithErrorReporting(notificationType, callback));
+ this.removers.set(returnId, remover);
+ return returnId;
+ }
+
+ private wrapWithErrorReporting(
+ notificationType: N,
+ callback: Consumer
+ ): Consumer {
+ return (notificationData: NotificationPayload[N]) => {
+ try {
+ callback(notificationData);
+ } catch (ex: any) {
+ const message = ex instanceof Error ? ex.message : String(ex);
+ this.errorReporter.report(NOTIFICATION_LISTENER_EXCEPTION, notificationType, message);
+ }
+ };
+ }
+
+ /**
+ * Remove a previously added notification callback
+ * @param {number} listenerId ID of listener to be removed
+ * @returns {boolean} Returns true if the listener was found and removed, and false
+ * otherwise.
+ */
+ removeNotificationListener(listenerId: number): boolean {
+ const remover = this.removers.get(listenerId);
+ if (remover) {
+ remover();
+ return true;
+ }
+ return false
+ }
+
+ /**
+ * Removes all previously added notification listeners, for all notification types
+ */
+ clearAllNotificationListeners(): void {
+ this.eventEmitter.removeAllListeners();
+ }
+
+ /**
+ * Remove all previously added notification listeners for the argument type
+ * @param {NotificationType} notificationType One of NotificationType
+ */
+ clearNotificationListeners(notificationType: NotificationType): void {
+ this.eventEmitter.removeListeners(notificationType);
+ }
+
+ /**
+ * Fires notifications for the argument type. All registered callbacks for this type will be
+ * called. The notificationData object will be passed on to callbacks called.
+ * @param {NotificationType} notificationType One of NotificationType
+ * @param {Object} notificationData Will be passed to callbacks called
+ */
+ sendNotifications(
+ notificationType: N,
+ notificationData: NotificationPayload[N]
+ ): void {
+ this.eventEmitter.emit(notificationType, notificationData);
+ }
+}
+
+/**
+ * Create an instance of NotificationCenter
+ * @param {NotificationCenterOptions} options
+ * @returns {NotificationCenter} An instance of NotificationCenter
+ */
+export function createNotificationCenter(options: NotificationCenterOptions): DefaultNotificationCenter {
+ return new DefaultNotificationCenter(options);
+}
diff --git a/lib/notification_center/type.ts b/lib/notification_center/type.ts
new file mode 100644
index 000000000..01adc56e5
--- /dev/null
+++ b/lib/notification_center/type.ts
@@ -0,0 +1,153 @@
+/**
+ * Copyright 2024-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.
+ */
+
+import { LogEvent } from '../event_processor/event_dispatcher/event_dispatcher';
+import {
+ EventTags,
+ Experiment,
+ FeatureVariableValue,
+ Holdout,
+ UserAttributes,
+ VariableType,
+ Variation,
+} from '../shared_types';
+import { DecisionSource } from '../utils/enums';
+import { Nullable } from '../utils/type';
+import { holdout, IfActive } from '../feature_toggle';
+
+export type UserEventListenerPayload = {
+ userId: string;
+ attributes?: UserAttributes;
+}
+
+export type ActivateListenerPayload = UserEventListenerPayload & {
+ experiment: Experiment | null;
+ holdout: IfActive;
+ variation: Variation | null;
+ logEvent: LogEvent;
+}
+
+export type TrackListenerPayload = UserEventListenerPayload & {
+ eventKey: string;
+ eventTags?: EventTags;
+ logEvent: LogEvent;
+}
+
+export const DECISION_NOTIFICATION_TYPES = {
+ AB_TEST: 'ab-test',
+ FEATURE: 'feature',
+ FEATURE_TEST: 'feature-test',
+ FEATURE_VARIABLE: 'feature-variable',
+ ALL_FEATURE_VARIABLES: 'all-feature-variables',
+ FLAG: 'flag',
+} as const;
+
+
+export type DecisionNotificationType = typeof DECISION_NOTIFICATION_TYPES[keyof typeof DECISION_NOTIFICATION_TYPES];
+
+export type ExperimentAndVariationInfo = {
+ experimentKey: string;
+ variationKey: string;
+}
+
+export type DecisionSourceInfo = Partial;
+
+export type AbTestDecisonInfo = Nullable;
+
+type FeatureDecisionInfo = {
+ featureKey: string,
+ featureEnabled: boolean,
+ source: DecisionSource,
+ sourceInfo: DecisionSourceInfo,
+}
+
+export type FeatureTestDecisionInfo = Nullable;
+
+export type FeatureVariableDecisionInfo = {
+ featureKey: string,
+ featureEnabled: boolean,
+ source: DecisionSource,
+ variableKey: string,
+ variableValue: FeatureVariableValue,
+ variableType: VariableType,
+ sourceInfo: DecisionSourceInfo,
+};
+
+export type VariablesMap = { [variableKey: string]: unknown }
+
+export type AllFeatureVariablesDecisionInfo = {
+ featureKey: string,
+ featureEnabled: boolean,
+ source: DecisionSource,
+ variableValues: VariablesMap,
+ sourceInfo: DecisionSourceInfo,
+};
+
+export type FlagDecisionInfo = {
+ flagKey: string,
+ enabled: boolean,
+ variationKey: string | null,
+ ruleKey: string | null,
+ variables: VariablesMap,
+ reasons: string[],
+ decisionEventDispatched: boolean,
+ experimentId: string | null,
+ variationId: string | null,
+};
+
+export type DecisionInfo = {
+ [DECISION_NOTIFICATION_TYPES.AB_TEST]: AbTestDecisonInfo;
+ [DECISION_NOTIFICATION_TYPES.FEATURE]: FeatureDecisionInfo;
+ [DECISION_NOTIFICATION_TYPES.FEATURE_TEST]: FeatureTestDecisionInfo;
+ [DECISION_NOTIFICATION_TYPES.FEATURE_VARIABLE]: FeatureVariableDecisionInfo;
+ [DECISION_NOTIFICATION_TYPES.ALL_FEATURE_VARIABLES]: AllFeatureVariablesDecisionInfo;
+ [DECISION_NOTIFICATION_TYPES.FLAG]: FlagDecisionInfo;
+}
+
+export type DecisionListenerPayloadForType = UserEventListenerPayload & {
+ type: T;
+ decisionInfo: DecisionInfo[T];
+}
+
+export type DecisionListenerPayload = {
+ [T in DecisionNotificationType]: DecisionListenerPayloadForType;
+}[DecisionNotificationType];
+
+export type LogEventListenerPayload = LogEvent;
+
+export type OptimizelyConfigUpdateListenerPayload = undefined;
+
+export type NotificationPayload = {
+ ACTIVATE: ActivateListenerPayload;
+ DECISION: DecisionListenerPayload;
+ TRACK: TrackListenerPayload;
+ LOG_EVENT: LogEventListenerPayload;
+ OPTIMIZELY_CONFIG_UPDATE: OptimizelyConfigUpdateListenerPayload;
+};
+
+export type NotificationType = keyof NotificationPayload;
+
+export type NotificationTypeValues = {
+ [key in NotificationType]: key;
+}
+
+export const NOTIFICATION_TYPES: NotificationTypeValues = {
+ ACTIVATE: 'ACTIVATE',
+ DECISION: 'DECISION',
+ LOG_EVENT: 'LOG_EVENT',
+ OPTIMIZELY_CONFIG_UPDATE: 'OPTIMIZELY_CONFIG_UPDATE',
+ TRACK: 'TRACK',
+};
diff --git a/lib/odp/constant.ts b/lib/odp/constant.ts
new file mode 100644
index 000000000..c33f3f0c9
--- /dev/null
+++ b/lib/odp/constant.ts
@@ -0,0 +1,28 @@
+/**
+ * Copyright 2024, 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.
+ */
+
+export enum ODP_USER_KEY {
+ VUID = 'vuid',
+ FS_USER_ID = 'fs_user_id',
+ FS_USER_ID_ALIAS = 'fs-user-id',
+}
+
+export enum ODP_EVENT_ACTION {
+ IDENTIFIED = 'identified',
+ INITIALIZED = 'client_initialized',
+}
+
+export const ODP_DEFAULT_EVENT_TYPE = 'fullstack';
diff --git a/packages/optimizely-sdk/lib/plugins/odp/odp_event.ts b/lib/odp/event_manager/odp_event.ts
similarity index 89%
rename from packages/optimizely-sdk/lib/plugins/odp/odp_event.ts
rename to lib/odp/event_manager/odp_event.ts
index 4260cd30d..062798d1b 100644
--- a/packages/optimizely-sdk/lib/plugins/odp/odp_event.ts
+++ b/lib/odp/event_manager/odp_event.ts
@@ -1,5 +1,5 @@
/**
- * Copyright 2022, Optimizely
+ * Copyright 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.
@@ -18,22 +18,22 @@ export class OdpEvent {
/**
* Type of event (typically "fullstack")
*/
- public type: string;
+ type: string;
/**
* Subcategory of the event type
*/
- public action: string;
+ action: string;
/**
* Key-value map of user identifiers
*/
- public identifiers: Map;
+ identifiers: Map;
/**
* Event data in a key-value map
*/
- public data: Map;
+ data: Map;
/**
* Event to be sent and stored in the Optimizely Data Platform
diff --git a/lib/odp/event_manager/odp_event_api_manager.spec.ts b/lib/odp/event_manager/odp_event_api_manager.spec.ts
new file mode 100644
index 000000000..04d74ea18
--- /dev/null
+++ b/lib/odp/event_manager/odp_event_api_manager.spec.ts
@@ -0,0 +1,225 @@
+/**
+ * Copyright 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.
+ * 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.
+ */
+import { describe, it, expect, vi } from 'vitest';
+
+import { DefaultOdpEventApiManager, eventApiRequestGenerator, LOGGER_NAME, pixelApiRequestGenerator } from './odp_event_api_manager';
+import { OdpEvent } from './odp_event';
+import { OdpConfig } from '../odp_config';
+
+const data1 = new Map();
+data1.set('key11', 'value-1');
+data1.set('key12', true);
+data1.set('key13', 3.5);
+data1.set('key14', null);
+
+const data2 = new Map();
+
+data2.set('key2', 'value-2');
+
+const ODP_EVENTS = [
+ new OdpEvent('t1', 'a1', new Map([['id-key-1', 'id-value-1']]), data1),
+ new OdpEvent('t2', 'a2', new Map([['id-key-2', 'id-value-2']]), data2),
+];
+
+const API_KEY = 'test-api-key';
+const API_HOST = 'https://odp.example.com/';
+const PIXEL_URL = 'https://odp.pixel.com/';
+
+const odpConfig = new OdpConfig(API_KEY, API_HOST, PIXEL_URL, []);
+
+import { getMockRequestHandler } from '../../tests/mock/mock_request_handler';
+import { getMockLogger } from '../../tests/mock/mock_logger';
+
+describe('DefaultOdpEventApiManager', () => {
+ it('should set name on the logger passed into the constructor', () => {
+ const logger = getMockLogger();
+ const requestHandler = getMockRequestHandler();
+
+ const manager = new DefaultOdpEventApiManager(requestHandler, vi.fn(), logger);
+
+ expect(logger.setName).toHaveBeenCalledWith(LOGGER_NAME);
+ });
+
+ it('should set name on the logger set by setLogger', () => {
+ const logger = getMockLogger();
+ const requestHandler = getMockRequestHandler();
+
+ const manager = new DefaultOdpEventApiManager(requestHandler, vi.fn());
+ manager.setLogger(logger);
+
+ expect(logger.setName).toHaveBeenCalledWith(LOGGER_NAME);
+ });
+
+ it('should generate the event request using the correct odp config and event', async () => {
+ const mockRequestHandler = getMockRequestHandler();
+ mockRequestHandler.makeRequest.mockReturnValue({
+ responsePromise: Promise.resolve({
+ statusCode: 200,
+ body: '',
+ headers: {},
+ }),
+ });
+ const requestGenerator = vi.fn().mockReturnValue({
+ method: 'PATCH',
+ endpoint: 'https://odp.example.com/v3/events',
+ headers: {
+ 'x-api-key': 'test-api',
+ },
+ data: 'event-data',
+ });
+
+ const manager = new DefaultOdpEventApiManager(mockRequestHandler, requestGenerator);
+ manager.sendEvents(odpConfig, ODP_EVENTS);
+
+ expect(requestGenerator.mock.calls[0][0]).toEqual(odpConfig);
+ expect(requestGenerator.mock.calls[0][1]).toEqual(ODP_EVENTS);
+ });
+
+ it('should send the correct request using the request handler', async () => {
+ const mockRequestHandler = getMockRequestHandler();
+ mockRequestHandler.makeRequest.mockReturnValue({
+ responsePromise: Promise.resolve({
+ statusCode: 200,
+ body: '',
+ headers: {},
+ }),
+ });
+ const requestGenerator = vi.fn().mockReturnValue({
+ method: 'PATCH',
+ endpoint: 'https://odp.example.com/v3/events',
+ headers: {
+ 'x-api-key': 'test-api',
+ },
+ data: 'event-data',
+ });
+
+ const manager = new DefaultOdpEventApiManager(mockRequestHandler, requestGenerator);
+ manager.sendEvents(odpConfig, ODP_EVENTS);
+
+ expect(mockRequestHandler.makeRequest.mock.calls[0][0]).toEqual('https://odp.example.com/v3/events');
+ expect(mockRequestHandler.makeRequest.mock.calls[0][1]).toEqual({
+ 'x-api-key': 'test-api',
+ });
+ expect(mockRequestHandler.makeRequest.mock.calls[0][2]).toEqual('PATCH');
+ expect(mockRequestHandler.makeRequest.mock.calls[0][3]).toEqual('event-data');
+ });
+
+ it('should return a promise that fails if the requestHandler response promise fails', async () => {
+ const mockRequestHandler = getMockRequestHandler();
+ mockRequestHandler.makeRequest.mockReturnValue({
+ responsePromise: Promise.reject(new Error('REQUEST_FAILED')),
+ });
+ const requestGenerator = vi.fn().mockReturnValue({
+ method: 'PATCH',
+ endpoint: 'https://odp.example.com/v3/events',
+ headers: {
+ 'x-api-key': 'test-api',
+ },
+ data: 'event-data',
+ });
+
+ const manager = new DefaultOdpEventApiManager(mockRequestHandler, requestGenerator);
+ const response = manager.sendEvents(odpConfig, ODP_EVENTS);
+
+ await expect(response).rejects.toThrow();
+ });
+
+ it('should return a promise that resolves with correct response code from the requestHandler', async () => {
+ const mockRequestHandler = getMockRequestHandler();
+ mockRequestHandler.makeRequest.mockReturnValue({
+ responsePromise: Promise.resolve({
+ statusCode: 226,
+ body: '',
+ headers: {},
+ }),
+ });
+ const requestGenerator = vi.fn().mockReturnValue({
+ method: 'PATCH',
+ endpoint: 'https://odp.example.com/v3/events',
+ headers: {
+ 'x-api-key': 'test-api',
+ },
+ data: 'event-data',
+ });
+
+ const manager = new DefaultOdpEventApiManager(mockRequestHandler, requestGenerator);
+ const response = manager.sendEvents(odpConfig, ODP_EVENTS);
+
+ await expect(response).resolves.not.toThrow();
+ const statusCode = await response.then((r) => r.statusCode);
+ expect(statusCode).toBe(226);
+ });
+});
+
+describe('pixelApiRequestGenerator', () => {
+ it('should generate the correct request for the pixel API using only the first event', () => {
+ const request = pixelApiRequestGenerator(odpConfig, ODP_EVENTS);
+ expect(request.method).toBe('GET');
+ const endpoint = new URL(request.endpoint);
+ expect(endpoint.origin).toBe(PIXEL_URL);
+ expect(endpoint.pathname).toBe('/v2/zaius.gif');
+ expect(endpoint.searchParams.get('id-key-1')).toBe('id-value-1');
+ expect(endpoint.searchParams.get('key11')).toBe('value-1');
+ expect(endpoint.searchParams.get('key12')).toBe('true');
+ expect(endpoint.searchParams.get('key13')).toBe('3.5');
+ expect(endpoint.searchParams.get('key14')).toBe('null');
+ expect(endpoint.searchParams.get('tracker_id')).toBe(API_KEY);
+ expect(endpoint.searchParams.get('event_type')).toBe('t1');
+ expect(endpoint.searchParams.get('vdl_action')).toBe('a1');
+
+ expect(request.headers).toEqual({});
+ expect(request.data).toBe('');
+ });
+});
+
+describe('eventApiRequestGenerator', () => {
+ it('should generate the correct request for the event API using all events', () => {
+ const request = eventApiRequestGenerator(odpConfig, ODP_EVENTS);
+ expect(request.method).toBe('POST');
+ expect(request.endpoint).toBe('https://odp.example.com/v3/events');
+ expect(request.headers).toEqual({
+ 'Content-Type': 'application/json',
+ 'x-api-key': API_KEY,
+ });
+
+ const data = JSON.parse(request.data);
+ expect(data).toEqual([
+ {
+ type: 't1',
+ action: 'a1',
+ identifiers: {
+ 'id-key-1': 'id-value-1',
+ },
+ data: {
+ key11: 'value-1',
+ key12: true,
+ key13: 3.5,
+ key14: null,
+ },
+ },
+ {
+ type: 't2',
+ action: 'a2',
+ identifiers: {
+ 'id-key-2': 'id-value-2',
+ },
+ data: {
+ key2: 'value-2',
+ },
+ },
+ ]);
+ });
+});
diff --git a/lib/odp/event_manager/odp_event_api_manager.ts b/lib/odp/event_manager/odp_event_api_manager.ts
new file mode 100644
index 000000000..79154b06e
--- /dev/null
+++ b/lib/odp/event_manager/odp_event_api_manager.ts
@@ -0,0 +1,116 @@
+/**
+ * Copyright 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.
+ * 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.
+ */
+
+import { LoggerFacade } from '../../logging/logger';
+import { OdpEvent } from './odp_event';
+import { HttpMethod, RequestHandler } from '../../utils/http_request_handler/http';
+import { OdpConfig } from '../odp_config';
+
+export type EventDispatchResponse = {
+ statusCode?: number;
+};
+export interface OdpEventApiManager {
+ sendEvents(odpConfig: OdpConfig, events: OdpEvent[]): Promise;
+ setLogger(logger: LoggerFacade): void;
+}
+
+export type EventRequest = {
+ method: HttpMethod;
+ endpoint: string;
+ headers: Record;
+ data: string;
+}
+
+export type EventRequestGenerator = (odpConfig: OdpConfig, events: OdpEvent[]) => EventRequest;
+
+export const LOGGER_NAME = 'OdpEventApiManager';
+
+export class DefaultOdpEventApiManager implements OdpEventApiManager {
+ private logger?: LoggerFacade;
+ private requestHandler: RequestHandler;
+ private requestGenerator: EventRequestGenerator;
+
+ constructor(
+ requestHandler: RequestHandler,
+ requestDataGenerator: EventRequestGenerator,
+ logger?: LoggerFacade
+ ) {
+ this.requestHandler = requestHandler;
+ this.requestGenerator = requestDataGenerator;
+ if (logger) {
+ this.setLogger(logger)
+ }
+ }
+
+ setLogger(logger: LoggerFacade): void {
+ this.logger = logger;
+ this.logger.setName(LOGGER_NAME);
+ }
+
+ async sendEvents(odpConfig: OdpConfig, events: OdpEvent[]): Promise