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/.eslintignore b/.eslintignore
new file mode 100644
index 000000000..d5ad68d3b
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,3 @@
+*.tests.js
+*.umdtests.js
+test_data.js
diff --git a/.eslintrc.js b/.eslintrc.js
index 9778987dc..20feb2cb4 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,48 +1,35 @@
module.exports = {
- "globals": {
- "console": true,
- "exports": true,
- "mocha": true,
- "it": true,
- "describe": true,
- "before": true,
- "beforeEach": true,
- "after": true,
- "afterEach": true,
- "sinon": true
+ env: {
+ browser: true,
+ commonjs: true,
+ node: true,
},
- "rules": {
- "strict": 0,
- "quotes": [1, "single"],
- "no-native-reassign": 0,
- "camelcase": 0,
- "dot-notation": 0,
- "no-debugger": 1,
- "comma-dangle": [0, "always-multiline"],
- "no-underscore-dangle": 0,
- "no-unused-vars": [1, {"vars": "all", "args": "none"}],
- "no-trailing-spaces": 1,
- "key-spacing": 1,
- "no-unused-expressions": 1,
- "no-multi-spaces": 1,
- "no-use-before-define": 0,
- "space-infix-ops": 1,
- "no-console": 0,
- "comma-spacing": 1,
- "no-alert": 1,
- "no-empty": 1,
- "no-extra-bind": 1,
- "eol-last": 1,
- "eqeqeq": 1,
- "semi": 1,
- "no-multi-str": 0,
- "no-extra-semi": 1,
- "new-cap": 0,
- "consistent-return": 0,
- "no-extra-boolean-cast": 0,
- "no-mixed-spaces-and-tabs": 1,
- "no-shadow": 1,
- "no-sequences": 1,
- "handle-callback-err": 1
- }
-}
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ ],
+ globals: {
+ Atomics: 'readonly',
+ SharedArrayBuffer: 'readonly',
+ Promise: 'readonly',
+ },
+ parserOptions: {
+ // Note: The TS compiler determines what syntax is accepted. We're using TS version 4.0.3.
+ // This seems to correspond to "2020" for this setting.
+ ecmaVersion: 2020,
+ sourceType: 'module',
+ },
+ overrides: [
+ {
+ 'files': ['*.ts'],
+ 'rules': {
+ '@typescript-eslint/explicit-module-boundary-types': ['error']
+ }
+ }
+ ],
+ rules: {
+ 'no-prototype-builtins': 'off',
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
+ '@typescript-eslint/no-empty-function': 'off',
+ },
+};
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
new file mode 100644
index 000000000..70b391e18
--- /dev/null
+++ b/.github/workflows/integration_test.yml
@@ -0,0 +1,60 @@
+name: Reusable action of running integration of production suite
+
+on:
+ workflow_call:
+ inputs:
+ FULLSTACK_TEST_REPO:
+ required: false
+ type: string
+ secrets:
+ CI_USER_TOKEN:
+ required: true
+ TRAVIS_COM_TOKEN:
+ required: true
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ # You should create a personal access token and store it in your repository
+ token: ${{ secrets.CI_USER_TOKEN }}
+ repository: 'optimizely/travisci-tools'
+ path: 'home/runner/travisci-tools'
+ ref: 'master'
+ - name: set SDK Branch if PR
+ env:
+ HEAD_REF: ${{ github.head_ref }}
+ if: ${{ github.event_name == 'pull_request' }}
+ run: |
+ echo "SDK_BRANCH=$HEAD_REF" >> $GITHUB_ENV
+ echo "TRAVIS_BRANCH=$HEAD_REF" >> $GITHUB_ENV
+ - name: set SDK Branch if not pull request
+ env:
+ REF_NAME: ${{ github.ref_name }}
+ if: ${{ github.event_name != 'pull_request' }}
+ run: |
+ echo "SDK_BRANCH=$REF_NAME" >> $GITHUB_ENV
+ echo "TRAVIS_BRANCH=$REF_NAME" >> $GITHUB_ENV
+ - name: Trigger build
+ env:
+ SDK: javascript
+ FULLSTACK_TEST_REPO: ${{ inputs.FULLSTACK_TEST_REPO }}
+ BUILD_NUMBER: ${{ github.run_id }}
+ TESTAPP_BRANCH: master
+ GITHUB_TOKEN: ${{ secrets.CI_USER_TOKEN }}
+ EVENT_TYPE: ${{ github.event_name }}
+ GITHUB_CONTEXT: ${{ toJson(github) }}
+ #REPO_SLUG: ${{ github.repository }}
+ PULL_REQUEST_SLUG: ${{ github.repository }}
+ UPSTREAM_REPO: ${{ github.repository }}
+ PULL_REQUEST_SHA: ${{ github.event.pull_request.head.sha }}
+ PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
+ UPSTREAM_SHA: ${{ github.sha }}
+ TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }}
+ EVENT_MESSAGE: ${{ github.event.message }}
+ HOME: 'home/runner'
+ run: |
+ CLIENT=node home/runner/travisci-tools/trigger-script-with-status-update.sh
+ # Run only browser builds when it's FSC not for FPS.
+ [ "$FULLSTACK_TEST_REPO" == "ProdTesting" ] || CLIENT=browser home/runner/travisci-tools/trigger-script-with-status-update.sh
\ No newline at end of file
diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml
new file mode 100644
index 000000000..c097ff585
--- /dev/null
+++ b/.github/workflows/javascript.yml
@@ -0,0 +1,104 @@
+name: Javascript
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ lint_markdown_files:
+ uses: optimizely/javascript-sdk/.github/workflows/lint_markdown.yml@master
+
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up Node
+ uses: actions/setup-node@v3
+ with:
+ node-version: 16
+ cache-dependency-path: ./package-lock.json
+ cache: 'npm'
+ - name: Run linting
+ working-directory: .
+ run: |
+ npm install
+ npm run lint
+
+ integration_tests:
+ uses: optimizely/javascript-sdk/.github/workflows/integration_test.yml@master
+ secrets:
+ CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }}
+ TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }}
+
+ fullstack_production_suite:
+ uses: optimizely/javascript-sdk/.github/workflows/integration_test.yml@master
+ with:
+ FULLSTACK_TEST_REPO: ProdTesting
+ secrets:
+ 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: 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: ['18', '20', '22', '24']
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up Node ${{ matrix.node }}
+ uses: actions/setup-node@v3
+ with:
+ node-version: ${{ matrix.node }}
+ cache: 'npm'
+ cache-dependency-path: ./package-lock.json
+ - name: Unit tests
+ working-directory: .
+ run: |
+ npm install
+ npm run coveralls
+ - name: Coveralls Parallel
+ uses: coverallsapp/github-action@master
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ path-to-lcov: ./coverage/lcov.info
+ flag-name: run-${{ matrix.node }}
+ # This is a parallel build so need this
+ parallel: true
+ base-path: .
+
+ # As testing against multiple versions need this to
+ # finish the parallel build
+ finish:
+ name: Coveralls coverage
+ needs: unit_tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Coveralls Finished
+ uses: coverallsapp/github-action@master
+ with:
+ github-token: ${{ secrets.github_token }}
+ path-to-lcov: ./coverage/lcov.info
+ parallel-finished: true
+ base-path: .
+
\ No newline at end of file
diff --git a/.github/workflows/lint_markdown.yml b/.github/workflows/lint_markdown.yml
new file mode 100644
index 000000000..af23e15a4
--- /dev/null
+++ b/.github/workflows/lint_markdown.yml
@@ -0,0 +1,19 @@
+name: Reusable action of linting markdown files
+
+on: [workflow_call]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '2.6'
+ bundler-cache: true # runs 'bundle install' and caches installed gems automatically
+ - name: Install gem
+ run: |
+ gem install awesome_bot
+ - name: Run tests
+ run: find . -type f -name '*.md' -exec awesome_bot {} \;
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/.github/workflows/source_clear_crone.yml b/.github/workflows/source_clear_crone.yml
new file mode 100644
index 000000000..328feb6ab
--- /dev/null
+++ b/.github/workflows/source_clear_crone.yml
@@ -0,0 +1,18 @@
+name: Source clear
+
+on:
+ push:
+ branches: [ master ]
+ schedule:
+ # Runs "weekly"
+ - cron: '0 0 * * 0'
+
+jobs:
+ source_clear:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: Source clear scan
+ env:
+ SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }}
+ run: curl -sSL https://download.sourceclear.com/ci.sh | bash -s - scan
\ No newline at end of file
diff --git a/.github/workflows/ticket_reference_check.yml b/.github/workflows/ticket_reference_check.yml
new file mode 100644
index 000000000..b7d52780f
--- /dev/null
+++ b/.github/workflows/ticket_reference_check.yml
@@ -0,0 +1,16 @@
+name: Jira ticket reference check
+
+on:
+ pull_request:
+ types: [opened, edited, reopened, synchronize]
+
+jobs:
+
+ jira_ticket_reference_check:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check for Jira ticket reference
+ uses: optimizely/github-action-ticket-reference-checker-public@master
+ with:
+ bodyRegex: 'FSSDK-(?\d+)'
diff --git a/.gitignore b/.gitignore
index 52945c239..4ab687ed5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,17 @@
-.DS_STORE
+node_modules/
+optimizely-*.tgz
+
npm-debug.log
-dist
-node_modules
lerna-debug.log
coverage/
+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/.travis.yml b/.travis.yml
deleted file mode 100644
index 76aa52e41..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-language: node_js
-node_js:
-- '6'
-- '8'
-- '9'
-- '10'
-branches:
- only:
- - master
- - /^\d+\.\d+\.(\d|[x])
-env:
- global:
- # BROWSER_STACK_USERNAME
- - secure: WnyM4gMsi2n69O/YZUD/pYwHJXdKDcBv3Hwft2cCw52yYc+z75uuRgdaLKs4BPisckBtnR17dH7hKlPX3HWwjCoqQm1q5qNpbJrArWaEcbotWGF2YFy21ZZ4rKNQmJqdgRj6XFZhLHbncA8v2gQPK7F6GUJ0vsJF/kiTfxAUjefR23oorcKSQrh9BfOxNAYu2Ma92qlaaMmHYbBdlNDM45/EQE+LnPCfboCiJD/5zTYq4Q+XhKLPV01vUDU60pj9ckDNXyLj9X2BwMbAzGAPGE4qTAB/IrMndVsUXblsahtwKQ6yrsUrsdTASz8/3oNkImtY7fqU874jSeG3d7PNBfZs47zkXEVy73ZWNBgM9rzVS5cPaIU3wqpuBoXFntDJcdHQhNTWEYdxmtcTUmxKt5TdUzDhrrkcti2WVLabU3N52aOBeOM0XBpfLkbV+HT6oWi3bNUb+EDMHvCxOxsP4IoEDfFs9HMzNIO3mmC3+2DFbI7s2Mb2oacAut38MbJDYSDTOLL4smG8scA3E0RQO4r8+TNk4aRIMQc7vCKqz7PpbO7Aj9dXSpeHrDmIszSmEoQqmaaGsRBwbXRom2P8fB9FcTbd/wbsfgoFNEPz5DlbtCtCmt0pQMa+3myWveKH52WC5KlFijBSDjYOMUnXbLnj5fK5eKaWp+z6/qcNwU8=
- # BROWSER_STACK_ACCESS_KEY
- - secure: U0GGZw46rJowBtH9gVluIrerB40u2b3uZpH0HsOdLlsXCCaTVk4JXX/JPVPashWAFLC7Enk3UOE4ofeEpVd0wbG6CxtG9/gklc2U2tvkqsdPpFZKaRrXoUzCyyPOmHEC2mXDXctbrncmttM4APaceRfbdTBEZIIfyLJadomjWylA61szFE9IZjvJpiwJO2xa5HI9GVRu3yXJci+riJux+JsDmfJ1hNwv3waMeeg/scddUH0hfgq69ftGs8cpMlYiO20eh32S7uPF7/IJTH1fDJjVKYQZwpypkF6AeI+od5CFTY1ajb25eaBNXThLS0Bo9ZJE/8Sogvon21dEJkt/ClY6R341InbAFXZvz7jyQAisvh0I4zxcu0VUCfh7bEUl6GXMO8VJnyxHEfqB+AIT2RoMXckkhulwiNUsJYH1yJ8mjnLvZq85mWBCp4n4jg0K6Wf46lHpjnHOVpLyLyoFGfiPf90AQVL02AJ3/ia8RkMuj0Ax+AGtiTC/+wy7dsDQOif/VpBNJcx/RciQ24mYOGzAMh4GsUWnXaZ9vXSxliogVNrmIefK5invJ0omv9pIx8NZHTHYGaulh4w6JsliiEq2kH78SlyvSrcsFGTwCY97LLaxiLm/75/Zf+F7LajKC23Fbtnj/LQizitFZqGMJ09DnR52krBAeultqRq8QLM=
-before_install: cd packages/optimizely-sdk
-install: npm install
-addons:
- srcclr: true
-script: npm test
-after_success: npm run coveralls
-
-# Integration tests need to run first to reset the PR build status to pending
-stages:
- - 'Integration tests'
- - 'Cross-browser and umd unit tests'
- - 'Test'
-
-jobs:
- include:
- - stage: 'Integration tests'
- merge_mode: replace
- env: SDK=javascript
- cache: false
- language: python
- before_install: skip
- install:
- - "pip install awscli"
- before_script:
- - "aws s3 cp s3://optimizely-travisci-artifacts/ci/trigger_fullstack-sdk-compat.sh ci/ && chmod u+x ci/trigger_fullstack-sdk-compat.sh"
- script:
- - "ci/trigger_fullstack-sdk-compat.sh"
- after_success: travis_terminate 0
- - stage: Cross-browser and umd unit tests
- node_js: '8'
- script: npm run test-ci
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/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000..ee82dd595
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,1307 @@
+# Changelog
+
+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).
+
+## [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.
+ - 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 `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
+- Add package.json script for running Karma tests locally using Chrome ([#651](https://github.com/optimizely/javascript-sdk/pull/651)).
+- Replaced explicit typescript typings with auto generated ones ([#745](https://github.com/optimizely/javascript-sdk/pull/745)).
+- Integrated code from `utils` package into `optimizely-sdk` ([#749](https://github.com/optimizely/javascript-sdk/pull/749)).
+- Added ODP Segments support in Audience Evaluation ([#765](https://github.com/optimizely/javascript-sdk/pull/765)).
+
+## [4.9.1] - January 18, 2022
+
+### Bug fixes
+- Fixed typescript compilation issue introduced by `4.9.0` ([#733](https://github.com/optimizely/javascript-sdk/pull/733))
+
+## [4.9.0] - January 14, 2022
+
+### New Features
+- Add a set of new APIs for overriding and managing user-level flag, experiment and delivery rule decisions. These methods can be used for QA and automated testing purposes. They are an extension of the OptimizelyUserContext interface ([#705](https://github.com/optimizely/javascript-sdk/pull/705), [#727](https://github.com/optimizely/javascript-sdk/pull/727), [#729](https://github.com/optimizely/javascript-sdk/pull/729), [#730](https://github.com/optimizely/javascript-sdk/pull/730)):
+ - setForcedDecision
+ - getForcedDecision
+ - removeForcedDecision
+ - removeAllForcedDecisions
+
+- For details, refer to our documentation pages: [OptimizelyUserContext](https://docs.developers.optimizely.com/full-stack/v4.0/docs/optimizelyusercontext-javascript-node) and [Forced Decision methods](https://docs.developers.optimizely.com/full-stack/v4.0/docs/forced-decision-methods-javascript-node).
+
+## [4.8.0] - November 29, 2021
+
+### New Features
+- Added a Lite bundle which does not include Data file manager and Event Processor packages. This reduces the bundle size up to 20% and is helpful for some platforms (such as edge service providers) that do not need extended functionality offered by these packages.
+- Removed Client engine validation in the SDK to allow tracking events from new clients without modifying SDK code.
+
+### Performance Improvements
+- Reduced SDK client initialization time by removing `OptimizelyConfig` creation from initialization. The `OptimizelyConfig` object is now created on the first call to `getOptimizelyConfig` API.
+- Made Improvements to logging mechanism. The SDK no longer concatenates and formats messages which do not qualify for the log level set by the user.
+
+### Changed
+- Updated `json-schema` package version to `0.4.0` to fix a high-severity vulnerability ([Prototype Pollution](https://snyk.io/vuln/SNYK-JS-JSONSCHEMA-1920922)).
+
+## [4.8.0-beta.2] - November 1, 2021
+
+### New Features
+- Removed Client engine validation in the SDK to allow tracking events from new clients without modifying SDK code.
+
+## [4.8.0-beta] - October 18, 2021
+
+### New Features
+- Added a Lite bundle which does not include Data file manager and Event Processor packages. This reduces the bundle size up to 20% and is helpful for some platforms (such as edge service providers) that do not need extended functionality offered by these packages.
+
+### Performance Improvements
+- Reduced SDK client initialization time by removing `OptimizelyConfig` creation from initialization. The `OptimizelyConfig` object is now created on the first call to `getOptimizelyConfig` API.
+- Made Improvements to logging mechanism. The SDK now no longer concatenates and formats messages which do not qualify for the log level set by the user.
+
+## [4.7.0] - September 15, 2021
+
+### New Features
+- Added new public properties to `OptimizelyConfig`. ([#683](https://github.com/optimizely/javascript-sdk/pull/683), [#698](https://github.com/optimizely/javascript-sdk/pull/698))
+ - sdkKey
+ - environmentKey
+ - attributes
+ - audiences
+ - events
+ - experimentRules and deliveryRules to `OptimizelyFeature`
+ - audiences to `OptimizelyExperiment`
+- For details, refer to our documentation page:
+ - Node version: [https://docs.developers.optimizely.com/full-stack/v4.0/docs/optimizelyconfig-javascript-node](https://docs.developers.optimizely.com/full-stack/v4.0/docs/optimizelyconfig-javascript-node).
+ - Browser version: [https://docs.developers.optimizely.com/full-stack/v4.0/docs/optimizelyconfig-javascript](https://docs.developers.optimizely.com/full-stack/v4.0/docs/optimizelyconfig-javascript).
+
+### Bug fixes
+- Followed experimentIds order of experiments inside featuresMap of OptimizelyConfig ([#701](https://github.com/optimizely/javascript-sdk/pull/701))
+
+### Deprecated
+
+- `OptimizelyFeature.experimentsMap` of `OptimizelyConfig` is deprecated as of this release. Please use `OptimizelyFeature.experimentRules` and `OptimizelyFeature.deliveryRules` ([#698](https://github.com/optimizely/javascript-sdk/pull/698))
+
+## [4.6.2] - July 15, 2021
+
+### Bug fixes
+- Fixed incorrect impression event payload in projects containing multiple flags with dublicate key rules ([#690](https://github.com/optimizely/javascript-sdk/pull/690))
+
+## [4.6.1] - July 8, 2021
+
+### Bug fixes
+- Bumped `event-processor` packages to version `0.8.2`
+- Fixed serving incorrect variation issue in projects containing multiple flags with same key rules ([#687](https://github.com/optimizely/javascript-sdk/pull/687))
+
+## [4.6.0] - May 27, 2021
+
+### New Features
+- Added support for multiple concurrent prioritized experiments per flag ([#664](https://github.com/optimizely/javascript-sdk/pull/664))
+
+### Bug fixes
+- Fixed the issue of forced-variation and whitelist not working properly with exclusion group experiments ([#664](https://github.com/optimizely/javascript-sdk/pull/664))
+- Bumped `datafile-manager` and `event-processor` packages to version `0.8.1`.
+
+## [4.5.1] - March 2, 2021
+
+### Bug fixes
+- Refactored TypeScript type definitions to have `OptimizelyUserContext` and `OptimizelyDecision` imported from `shared_types` to provide isolation from internal modules ([#655](https://github.com/optimizely/javascript-sdk/pull/655))
+
+## [4.5.0] - February 17, 2021
+
+### New Features
+
+- Introducing a new primary interface for retrieving feature flag status, configuration and associated experiment decisions for users ([#632](https://github.com/optimizely/javascript-sdk/pull/632), [#634](https://github.com/optimizely/javascript-sdk/pull/634), [#635](https://github.com/optimizely/javascript-sdk/pull/635), [#636](https://github.com/optimizely/javascript-sdk/pull/636), [#640](https://github.com/optimizely/javascript-sdk/pull/640), [#642](https://github.com/optimizely/javascript-sdk/pull/642), [#643](https://github.com/optimizely/javascript-sdk/pull/643), [#644](https://github.com/optimizely/javascript-sdk/pull/644), [#647](https://github.com/optimizely/javascript-sdk/pull/647), [#648](https://github.com/optimizely/javascript-sdk/pull/648)). The new `OptimizelyUserContext` class is instantiated with `createUserContext` and exposes the following APIs to get `OptimizelyDecision`:
+
+ - setAttribute
+ - decide
+ - decideAll
+ - decideForKeys
+ - trackEvent
+
+- For details, refer to our documentation page:
+ - browser version: [https://docs.developers.optimizely.com/full-stack/v4.0/docs/javascript-sdk](https://docs.developers.optimizely.com/full-stack/v4.0/docs/javascript-sdk).
+ - Node version: [https://docs.developers.optimizely.com/full-stack/v4.0/docs/javascript-node-sdk](https://docs.developers.optimizely.com/full-stack/v4.0/docs/javascript-node-sdk).
+
+## [4.5.0-beta] - February 10, 2021
+
+### New Features
+
+- Introducing a new primary interface for retrieving feature flag status, configuration and associated experiment decisions for users ([#632](https://github.com/optimizely/javascript-sdk/pull/632), [#634](https://github.com/optimizely/javascript-sdk/pull/634), [#635](https://github.com/optimizely/javascript-sdk/pull/635), [#636](https://github.com/optimizely/javascript-sdk/pull/636), [#640](https://github.com/optimizely/javascript-sdk/pull/640), [#642](https://github.com/optimizely/javascript-sdk/pull/642), [#643](https://github.com/optimizely/javascript-sdk/pull/643), [#644](https://github.com/optimizely/javascript-sdk/pull/644), [#647](https://github.com/optimizely/javascript-sdk/pull/647), [#648](https://github.com/optimizely/javascript-sdk/pull/648)). The new `OptimizelyUserContext` class is instantiated with `createUserContext` and exposes the following APIs to get `OptimizelyDecision`:
+
+ - setAttribute
+ - decide
+ - decideAll
+ - decideForKeys
+ - trackEvent
+
+- For details, refer to our documentation page:
+ - browser version: [https://docs.developers.optimizely.com/full-stack/v4.0/docs/javascript-sdk](https://docs.developers.optimizely.com/full-stack/v4.0/docs/javascript-sdk).
+ - Node version: [https://docs.developers.optimizely.com/full-stack/v4.0/docs/javascript-node-sdk](https://docs.developers.optimizely.com/full-stack/v4.0/docs/javascript-node-sdk).
+
+## [4.4.3] - November 23, 2020
+
+### Bug fixes
+- Refactored TypeScript type definitions to have `OptimizelyOptions` imported from `shared_types` to provide isolation from internal modules ([#629](https://github.com/optimizely/javascript-sdk/pull/629))
+
+## [4.4.2] - November 19, 2020
+
+### Bug fixes
+
+- In `Optimizely` class, use `any` type when assigning the return value of `setTimeout`. This is to allow it to type check regardless of whether it uses the browser or Node version of `setTimeout` ([PR #623](https://github.com/optimizely/javascript-sdk/pull/623)), ([Issue #622](https://github.com/optimizely/javascript-sdk/issues/622))
+- Allowed to pass string type `logLevel` to `createInstance`. ([PR #627](https://github.com/optimizely/javascript-sdk/pull/627)), ([Issue #614](https://github.com/optimizely/javascript-sdk/issues/614))
+- Excluded `suppressImplicitAnyIndexErrors` from TSconfig and resolved reported TS compiler issues ([PR #616](https://github.com/optimizely/javascript-sdk/pull/616)), ([Issue #613](https://github.com/optimizely/javascript-sdk/issues/613))
+- Refactored TypeScript type definitions to only import from `shared_types` to provide isolation from internal modules ([#625](https://github.com/optimizely/javascript-sdk/pull/625))
+
+### New Features
+
+- Added `enabled` field to decision metadata structure to support upcoming application-controlled introduction of tracking for non-experiment Flag decisions ([#619](https://github.com/optimizely/javascript-sdk/pull/619))
+
+## [4.4.1] - November 5, 2020
+
+### Bug fixes
+
+- Allowed using `--isolatedModules` flag in TSConfig file by fixing exports in event processor ([#610](https://github.com/optimizely/javascript-sdk/pull/610))
+- Fixed strictNullChecks type errors ([#611](https://github.com/optimizely/javascript-sdk/pull/611))
+
+## [4.4.0] - November 2, 2020
+
+### New Features
+
+- Added support sending impression events every time a decision is made ([#599](https://github.com/optimizely/javascript-sdk/pull/599))
+
+## [4.3.4] - October 8, 2020
+
+### Bug fixes
+- The prior version (4.3.3) was erroneously published with the wrong content. This version contains up-to-date content, with no new changes.
+
+## [4.3.3] - October 7, 2020
+
+### Bug fixes
+- Exported `OptimizelyVariable`, `OptimizelyVariation`, `OptimizelyExperiment`, `OptimizelyFeature`, `UserProfileService`, and `UserProfile` types from TypeScript type definitions ([#594](https://github.com/optimizely/javascript-sdk/pull/594))
+
+## [4.3.2] - October 6, 2020
+
+### Bug fixes
+
+- Fixed return type of `getAllFeatureVariables` method and `dispatchEvent ` method signature of `EventDispatcher` interface in TypeScript type definitions ([#576](https://github.com/optimizely/javascript-sdk/pull/576))
+- Don't log an error message when initialized with `sdkKey`, but no `datafile` ([#589](https://github.com/optimizely/javascript-sdk/pull/589))
+
+## [4.3.1] - October 5, 2020
+
+### Bug fixes
+
+- Exported `OptimizelyConfig` and `UserAttributes` type in TypeScript type definitions ([#587](https://github.com/optimizely/javascript-sdk/pull/587))
+
+## [4.3.0] - October 1, 2020
+
+### New Features
+
+- Added support for version audience evaluation ([#517](https://github.com/optimizely/javascript-sdk/pull/571))
+- Add datafile accessor ([#564](https://github.com/optimizely/javascript-sdk/pull/564))
+
+## [4.2.1] - August 10, 2020
+
+### Bug fixes
+ - Remove incorrect warning about invalid variation ID when user not bucketed into experiment or feature rollout ([#549](https://github.com/optimizely/javascript-sdk/pull/549))
+
+## [4.2.0] - July 31, 2020
+
+### New Features
+
+ - Better offline support in React Native apps:
+ - Persist downloaded datafiles in local storage for use in subsequent SDK initializations ([#430](https://github.com/optimizely/javascript-sdk/pull/430))
+ - Persist pending impression & conversion events in local storage ([#517](https://github.com/optimizely/javascript-sdk/pull/517), [#532](https://github.com/optimizely/javascript-sdk/pull/532))
+
+### Bug fixes
+
+ - Fixed log messages for Targeted Rollouts ([#515](https://github.com/optimizely/javascript-sdk/pull/515))
+
+## [4.1.0] - July 7, 2020
+
+### New Features
+
+- Added support for JSON feature variables: new methods `getFeatureVariableJSON` and `getAllFeatureVariables` ([#467](https://github.com/optimizely/javascript-sdk/pull/467), [#470](https://github.com/optimizely/javascript-sdk/pull/470))
+- Added support for authenticated datafiles when running in Node.js. Pass `datafileAccessToken` within `datafileOptions` to request an authenticated datafile using the token ([#498](https://github.com/optimizely/javascript-sdk/pull/498), [#502](https://github.com/optimizely/javascript-sdk/pull/502)):
+ ```js
+ const optimizelySDK = require('@optimizely/optimizely-sdk');
+ var optimizelyClientInstance = optimizely.createInstance({
+ sdkKey: '',
+ datafileOptions: {
+ datafileAccessToken: '',
+ }
+ });
+ ```
+
+### Bug fixes
+
+- Fixed audience evaluation log level: changed from `INFO` to `DEBUG` ([#496](https://github.com/optimizely/javascript-sdk/pull/496))
+- Temporarily disabled React Native FSC tests ([#514](https://github.com/optimizely/javascript-sdk/pull/514))
+- Changed `getFeatureVariableJson` to `getFeatureVariableJSON` ([#516](https://github.com/optimizely/javascript-sdk/pull/516))
+
+## [4.1.0-beta] - June 16, 2020
+
+### New Features
+
+- Added support for JSON feature variables: new methods `getFeatureVariableJSON` and `getAllFeatureVariables` ([#467](https://github.com/optimizely/javascript-sdk/pull/467), [#470](https://github.com/optimizely/javascript-sdk/pull/470))
+- Added support for authenticated datafiles when running in Node.js. Pass `datafileAccessToken` within `datafileOptions` to request an authenticated datafile using the token ([#498](https://github.com/optimizely/javascript-sdk/pull/498), [#502](https://github.com/optimizely/javascript-sdk/pull/502)):
+ ```js
+ const optimizelySDK = require('@optimizely/optimizely-sdk');
+ var optimizelyClientInstance = optimizely.createInstance({
+ sdkKey: '',
+ datafileOptions: {
+ datafileAccessToken: '',
+ }
+ });
+ ```
+
+### Bug fixes
+
+- Fixed audience evaluation log level: changed from `INFO` to `DEBUG` ([#496](https://github.com/optimizely/javascript-sdk/pull/496))
+
+## [4.0.0] - April 30, 2020
+
+### New Features
+
+- Removed lodash dependency
+- ES module entry point for the browser - `"module"` property of `package.json` points to `dist/optimizely.browser.es.min.js` ([#445](https://github.com/optimizely/javascript-sdk/pull/445))
+
+### Breaking Changes
+
+- Removed `Promise` polyfill from browser entry point ([417](https://github.com/optimizely/javascript-sdk/pull/417)).
+- Changed functionality of JSON schema validation in all entry points ([442](https://github.com/optimizely/javascript-sdk/pull/442)).
+ - Previously, `skipJSONValidation` flag was used by the user to specify whether the JSON object should be validated.
+ - Now, `skipJSONValidation` has been removed entirely from all entry points. Instead, a user will need to import `jsonSchemaValidator` from `@optimizely/optimizely-sdk/dist/optimizely.json_schema_validator.min.js` and pass it to `createInstance` to perform validation as shown below:
+ ```js
+ const optimizelySDK = require('@optimizely/optimizely-sdk');
+ const jsonSchemaValidator = require('@optimizely/optimizely-sdk/dist/optimizely.json_schema_validator.min');
+
+ // Require JSON schema validation for the datafile
+ var optimizelyClientInstance = optimizely.createInstance({
+ datafile: datafile,
+ jsonSchemaValidator: jsonSchemaValidator,
+ });
+ ```
+- Dropped support for Node.js version <8 ([#456](https://github.com/optimizely/javascript-sdk/pull/456))
+
+### Bug fixes
+
+- Changed `track()` to log a warning instead of an error when the event isn't in the datafile ([#418](https://github.com/optimizely/javascript-sdk/pull/418))
+- Fixed return type for `close` method in TypeScript type definitions ([#410](https://github.com/optimizely/javascript-sdk/pull/410))
+- Node.js datafile manager uses gzip,deflate compression for requests ([#456](https://github.com/optimizely/javascript-sdk/pull/456))
+
+## [4.0.0-rc.2] - April 24, 2020
+
+### Bug fixes
+
+- Allow multiple instances to be created from the same datafile object ([#462](https://github.com/optimizely/javascript-sdk/pull/462))
+
+## [4.0.0-rc.1] - April 17, 2020
+
+### New Features
+
+- ES module entry point for the browser - `"module"` property of `package.json` points to `dist/optimizely.browser.es.min.js` ([#445](https://github.com/optimizely/javascript-sdk/pull/445))
+
+### Breaking Changes:
+
+- Dropped support for Node.js version <8 ([#456](https://github.com/optimizely/javascript-sdk/pull/456))
+
+### Bug fixes
+
+- Node.js datafile manager uses gzip,deflate compression for requests ([#456](https://github.com/optimizely/javascript-sdk/pull/456))
+
+## [4.0.0-alpha.1] - March 4, 2020
+
+### Breaking Changes:
+
+- Removed `Promise` polyfill from browser entry point ([417](https://github.com/optimizely/javascript-sdk/pull/417)).
+- Changed functionality of JSON schema validation in all entry points ([442](https://github.com/optimizely/javascript-sdk/pull/442)).
+ - Previously, `skipJSONValidation` flag was used by the user to specify whether the JSON object should be validated.
+ - Now, `skipJSONValidation` has been removed entirely from all entry points. Instead, a user will need to import `jsonSchemaValidator` from `@optimizely/optimizely-sdk/dist/optimizely.json_schema_validator.min.js` and pass it to `createInstance` to perform validation as shown below:
+
+ ```js
+ const optimizelySDK = require('@optimizely/optimizely-sdk');
+ const jsonSchemaValidator = require('@optimizely/optimizely-sdk/dist/optimizely.json_schema_validator.min');
+
+ // Require JSON schema validation for the datafile
+ var optimizelyClientInstance = optimizely.createInstance({
+ datafile: datafile,
+ jsonSchemaValidator: jsonSchemaValidator,
+ });
+ ```
+
+## [3.6.0-alpha.1] - March 4, 2020
+
+### New Features
+
+- Changed `track()` to log a warning instead of an error when the event isn't in the datafile ([#418](https://github.com/optimizely/javascript-sdk/pull/418))
+
+## [3.5.0] - February 20th, 2020
+
+### Bug fixes
+
+- Fixed default event dispatcher not used in React Native entry point ([#383](https://github.com/optimizely/javascript-sdk/pull/383))
+- Fixed errors in `getOptimizelyConfig` TypeScript type definitions ([#406](https://github.com/optimizely/javascript-sdk/pull/406))
+
+### New Features
+
+- Promise returned from `close` tracks the state of in-flight event dispatcher requests ([#404](https://github.com/optimizely/javascript-sdk/pull/404))
+
+## [3.4.1] - January 28th, 2020
+
+### Bug fixes
+
+- Added `getOptimizelyConfig` and related types to TypeScript type definitions([#390](https://github.com/optimizely/javascript-sdk/pull/390)).
+
+## [3.4.0] - January 21th, 2020
+
+### Bug fixes
+
+- Fixed incorrect payload for decision notification triggered by calling getVariation on a feature test in a mutex group([#375](https://github.com/optimizely/javascript-sdk/pull/375)).
+
+### New Features
+
+- Added a new API to get project configuration static data.
+ - Call `getOptimizelyConfig()` to get a snapshot of project configuration static data.
+ - It returns an `OptimizelyConfig` instance which includes a datafile revision number, all experiments, and feature flags mapped by their key values.
+ - Added caching for `getOptimizelyConfig` - `OptimizelyConfig` object will be cached and reused for the lifetime of the datafile.
+ - For details, refer to our documentation page: [https://docs.developers.optimizely.com/full-stack/docs/optimizelyconfig-javascript-node](https://docs.developers.optimizely.com/full-stack/docs/optimizelyconfig-javascript-node).
+
+### Removed Features
+
+- Removed support for `'launched'` experiment status
+ - Previously, experiments with status `'running'` or `'launched'` would return non-`null` variations from `activate` and `getVariation`, and generate impression events from `activate`
+ - Now, only `'running'` experiments will return non-`null` variations and generate impressions
+
+## [3.4.0-beta] - December 18th, 2019
+
+### Bug fixes
+
+- Fixed incorrect payload for decision notification triggered by calling getVariation on a feature test in a mutex group([#375](https://github.com/optimizely/javascript-sdk/pull/375))
+
+### New Features
+
+- Added a new API to get a project configuration static data.
+ - Call `getOptimizelyConfig()` to get a snapshot copy of project configuration static data.
+ - It returns an `OptimizelyConfig` instance which includes a datafile revision number, all experiments, and feature flags mapped by their key values.
+ - For details, refer to a documention page: https://docs.developers.optimizely.com/full-stack/docs/optimizelyconfig-javascript-node
+
+## [3.3.2] - November 14th, 2019
+
+### Bug fixes
+
+- Fixed error message that was being logged when a user was bucketed into empty space in an experiment or a mutual exclusion group. This is not an error. With the fix, the message indicates that the user was not included in any experiment ([#366](https://github.com/optimizely/javascript-sdk/pull/366)).
+
+## [3.3.1] - October 25th, 2019
+
+### Bug fixes
+
+- Fixed full screen error dialog appearing in local development for React Native apps when using the default logger. We now provide a default logger for React Native that does not call `console.error`.
+
+## [3.3.0] - September 25th, 2019
+
+### New Features
+
+- Added support for event batching via the event processor.
+ - Events generated by methods like `activate`, `track`, and `isFeatureEnabled` will be held in a queue until the configured batch size is reached, or the configured flush interval has elapsed. Then, they will be combined into a request and sent to the event dispatcher.
+ - To configure event batching, include the `eventBatchSize` and `eventFlushInterval` number properties in the object you pass to `createInstance`.
+ - Event batching is enabled by default. `eventBatchSize` defaults to `10`. `eventFlushInterval` defaults to `30000` in Node and `1000` in browsers.
+- Added `localStorage` mitigation against lost events in the browser
+ - When event requests are dispatched, they are written to `localStorage`, and when a response is received, they are removed from `localStorage`.
+ - When the SDK is initialized for the first time in the browser, if any requests remain in `localStorage`, they will be sent, and removed from `localStorage` when a response is received.
+- Updated the `close` method to return a `Promise` representing the process of closing the instance. When `close` is called, any events waiting to be sent as part of a batched event request will be immediately batched and sent to the event dispatcher.
+ - If any such requests were sent to the event dispatcher, `close` returns a `Promise` that fulfills after the event dispatcher calls the response callback for each request. Otherwise, `close` returns an immediately-fulfilled `Promise`.
+ - The `Promise` returned from `close` is fulfilled with a result object containing `success` (boolean) and `reason` (string, only when success is `false`) properties. In the result object, `success` is `true` if all events in the queue at the time close was called were combined into requests, sent to the event dispatcher, and the event dispatcher called the callbacks for each request. `success` is false if an unexpected error was encountered during the close process.
+- Added non-typed `getFeatureVariable` method ([#298](https://github.com/optimizely/javascript-sdk/pull/298)) as a more idiomatic approach to getting values of feature variables.
+ - Typed `getFeatureVariable` methods will still be available for use.
+
+## [3.3.0-beta] - August 21th, 2019
+
+### New Features
+
+- Added support for event batching via the event processor.
+ - Events generated by methods like `activate`, `track`, and `isFeatureEnabled` will be held in a queue until the configured batch size is reached, or the configured flush interval has elapsed. Then, they will be combined into a request and sent to the event dispatcher.
+ - To configure event batching, include the `eventBatchSize` and `eventFlushInterval` number properties in the object you pass to `createInstance`.
+ - Event batching is enabled by default. `eventBatchSize` defaults to `10`. `eventFlushInterval` defaults to `30000` in Node and `1000` in browsers.
+- Added `localStorage` mitigation against lost events in the browser
+ - When event requests are dispatched, they are written to `localStorage`, and when a response is received, they are removed from `localStorage`.
+ - When the SDK is initialized for the first time in the browser, if any requests remain in `localStorage`, they will be sent, and removed from `localStorage` when a response is received.
+- Updated the `close` method to return a `Promise` representing the process of closing the instance. When `close` is called, any events waiting to be sent as part of a batched event request will be immediately batched and sent to the event dispatcher.
+ - If any such requests were sent to the event dispatcher, `close` returns a `Promise` that fulfills after the event dispatcher calls the response callback for each request. Otherwise, `close` returns an immediately-fulfilled `Promise`.
+ - The `Promise` returned from `close` is fulfilled with a result object containing `success` (boolean) and `reason` (string, only when success is `false`) properties. In the result object, `success` is `true` if all events in the queue at the time close was called were combined into requests, sent to the event dispatcher, and the event dispatcher called the callbacks for each request. `success` is false if an unexpected error was encountered during the close process.
+- Added non-typed `getFeatureVariable` method ([#298](https://github.com/optimizely/javascript-sdk/pull/298)) as a more idiomatic approach to getting values of feature variables.
+ - Typed `getFeatureVariable` methods will still be available for use.
+
+## [3.2.2] - August 20th, 2019
+
+### Bug fixes
+
+- Dont use pendingEventsDispatcher with user defined eventDispatcher ([#289](https://github.com/optimizely/javascript-sdk/issues/289))
+ Note: This was supposed to be released in 3.2.1 but did not make it into the release.
+- Updated lodash dependency to ^4.17.11 to address security vulnerabilities ([#296](https://github.com/optimizely/javascript-sdk/issues/296))
+
+## [3.2.1] - July 1st, 2019
+
+### Changed
+
+- Updated lodash dependency to ^4.17.11 to address security vulnerabilities ([#296](https://github.com/optimizely/javascript-sdk/issues/296))
+
+## [3.2.0] - May 30th, 2019
+
+### New Features
+
+- Added support for automatic datafile management ([#261](https://github.com/optimizely/javascript-sdk/pull/261)), ([#266](https://github.com/optimizely/javascript-sdk/pull/266)), ([#267](https://github.com/optimizely/javascript-sdk/pull/267)), ([#268](https://github.com/optimizely/javascript-sdk/pull/268)), ([#270](https://github.com/optimizely/javascript-sdk/pull/270)), ([#272](https://github.com/optimizely/javascript-sdk/pull/272))
+
+ - To use automatic datafile management, include `sdkKey` as a string property in the options object you pass to `createInstance`.
+ - When sdkKey is provided, the SDK instance will download the datafile associated with that sdkKey immediately upon construction. When the download completes, the SDK instance will update itself to use the downloaded datafile.
+ - Use the `onReady` method to wait until the download is complete and the SDK is ready to use.
+ - Customize datafile management behavior by passing a `datafileOptions` object within the options you pass to `createInstance`.
+ - Enable automatic updates by passing `autoUpdate: true`. Periodically (on the provided update interval), the SDK instance will download the datafile and update itself. Use this to ensure that the SDK instance is using a fresh datafile reflecting changes recently made to your experiment or feature configuration.
+ - Add a notification listener for the `OPTIMIZELY_CONFIG_UPDATE` notification type to be notified when an instance updates its Optimizely config after obtaining a new datafile.
+ - Stop active downloads and cancel recurring downloads by calling the `close` method
+
+ #### Create an instance with datafile management enabled
+
+ ```js
+ const optimizely = require('@optimizely/optimizely-sdk');
+ const optimizelyClientInstance = optimizely.createInstance({
+ sdkKey: '12345', // Provide the sdkKey of your desired environment here
+ });
+ ```
+
+ #### Use `onReady` to wait until optimizelyClientInstance has a datafile
+
+ ```js
+ const optimizely = require('@optimizely/optimizely-sdk');
+ const optimizelyClientInstance = optimizely.createInstance({
+ sdkKey: '12345',
+ });
+ optimizelyClientInstance.onReady().then(() => {
+ // optimizelyClientInstance is ready to use, with datafile downloaded from the Optimizely CDN
+ });
+ ```
+
+ #### Enable automatic updates, add notification listener for OPTIMIZELY_CONFIG_UPDATE notification type, and stop automatic updates
+
+ ```js
+ const optimizely = require('@optimizely/optimizely-sdk');
+ const optimizelyClientInstance = optimizely.createInstance({
+ sdkKey: '12345',
+ datafileOptions: {
+ autoUpdate: true,
+ updateInterval: 600000, // 10 minutes in milliseconds
+ },
+ });
+ optimizelyClientInstance.notificationCenter.addNotificationListener(
+ optimizely.enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ () => {
+ // optimizelyClientInstance has updated its Optimizely config
+ }
+ );
+ // Stop automatic updates - optimizelyClientInstance will use whatever datafile it currently has from now on
+ optimizelyClientInstance.close();
+ ```
+
+### Changed
+
+- Forced variation logic has been moved from the project config module to the decision service. Prefixes for forced-variation-related log messages will reflect this change ([#261](https://github.com/optimizely/javascript-sdk/pull/261)).
+- Update TypeScript definitions to account for new methods (`onReady`, `close`) and new properties on object accepted by createInstance (`datafileOptions`, `sdkKey`), ([#263](https://github.com/optimizely/javascript-sdk/pull/263)), ([#278](https://github.com/optimizely/javascript-sdk/pull/278))
+- Allow react-sdk to be passed in as `clientEngine` ([#279](https://github.com/optimizely/javascript-sdk/pull/279))
+
+### Bug Fixes:
+
+- Add logging message for `optimizely.track()` ([#281](https://github.com/optimizely/javascript-sdk/pull/281))
+
+## [3.2.0-beta] - May 16th, 2019
+
+### Bug Fixes:
+
+- Clear timeout created in onReady call for timeout promise as soon as project config manager's ready promise fulfills
+
+### New Features
+
+- Added 60 second timeout for all datafile requests
+
+### Changed
+
+- Updated datafile request polling behavior:
+ - Start update interval timer immediately after request
+ - When update interval timer fires during request, wait until request completes, then immediately start next request
+- Update TypeScript definitions to account for new methods (`onReady`, `close`) and new properties on object accepted by createInstance (`datafileOptions`, `sdkKey`)
+
+## [3.2.0-alpha] - April 26nd, 2019
+
+### New Features
+
+- Added support for automatic datafile management
+ - To use automatic datafile management, include `sdkKey` as a string property in the options object you pass to `createInstance`.
+ - When sdkKey is provided, the SDK instance will download the datafile associated with that sdkKey immediately upon construction. When the download completes, the SDK instance will update itself to use the downloaded datafile.
+ - Use the `onReady` method to wait until the download is complete and the SDK is ready to use.
+ - Customize datafile management behavior by passing a `datafileOptions` object within the options you pass to `createInstance`.
+ - Enable automatic updates by passing `autoUpdate: true`. Periodically (on the provided update interval), the SDK instance will download the datafile and update itself. Use this to ensure that the SDK instance is using a fresh datafile reflecting changes recently made to your experiment or feature configuration.
+ - Add a notification listener for the `OPTIMIZELY_CONFIG_UPDATE` notification type to be notified when an instance updates its Optimizely config after obtaining a new datafile.
+ - Stop active downloads and cancel pending downloads by calling the `close` method
+
+#### Create an instance with datafile management enabled
+
+```js
+const optimizely = require('@optimizely/optimizely-sdk');
+const optimizelyClientInstance = optimizely.createInstance({
+ sdkKey: '12345', // Provide the sdkKey of your desired environment here
+});
+```
+
+#### Use `onReady` to wait until optimizelyClientInstance has a datafile
+
+```js
+const optimizely = require('@optimizely/optimizely-sdk');
+const optimizelyClientInstance = optimizely.createInstance({
+ sdkKey: '12345',
+});
+optimizelyClientInstance.onReady().then(() => {
+ // optimizelyClientInstance is ready to use, with datafile downloaded from the Optimizely CDN
+});
+```
+
+#### Enable automatic updates, add notification listener for OPTIMIZELY_CONFIG_UPDATE notification type, and stop automatic updates
+
+```js
+const optimizely = require('@optimizely/optimizely-sdk');
+const optimizelyClientInstance = optimizely.createInstance({
+ sdkKey: '12345',
+ datafileOptions: {
+ autoUpdate: true,
+ updateInterval: 600000, // 10 minutes in milliseconds
+ },
+});
+optimizelyClientInstance.notificationCenter.addNotificationListener(
+ optimizely.enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
+ () => {
+ // optimizelyClientInstance has updated its Optimizely config
+ }
+);
+// Stop automatic updates - optimizelyClientInstance will use whatever datafile it currently has from now on
+optimizelyClientInstance.close();
+```
+
+### Changed
+
+- Forced variation logic has been moved from the project config module to the decision service. Prefixes for forced-variation-related log messages will reflect this change.
+
+## [3.1.0] - April 22nd, 2019
+
+### New Features:
+
+- Introduced Decision notification listener to be able to record:
+ - Variation assignments for users activated in an experiment.
+ - Feature access for users.
+ - Feature variable value for users.
+
+### Changed
+
+- New APIs for setting `logger` and `logLevel` on the optimizelySDK singleton ([#232](https://github.com/optimizely/javascript-sdk/pull/232))
+- `logger` and `logLevel` are now set globally for all instances of Optimizely. If you were passing
+ different loggers to individual instances of Optimizely, logging behavior may now be different.
+
+#### Setting a ConsoleLogger
+
+```js
+var optimizelySDK = require('@optimizely/optimizely-sdk');
+
+// logger and logLevel are now set on the optimizelySDK singleton
+optimizelySDK.setLogger(optimizelySDK.logging.createLogger());
+
+// valid levels: 'DEBUG', 'INFO', 'WARN', 'ERROR'
+optimizelySDK.setLogLevel('WARN');
+// enums can also be used
+optimizelySDK.setLogLevel(optimizelySDK.enums.LOG_LEVEL.ERROR);
+```
+
+#### Disable logging
+
+```js
+var optimizelySDK = require('@optimizely/optimizely-sdk');
+
+optimizelySDK.setLogger(null);
+```
+
+### Bug Fixes
+
+- Feature variable APIs now return default variable value when featureEnabled property is false. ([#249](https://github.com/optimizely/javascript-sdk/pull/249))
+
+### Deprecated
+
+- Activate notification listener is deprecated as of this release. Recommendation is to use the new Decision notification listener. Activate notification listener will be removed in the next major release.
+
+## [3.1.0-beta1] - March 5th, 2019
+
+### Changed
+
+- New APIs for setting `logger` and `logLevel` on the optimizelySDK singleton ([#232](https://github.com/optimizely/javascript-sdk/pull/232))
+- `logger` and `logLevel` are now set globally for all instances of Optimizely. If you were passing
+ different loggers to individual instances of Optimizely, logging behavior may now be different.
+
+#### Setting a ConsoleLogger
+
+```js
+var optimizelySDK = require('@optimizely/optimizely-sdk');
+
+// logger and logLevel are now set on the optimizelySDK singleton
+optimizelySDK.setLogger(optimizelySDK.logging.createLogger());
+
+// valid levels: 'DEBUG', 'INFO', 'WARN', 'ERROR'
+optimizelySDK.setLogLevel('WARN');
+// enums can also be used
+optimizelySDK.setLogLevel(optimizely.enums.LOG_LEVEL.ERROR);
+```
+
+#### Disable logging
+
+```js
+var optimizelySDK = require('@optimizely/optimizely-sdk');
+
+optimizelySDK.setLogger(null);
+```
+
+## [3.0.1] - February 21, 2019
+
+### Changed
+
+- Expose default `loggers`, `errorHandlers`, `eventDispatcher` and `enums` on top level require.
+- `createLogger` and `createNoOpLogger` are available as methods on `optimizelySdk.logging`
+- Added `optimizelySdk.errorHandler`
+- Added `optimizelySdk.eventDispatcher`
+- Added `optimizelySdk.enums`
+
+## [3.0.0] - February 13, 2019
+
+The 3.0 release improves event tracking and supports additional audience targeting functionality.
+
+### New Features:
+
+- Event tracking ([#207](https://github.com/optimizely/javascript-sdk/pull/207)):
+ - The `track` method now dispatches its conversion event _unconditionally_, without first determining whether the user is targeted by a known experiment that uses the event. This may increase outbound network traffic.
+ - In Optimizely results, conversion events sent by 3.0 SDKs don't explicitly name the experiments and variations that are currently targeted to the user. Instead, conversions are automatically attributed to variations that the user has previously seen, as long as those variations were served via 3.0 SDKs or by other clients capable of automatic attribution, and as long as our backend actually received the impression events for those variations.
+ - Altogether, this allows you to track conversion events and attribute them to variations even when you don't know all of a user's attribute values, and even if the user's attribute values or the experiment's configuration have changed such that the user is no longer affected by the experiment. As a result, **you may observe an increase in the conversion rate for previously-instrumented events.** If that is undesirable, you can reset the results of previously-running experiments after upgrading to the 3.0 SDK.
+ - This will also allow you to attribute events to variations from other Optimizely projects in your account, even though those experiments don't appear in the same datafile.
+ - Note that for results segmentation in Optimizely results, the user attribute values from one event are automatically applied to all other events in the same session, as long as the events in question were actually received by our backend. This behavior was already in place and is not affected by the 3.0 release.
+- Support for all types of attribute values, not just strings ([#174](https://github.com/optimizely/javascript-sdk/pull/174), [#204](https://github.com/optimizely/javascript-sdk/pull/204)).
+ - All values are passed through to notification listeners.
+ - Strings, booleans, and valid numbers are passed to the event dispatcher and can be used for Optimizely results segmentation. A valid number is a finite number in the inclusive range [-2⁵³, 2⁵³].
+ - Strings, booleans, and valid numbers are relevant for audience conditions.
+- Support for additional matchers in audience conditions ([#174](https://github.com/optimizely/javascript-sdk/pull/174)):
+ - An `exists` matcher that passes if the user has a non-null value for the targeted user attribute and fails otherwise.
+ - A `substring` matcher that resolves if the user has a string value for the targeted attribute.
+ - `gt` (greater than) and `lt` (less than) matchers that resolve if the user has a valid number value for the targeted attribute. A valid number is a finite number in the inclusive range [-2⁵³, 2⁵³].
+ - The original (`exact`) matcher can now be used to target booleans and valid numbers, not just strings.
+- Support for A/B tests, feature tests, and feature rollouts whose audiences are combined using `"and"` and `"not"` operators, not just the `"or"` operator ([#175](https://github.com/optimizely/javascript-sdk/pull/175))
+- Updated Pull Request template and commit message guidelines ([#183](https://github.com/optimizely/javascript-sdk/pull/183)).
+- Support for sticky bucketing. You can pass an `$opt_experiment_bucket_map` attribute to ensure that the user gets a specific variation ([#179](https://github.com/optimizely/javascript-sdk/pull/179)).
+- Support for bucketing IDs when evaluating feature rollouts, not just when evaluating A/B tests and feature tests ([#200](https://github.com/optimizely/javascript-sdk/pull/200)).
+- TypeScript declarations ([#199](https://github.com/optimizely/javascript-sdk/pull/199)).
+
+### Breaking Changes:
+
+- Conversion events sent by 3.0 SDKs don't explicitly name the experiments and variations that are currently targeted to the user, so these events are unattributed in raw events data export. You must use the new _results_ export to determine the variations to which events have been attributed.
+- Previously, notification listeners were only given string-valued user attributes because only strings could be passed into various method calls. That is no longer the case. You may pass non-string attribute values, and if you do, you must update your notification listeners to be able to receive whatever values you pass in ([#174](https://github.com/optimizely/javascript-sdk/pull/174), [#204](https://github.com/optimizely/javascript-sdk/pull/204)).
+- Drops `window.optimizelyClient` from the bundled build. Now, `window.optimizelySdk` can be used instead. ([#189](https://github.com/optimizely/javascript-sdk/pull/189)).
+
+### Bug Fixes:
+
+- Experiments and features can no longer activate when a negatively targeted attribute has a missing, null, or malformed value ([#174](https://github.com/optimizely/javascript-sdk/pull/174)).
+ - Audience conditions (except for the new `exists` matcher) no longer resolve to `false` when they fail to find an legitimate value for the targeted user attribute. The result remains `null` (unknown). Therefore, an audience that negates such a condition (using the `"not"` operator) can no longer resolve to `true` unless there is an unrelated branch in the condition tree that itself resolves to `true`.
+- `setForcedVariation` now treats an empty variation key as invalid and does not reset the variation ([#185](https://github.com/optimizely/javascript-sdk/pull/185)).
+- You can now specify `0` as the `revenue` or `value` for a conversion event when using the `track` method. Previously, `0` was withheld and would not appear in your data export ([#213](https://github.com/optimizely/javascript-sdk/pull/213)).
+- The existence of a feature test in an experimentation group no longer causes A/B tests in the same group to activate the same feature ([#194](https://github.com/optimizely/fullstack-sdk-compatibility-suite/pull/194)).
+
+## [2.3.1] - November 14, 2018
+
+### Fixed
+
+- fix(bundling): Publish the unminified UMD bundle along with the minified one. ([#187](https://github.com/optimizely/javascript-sdk/pull/187))
+
+## [2.3.0] - November 14, 2018
+
+### New Features
+
+- Allow sticky bucketing via passing in `attributes.$opt_experiment_bucket_map`, this more easily allows customers to do some async data fetching and ensure a user gets a specific variation.
+
+```
+const userId = '123'
+const expId = '456'
+const variationId = '678'
+const userAttributes = {
+ $opt_experiment_bucket_map: {
+ [expId]: {
+ variation_id: variationId
+ }
+ }
+}
+
+var selectedVariationKey = optimizelyClient.activate('experiment-1', userId, userAttributes);
+```
+
+## [2.2.0] - September 26, 2018
+
+### Fixed
+
+- Track and activate should not remove null attributes ([#168](https://github.com/optimizely/javascript-sdk/pull/168))
+- Track attributes with valid attribute types ([#166](https://github.com/optimizely/javascript-sdk/pull/166))
+- Prevent SDK from initializing if the datafile version in invalid ([#161](https://github.com/optimizely/javascript-sdk/pull/161))
+- Updating lerna to latest version ([#160](https://github.com/optimizely/javascript-sdk/pull/160))
+
+### Changed
+
+- Change invalid experiment key to debug level ([#165](https://github.com/optimizely/javascript-sdk/pull/165))
+
+## [2.1.3] - August 21, 2018
+
+### Fixed
+
+- Send all decisions for the same event in one snapshot. ([#155](https://github.com/optimizely/javascript-sdk/pull/155))
+- Give Node.js consumers the unbundled package ([#133](https://github.com/optimizely/javascript-sdk/pull/133))
+
+### Deprecated
+
+- The UMD build of the SDK now assigns the SDK namespace object to `window.optimizelySdk` rather than to `window.optimizelyClient`. The old name still works, but on its first access a deprecation warning is logged to the console. The alias will be removed in the 3.0.0 release. ([#152](https://github.com/optimizely/javascript-sdk/pull/152))
+
+## [2.1.2] - June 25, 2018
+
+### Fixed
+
+- Failure to log success message when event dispatched ([#123](https://github.com/optimizely/javascript-sdk/pull/123))
+- Fix: Don't call success message when event fails to send ([#123](https://github.com/optimizely/javascript-sdk/pull/123))
+
+## [2.0.5] - June 25, 2018
+
+### Fixed
+
+- Failure to log success message when event dispatched ([#123](https://github.com/optimizely/javascript-sdk/pull/123))
+- Fix: Don't call success message when event fails to send ([#123](https://github.com/optimizely/javascript-sdk/pull/123))
+
+## 2.1.1
+
+June 19, 2018
+
+- Fix: send impression event for Feature Test with Feature disabled ([#117](https://github.com/optimizely/javascript-sdk/pull/117))
+
+## 2.0.4
+
+June 19, 2018
+
+- Fix: send impression event for Feature Test with Feature disabled ([#117](https://github.com/optimizely/javascript-sdk/pull/117))
+
+## 2.1.0
+
+May 24, 2018
+
+- Introduces support for bot filtering.
+
+## 2.0.3
+
+May 24, 2018
+
+- Remove [`request`](https://www.npmjs.com/package/request) dependency ([#98](https://github.com/optimizely/javascript-sdk/pull/98))
+- Add package-lock.json ([#100](https://github.com/optimizely/javascript-sdk/pull/100))
+- Input validation in Activate, Track, and GetVariation methods ([#91](https://github.com/optimizely/javascript-sdk/pull/91) by [@mfahadahmed](https://github.com/mfahadahmed))
+
+## 2.0.1
+
+April 16th, 2018
+
+- Improve browser entry point by pointing to the browser index file instead of the webpack-compiled bundle. ([@DullReferenceException](https://github.com/DullReferenceException) in [#88](https://github.com/optimizely/javascript-sdk/pull/88))
+
+## 2.0.0
+
+April 11th, 2018
+
+This major release of the Optimizely SDK introduces APIs for Feature Management. It also introduces some breaking changes listed below.
+
+### New Features
+
+- Introduces the `isFeatureEnabled` API to determine whether to show a feature to a user or not.
+
+```
+var enabled = optimizelyClient.isFeatureEnabled('my_feature_key', 'user_1', userAttributes);
+```
+
+- You can also get all the enabled features for the user by calling the following method which returns a list of strings representing the feature keys:
+
+```
+var enabledFeatures = optimizelyClient.getEnabledFeatures('user_1', userAttributes);
+```
+
+- Introduces Feature Variables to configure or parameterize your feature. There are four variable types: `Integer`, `String`, `Double`, `Boolean`.
+
+```
+var stringVariable = optimizelyClient.getFeatureVariableString('my_feature_key', 'string_variable_key', 'user_1');
+var integerVariable = optimizelyClient.getFeatureVariableInteger('my_feature_key', 'integer_variable_key', 'user_1');
+var doubleVariable = optimizelyClient.getFeatureVariableDouble('my_feature_key', 'double_variable_key', 'user_1');
+var booleanVariable = optimizelyClient.getFeatureVariableBoolean('my_feature_key', 'boolean_variable_key', 'user_1');
+```
+
+### Breaking changes
+
+- The `track` API with revenue value as a stand-alone parameter has been removed. The revenue value should be passed in as an entry of the event tags map. The key for the revenue tag is `revenue` and will be treated by Optimizely as the key for analyzing revenue data in results.
+
+```
+var eventTags = {
+ 'revenue': 1200
+};
+
+optimizelyClient.track('event_key', 'user_id', userAttributes, eventTags);
+```
+
+- The package name has changed from `optimizely-client-sdk` to `optimizely-sdk` as we have consolidated both Node and JavaScript SDKs into one.
+
+## 2.0.0-beta1
+
+March 29th, 2018
+
+This major release of the Optimizely SDK introduces APIs for Feature Management. It also introduces some breaking changes listed below.
+
+### New Features
+
+- Introduces the `isFeatureEnabled` API to determine whether to show a feature to a user or not.
+
+```
+var enabled = optimizelyClient.isFeatureEnabled('my_feature_key', 'user_1', userAttributes);
+```
+
+- You can also get all the enabled features for the user by calling the following method which returns a list of strings representing the feature keys:
+
+```
+var enabledFeatures = optimizelyClient.getEnabledFeatures('user_1', userAttributes);
+```
+
+- Introduces Feature Variables to configure or parameterize your feature. There are four variable types: `Integer`, `String`, `Double`, `Boolean`.
+
+```
+var stringVariable = optimizelyClient.getFeatureVariableString('my_feature_key', 'string_variable_key', 'user_1');
+var integerVariable = optimizelyClient.getFeatureVariableInteger('my_feature_key', 'integer_variable_key', 'user_1');
+var doubleVariable = optimizelyClient.getFeatureVariableDouble('my_feature_key', 'double_variable_key', 'user_1');
+var booleanVariable = optimizelyClient.getFeatureVariableBoolean('my_feature_key', 'boolean_variable_key', 'user_1');
+```
+
+### Breaking changes
+
+- The `track` API with revenue value as a stand-alone parameter has been removed. The revenue value should be passed in as an entry of the event tags map. The key for the revenue tag is `revenue` and will be treated by Optimizely as the key for analyzing revenue data in results.
+
+```
+var eventTags = {
+ 'revenue': 1200
+};
+
+optimizelyClient.track('event_key', 'user_id', userAttributes, eventTags);
+```
+
+- The package name has changed from `optimizely-client-sdk` to `optimizely-sdk` as we have consolidated both Node and JavaScript SDKs into one.
+
+## 1.6.0
+
+- Bump optimizely-server-sdk to version 1.5.0, which includes:
+ - Implemented IP anonymization.
+ - Implemented bucketing IDs.
+ - Implemented notification listeners.
+
+## 1.5.1
+
+- Bump optimizely-server-sdk to version 1.4.2, which includes:
+ - Bug fix to filter out undefined values in attributes and event tags
+ - Remove a duplicated test
+
+## 1.5.0
+
+- Bump optimizely-server-sdk to version 1.4.0, which includes:
+ - Add support for numeric metrics.
+ - Add getForcedVariation and setForcedVariation methods for client-side variation setting
+ - Bug fix for filtering out null attribute and event tag values
+
+## 1.4.3
+
+- Default skipJSONValidation to true
+- Bump optimizely-server-sdk to version 1.3.3, which includes:
+ - Removed JSON Schema Validator from Optimizely constructor
+ - Updated SDK to use new event endpoint
+ - Minor bug fixes
+
+## 1.4.2
+
+- Minor performance improvements.
+
+## 1.4.1
+
+- Switched to karma/browserstack for cross-browser testing
+- Removed es6-promise
+- Bump optimizely-server-sdk to version 1.3.1, which includes:
+ - Minor performance improvements.
+
+## 1.4.0
+
+- Reduce lodash footprint.
+- Bump optimizely-server-sdk to version 1.3.0, which includes:
+ - Introduced user profile service.
+ - Minor performance and readibility improvements.
+
+## 1.3.5
+
+- Bump optimizely-server-sdk to version 1.2.3, which includes:
+ - Switched to json-schema library which has a smaller footprint.
+ - Refactored order of bucketing logic.
+ - Refactor lodash dependencies.
+ - Fixed error on validation for objects with undefined values for attributes.
+
+## 1.3.4
+
+- Bump optimizely-server-sdk to version 1.2.2, which includes:
+ - Use the 'name' field for tracking event tags instead of 'id'.
+
+## 1.3.3
+
+- Include index.js in package.json files to make sure it gets published regardless of node environment.
+
+## 1.3.2
+
+- Bump to 1.3.2 to re-publish to npm
+
+## 1.3.1
+
+- Bump optimizely-server-sdk to version 1.2.1, which includes:
+ - Gracefully handle empty traffic allocation ranges.
+
+## 1.3.0
+
+- Bump optimizely-server-sdk to version 1.2.0, which includes:
+ - Introduce support for event tags.
+ - Add optional eventTags argument to track method signature.
+ - Removed optional eventValue argument from track method signature.
+ - Removed optional sessionId argument from activate and track method signatures.
+ - Allow log level config on createInstance method.
+
+## 1.2.2
+
+- Remove .npmignore to consolidate with .gitignore.
+- Add dist and lib directories to "files" in package.json.
+
+## 1.2.1
+
+- Fix webpack build error.
+
+## 1.2.0
+
+- Bump optimizely-server-sdk to version 1.1.0, which includes:
+ - Add optional sessionId argument to activate and track method signatures.
+ - Add sessionId and revision to event ticket.
+ - Add 'Launched' status where user gets bucketed but event is not sent to Optimizely.
+
+## 1.1.1
+
+- Bump to optimizely-server-sdk to version 1.0.1, which includes:
+ - Fix bug so conversion event is not sent if user is not bucketed into any experiment.
+ - Bump bluebird version from 3.3.5 to 3.4.6.
+ - Update event endpoint from p13nlog.dz.optimizely to logx.optimizely.
+
+## 1.1.0
+
+- Add global variable name export for use in non-CommonJS environments
+- Remove redundant lodash core dependency to reduce bundle bloat
+
+## 1.0.0
+
+- Introduce support for Full Stack projects in Optimizely X with no breaking changes from previous version.
+- Introduce more graceful exception handling in instantiation and core methods.
+- Update whitelisting to take precedence over audience condition evaluation.
+- Fix bug activating/tracking with attributes not in the datafile.
+
+## 0.1.4
+
+- Add functionality for New Optimizely endpoint.
+
+## 0.1.3
+
+- Add environment detection to event builder so it can distinguish between events sent from node or the browser.
+
+## 0.1.2
+
+- Add CORS param to prevent browsers from logging cors errors in the console when dispatching events.
+
+## 0.1.1
+
+- Remove percentageIncluded field from JSON schema, which is not needed.
+
+## 0.1.0
+
+- Beta release of the Javascript SDK for our Optimizely testing solution
diff --git a/CODEOWNERS b/CODEOWNERS
new file mode 100644
index 000000000..15a35bcf9
--- /dev/null
+++ b/CODEOWNERS
@@ -0,0 +1,16 @@
+# This is a comment.
+# Each line is a file pattern followed by one or more owners.
+
+ # These owners will be the default owners for everything in the repo.
+# Unless a later match takes precedence, @global-owner1 and @global-owner2
+# will be requested for review when someone opens a pull request.
+* @optimizely/fullstack-devs
+
+ # Order is important; the last matching pattern takes the most precedence.
+# When someone opens a pull request that only modifies JS files, only @js-owner
+# and not the global owner(s) will be requested for a review.
+#*.js @js-owner
+
+ # You can also use email addresses if you prefer. They'll be used to look up
+# users just like we do for commit author emails.
+#docs/* docs@example.com
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5d8b1dd19..101b4cd35 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -5,21 +5,22 @@ We welcome contributions and feedback! All contributors must sign our [Contribut
## Development process
1. Fork the repository and create your branch from master.
-2. Please follow the [commit message guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines) for each commit message.
+2. Please follow the [commit message guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines) for each commit message.
3. Make sure to add tests!
-4. Run `npm run lint` to ensure there are no lint errors.
-5. `git push` your changes to GitHub.
-6. Open a PR from your fork into the master branch of the original repo
-7. Make sure that all unit tests are passing and that there are no merge conflicts between your branch and `master`.
-8. Open a pull request from `YOUR_NAME/branch_name` to `master`.
-9. A repository maintainer will review your pull request and, if all goes well, squash and merge it!
+4. Update relevant `CHANGELOG.md` if users should know about this change.
+5. Run `npm run lint` to ensure there are no lint errors.
+6. `git push` your changes to GitHub.
+7. Open a PR from your fork into the master branch of the original repo
+8. Make sure that all unit tests are passing and that there are no merge conflicts between your branch and `master`.
+9. Open a pull request from `YOUR_NAME/branch_name` to `master`.
+10. A repository maintainer will review your pull request and, if all goes well, squash and merge it!
## Pull request acceptance criteria
-* **All code must have test coverage.** We use Mocha's chai assertion library and Sinon. Changes in functionality should have accompanying unit tests. Bug fixes should have accompanying regression tests.
- * Tests are located in the `tests.js` file.
-* Please don't change the `package.json` or `VERSION`. We'll take care of bumping the version when we next release.
-* Lint your code with our `npm run lint` before submitting.
+- **All code must have test coverage.** We use Mocha's chai assertion library and Sinon. Changes in functionality should have accompanying unit tests. Bug fixes should have accompanying regression tests.
+ - Tests are located in the `tests.js` file.
+- Please don't change the `package.json` or `VERSION`. We'll take care of bumping the version when we next release.
+- Lint your code with our `npm run lint` before submitting.
## Style
@@ -45,7 +46,7 @@ All contributions are under the CLA mentioned above. For this project, Optimizel
* See the License for the specific language governing permissions and *
* limitations under the License. *
***************************************************************************/
- ```
+```
The YEAR above should be the year of the contribution. If work on the file has been done over multiple years, list each year in the section above. Example: Optimizely writes the file and releases it in 2014. No changes are made in 2015. Change made in 2016. YEAR should be “2014, 2016”.
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 64b80f460..08ab9f5ad 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,280 @@
-
- Optimizely JavaScript SDK
-
+# Optimizely JavaScript SDK
-
- This repository houses the official JavaScript SDK for use with Optimizely X Full Stack.
-
+[](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 X Full Stack is A/B testing and feature 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/products/full-stack/, or see the [documentation](https://developers.optimizely.com/x/solutions/sdks/reference/index.html?language=node).
+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).
-## Packages
+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).
-This repository is a monorepo that we manage using [Lerna](https://github.com/lerna/lerna). Only one package lives here currently, but that may change in the future.
+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.
-| Package | Version | Docs | Description |
-| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
-| [`@optimizely/optimizely-sdk`](/packages/optimizely-sdk) | [](https://www.npmjs.com/package/@optimizely/optimizely-sdk) | [](https://developers.optimizely.com/x/solutions/sdks/reference/?language=javascript) | The Optimizely SDK |
+---
-## About
+## Get Started
-`@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)!
+> 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.
+> 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
+
+`@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/__mocks__/@react-native-async-storage/async-storage-event-processor.ts b/__mocks__/@react-native-async-storage/async-storage-event-processor.ts
new file mode 100644
index 000000000..ad40f0152
--- /dev/null
+++ b/__mocks__/@react-native-async-storage/async-storage-event-processor.ts
@@ -0,0 +1,53 @@
+/**
+ * Copyright 2022, 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.
+ */
+let items: {[key: string]: string} = {}
+
+export default class AsyncStorage {
+
+ static getItem(key: string, callback?: (error?: Error, result?: string) => void): Promise {
+ return new Promise(resolve => {
+ setTimeout(() => resolve(items[key] || null), 1)
+ })
+ }
+
+ static setItem(key: string, value: string, callback?: (error?: Error) => void): Promise {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ items[key] = value
+ resolve()
+ }, 1)
+ })
+ }
+
+ static removeItem(key: string, callback?: (error?: Error, result?: string) => void): Promise {
+ return new Promise(resolve => {
+ setTimeout(() => {
+ items[key] && delete items[key]
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ resolve()
+ }, 1)
+ })
+ }
+
+ static dumpItems(): {[key: string]: string} {
+ return items
+ }
+
+ static clearStore(): void {
+ items = {}
+ }
+}
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/lib/utils/string_value_validator/index.js b/__mocks__/@react-native-community/netinfo.ts
similarity index 65%
rename from packages/optimizely-sdk/lib/utils/string_value_validator/index.js
rename to __mocks__/@react-native-community/netinfo.ts
index f968a50ab..12fab972a 100644
--- a/packages/optimizely-sdk/lib/utils/string_value_validator/index.js
+++ b/__mocks__/@react-native-community/netinfo.ts
@@ -1,5 +1,5 @@
/**
- * Copyright 2018, Optimizely
+ * Copyright 2022, 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,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+let localCallback: any
-module.exports = {
- /**
- * Validates provided value is a non-empty string
- * @param {string} input
- * @return {boolean} True for non-empty string, false otherwise
- */
- validate: function(input) {
- return typeof input === 'string' && input !== '';
- }
-};
+export function addEventListener(callback: any) {
+ localCallback = callback
+}
+
+export function triggerInternetState(isInternetReachable: boolean) {
+ localCallback({ isInternetReachable })
+}
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 71%
rename from packages/optimizely-sdk/karma.base.conf.js
rename to karma.base.conf.js
index 7fdff6cf6..9691083ef 100644
--- a/packages/optimizely-sdk/karma.base.conf.js
+++ b/karma.base.conf.js
@@ -1,5 +1,5 @@
/**
- * Copyright 2018, Optimizely
+ * Copyright 2018, 2020, 2022 Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -24,12 +24,24 @@ module.exports = {
webpack: {
mode: 'production',
+ module: {
+ rules: [
+ {
+ test: /\.[tj]s$/,
+ use: 'ts-loader',
+ exclude: /node_modules/,
+ },
+ ],
+ },
+ resolve: {
+ extensions: ['.ts', '.js'],
+ },
},
//browserStack setup
browserStack: {
username: process.env.BROWSER_STACK_USERNAME,
- accessKey: process.env.BROWSER_STACK_ACCESS_KEY
+ accessKey: process.env.BROWSER_STACK_ACCESS_KEY,
},
// to avoid DISCONNECTED messages when connecting to BrowserStack
@@ -42,89 +54,75 @@ module.exports = {
customLaunchers: {
bs_chrome_mac: {
base: 'BrowserStack',
- browser: 'chrome',
- browser_version: '21.0',
os: 'OS X',
- os_version: 'Mountain Lion'
+ os_version: 'Mojave',
+ browserName: 'Chrome',
+ browser_version: '102.0',
+ browser: 'Chrome',
},
bs_edge: {
base: 'BrowserStack',
os: 'Windows',
os_version: '10',
- browser: 'edge',
- device: null,
- browser_version: '15.0'
+ browserName: 'Edge',
+ browser_version: '84.0',
+ browser: 'Edge',
},
- bs_firefox_mac: {
- base: 'BrowserStack',
- browser: 'firefox',
- browser_version: '21.0',
- os: 'OS X',
- os_version: 'Mountain Lion'
- },
- bs_ie: {
+ bs_firefox: {
base: 'BrowserStack',
os: 'Windows',
- os_version: '7',
- browser: 'ie',
- device: null,
- browser_version: '10.0'
- },
- bs_iphone6: {
- base: 'BrowserStack',
- device: 'iPhone 6',
- os: 'ios',
- os_version: '8.3'
+ browser: 'Firefox',
+ os_version: '10',
+ browserName: 'Firefox',
+ browser_version: '91.0',
},
+
bs_opera_mac: {
base: 'BrowserStack',
- browser: 'opera',
- browser_version: '37',
+ browser: 'Opera',
+ os_version: 'Mojave',
+ browserName: 'Opera',
+ browser: 'Opera',
+ browser_version: '76.0',
os: 'OS X',
- os_version: 'Mountain Lion'
},
bs_safari: {
base: 'BrowserStack',
os: 'OS X',
- os_version: 'Mountain Lion',
- browser: 'safari',
+ os_version: 'Catalina',
+ browserName: 'Safari',
+ browser_version: '13.0',
+ browser: 'Safari',
device: null,
- browser_version: '6.2'
- }
+ },
},
- browsers: ['bs_chrome_mac', 'bs_edge', 'bs_firefox_mac', 'bs_ie', 'bs_iphone6', 'bs_opera_mac', 'bs_safari'],
+ browsers: ['bs_chrome_mac', 'bs_edge', 'bs_firefox', 'bs_opera_mac', 'bs_safari'],
// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ['mocha'],
// list of files to exclude
- exclude: [
- ],
-
+ exclude: [],
// preprocess matching files before serving them to the browser
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {
- './lib/**/*tests.js': ['webpack']
+ './lib/**/*tests.js': ['webpack'],
},
-
// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['progress'],
-
// web server port
port: 9876,
-
// enable / disable colors in the output (reporters and logs)
colors: true,
-
// enable / disable watching file and executing tests whenever any file changes
autoWatch: false,
@@ -134,5 +132,5 @@ module.exports = {
// Concurrency level
// how many browser should be started simultaneous
- concurrency: Infinity
-}
+ concurrency: Infinity,
+};
diff --git a/packages/optimizely-sdk/karma.bs.conf.js b/karma.bs.conf.js
similarity index 89%
rename from packages/optimizely-sdk/karma.bs.conf.js
rename to karma.bs.conf.js
index d7b8df6ce..7b7ce8cbb 100644
--- a/packages/optimizely-sdk/karma.bs.conf.js
+++ b/karma.bs.conf.js
@@ -15,7 +15,7 @@
*/
// Karma configuration for cross-browser testing
-const baseConfig = require('./karma.base.conf.js')
+const baseConfig = require('./karma.base.conf.js');
module.exports = function(config) {
config.set({
@@ -27,6 +27,7 @@ module.exports = function(config) {
// list of files / patterns to load in the browser
files: [
+ './node_modules/promise-polyfill/dist/polyfill.min.js',
'./lib/index.browser.tests.js'
],
});
diff --git a/packages/optimizely-sdk/lib/plugins/error_handler/index.tests.js b/karma.local_chrome.bs.conf.js
similarity index 59%
rename from packages/optimizely-sdk/lib/plugins/error_handler/index.tests.js
rename to karma.local_chrome.bs.conf.js
index 753ec01a1..7b6fc0b8a 100644
--- a/packages/optimizely-sdk/lib/plugins/error_handler/index.tests.js
+++ b/karma.local_chrome.bs.conf.js
@@ -1,5 +1,5 @@
/**
- * Copyright 2016, Optimizely
+ * Copyright 2021 Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,17 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-var errorHandler = require('./');
+const baseConfig = require('./karma.base.conf');
-var chai = require('chai');
-var assert = chai.assert;
-
-describe('lib/plugins/error_handler', function() {
- describe('APIs', function() {
- describe('handleError', function() {
- it('should just be a no-op function', function() {
- assert.isFunction(errorHandler.handleError);
- });
- });
+module.exports = function(config) {
+ config.set({
+ ...baseConfig,
+ plugins: ['karma-mocha', 'karma-webpack', 'karma-chrome-launcher'],
+ browserStack: null,
+ browsers: ['Chrome'],
+ files: [
+ './node_modules/promise-polyfill/dist/polyfill.min.js',
+ './lib/index.browser.tests.js'
+ ],
});
-});
+}
diff --git a/karma.local_chrome.umd.conf.js b/karma.local_chrome.umd.conf.js
new file mode 100644
index 000000000..9c5da74c2
--- /dev/null
+++ b/karma.local_chrome.umd.conf.js
@@ -0,0 +1,30 @@
+/**
+ * Copyright 2021 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.
+ */
+const baseConfig = require('./karma.base.conf');
+
+module.exports = function(config) {
+ config.set({
+ ...baseConfig,
+ plugins: ['karma-mocha', 'karma-webpack', 'karma-chrome-launcher'],
+ browserStack: null,
+ browsers: ['Chrome'],
+ files: [
+ './node_modules/promise-polyfill/dist/polyfill.min.js',
+ './dist/optimizely.browser.umd.min.js',
+ './lib/index.browser.umdtests.js'
+ ],
+ });
+}
diff --git a/packages/optimizely-sdk/karma.umd.conf.js b/karma.umd.conf.js
similarity index 90%
rename from packages/optimizely-sdk/karma.umd.conf.js
rename to karma.umd.conf.js
index ea0f59e27..51d621238 100644
--- a/packages/optimizely-sdk/karma.umd.conf.js
+++ b/karma.umd.conf.js
@@ -15,7 +15,7 @@
*/
// Karma configuration for UMD bundle testing
-const baseConfig = require('./karma.base.conf.js')
+const baseConfig = require('./karma.base.conf.js');
module.exports = function(config) {
config.set({
@@ -27,6 +27,7 @@ module.exports = function(config) {
// list of files / patterns to load in the browser
files: [
+ './node_modules/promise-polyfill/dist/polyfill.min.js',
'./dist/optimizely.browser.umd.min.js',
'./lib/index.browser.umdtests.js'
],
diff --git a/lerna.json b/lerna.json
deleted file mode 100644
index b9c6fb446..000000000
--- a/lerna.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "lerna": "3.2.1",
- "version": "independent",
- "packages": [
- "packages/*"
- ]
-}
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..87a239246
--- /dev/null
+++ b/lib/client_factory.ts
@@ -0,0 +1,80 @@
+/**
+ * Copyright 2025, Optimizely
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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 { 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_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT, 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";
+
+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,
+ });
+
+ const cmabService = new DefaultCmabService({
+ cmabClient,
+ cmabCache: new InMemoryLruCache(DEFAULT_CMAB_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT),
+ });
+
+ 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/lib/core/audience_evaluator/index.tests.js b/lib/core/audience_evaluator/index.tests.js
new file mode 100644
index 000000000..1dc5efd30
--- /dev/null
+++ b/lib/core/audience_evaluator/index.tests.js
@@ -0,0 +1,644 @@
+/**
+ * Copyright 2016, 2018-2020, 2022, 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 { sprintf } from '../../utils/fns';
+
+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 = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+}
+
+var getMockUserContext = (attributes, segments) => ({
+ getAttributes: () => ({ ... (attributes || {})}),
+ isQualifiedFor: segment => segments.indexOf(segment) > -1
+});
+
+var chromeUserAudience = {
+ conditions: [
+ 'and',
+ {
+ name: 'browser_type',
+ value: 'chrome',
+ type: 'custom_attribute',
+ },
+ ],
+};
+var iphoneUserAudience = {
+ conditions: [
+ 'and',
+ {
+ name: 'device_model',
+ value: 'iphone',
+ type: 'custom_attribute',
+ },
+ ],
+};
+var specialConditionTypeAudience = {
+ conditions: [
+ 'and',
+ {
+ match: 'interest_level',
+ value: 'special',
+ type: 'special_condition_type',
+ },
+ ],
+};
+var conditionsPassingWithNoAttrs = [
+ 'not',
+ {
+ match: 'exists',
+ name: 'input_value',
+ type: 'custom_attribute',
+ },
+];
+var conditionsPassingWithNoAttrsAudience = {
+ conditions: conditionsPassingWithNoAttrs,
+};
+var audiencesById = {
+ 0: chromeUserAudience,
+ 1: iphoneUserAudience,
+ 2: conditionsPassingWithNoAttrsAudience,
+ 3: specialConditionTypeAudience,
+};
+
+describe('lib/core/audience_evaluator', function() {
+ var audienceEvaluator;
+
+ beforeEach(function() {
+ sinon.stub(mockLogger, 'info');
+ sinon.stub(mockLogger, 'debug');
+ sinon.stub(mockLogger, 'warn');
+ sinon.stub(mockLogger, 'error');
+ });
+
+ afterEach(function() {
+ mockLogger.info.restore();
+ mockLogger.debug.restore();
+ mockLogger.warn.restore();
+ mockLogger.error.restore();
+ });
+
+ describe('APIs', function() {
+ context('with default condition evaluator', function() {
+ beforeEach(function() {
+ audienceEvaluator = createAudienceEvaluator();
+ });
+ describe('evaluate', function() {
+ it('should return true if there are no audiences', function() {
+ assert.isTrue(audienceEvaluator.evaluate([], audiencesById, getMockUserContext({})));
+ });
+
+ it('should return false if there are audiences but no attributes', function() {
+ assert.isFalse(audienceEvaluator.evaluate(['0'], audiencesById, getMockUserContext({})));
+ });
+
+ it('should return true if any of the audience conditions are met', function() {
+ var iphoneUsers = {
+ device_model: 'iphone',
+ };
+
+ var chromeUsers = {
+ browser_type: 'chrome',
+ };
+
+ var iphoneChromeUsers = {
+ browser_type: 'chrome',
+ device_model: 'iphone',
+ };
+
+ assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, getMockUserContext(iphoneUsers)));
+ assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, getMockUserContext(chromeUsers)));
+ assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, getMockUserContext(iphoneChromeUsers)));
+ });
+
+ it('should return false if none of the audience conditions are met', function() {
+ var nexusUsers = {
+ device_model: 'nexus5',
+ };
+
+ var safariUsers = {
+ browser_type: 'safari',
+ };
+
+ var nexusSafariUsers = {
+ browser_type: 'safari',
+ device_model: 'nexus5',
+ };
+
+ assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, getMockUserContext(nexusUsers)));
+ assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, getMockUserContext(safariUsers)));
+ assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, getMockUserContext(nexusSafariUsers)));
+ });
+
+ it('should return true if no attributes are passed and the audience conditions evaluate to true in the absence of attributes', function() {
+ assert.isTrue(audienceEvaluator.evaluate(['2'], audiencesById, getMockUserContext({})));
+ });
+
+ describe('complex audience conditions', function() {
+ it('should return true if any of the audiences in an "OR" condition pass', function() {
+ var result = audienceEvaluator.evaluate(['or', '0', '1'], audiencesById, getMockUserContext({ browser_type: 'chrome' }));
+ assert.isTrue(result);
+ });
+
+ it('should return true if all of the audiences in an "AND" condition pass', function() {
+ var result = audienceEvaluator.evaluate(['and', '0', '1'], audiencesById, getMockUserContext({
+ browser_type: 'chrome',
+ device_model: 'iphone',
+ }));
+ assert.isTrue(result);
+ });
+
+ it('should return true if the audience in a "NOT" condition does not pass', function() {
+ var result = audienceEvaluator.evaluate(['not', '1'], audiencesById, getMockUserContext({ device_model: 'android' }));
+ assert.isTrue(result);
+ });
+ });
+
+ describe('integration with dependencies', function() {
+ var sandbox = sinon.sandbox.create();
+
+ beforeEach(function() {
+ sandbox.stub(conditionTreeEvaluator, 'evaluate');
+ });
+
+ afterEach(function() {
+ sandbox.restore();
+ });
+
+ it('returns true if conditionTreeEvaluator.evaluate returns true', function() {
+ conditionTreeEvaluator.evaluate.returns(true);
+ var result = audienceEvaluator.evaluate(['or', '0', '1'], audiencesById, getMockUserContext({ browser_type: 'chrome' }));
+ assert.isTrue(result);
+ });
+
+ it('returns false if conditionTreeEvaluator.evaluate returns false', function() {
+ conditionTreeEvaluator.evaluate.returns(false);
+ var result = audienceEvaluator.evaluate(['or', '0', '1'], audiencesById, getMockUserContext({ browser_type: 'safari' }));
+ assert.isFalse(result);
+ });
+
+ it('returns false if conditionTreeEvaluator.evaluate returns null', function() {
+ conditionTreeEvaluator.evaluate.returns(null);
+ var result = audienceEvaluator.evaluate(['or', '0', '1'], audiencesById, getMockUserContext({ state: 'California' }));
+ assert.isFalse(result);
+ });
+
+ it('calls customAttributeConditionEvaluator.evaluate in the leaf evaluator for audience conditions', function() {
+ conditionTreeEvaluator.evaluate.callsFake(function(conditions, leafEvaluator) {
+ return leafEvaluator(conditions[1]);
+ });
+
+ 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(mockCustomAttributeConditionEvaluator);
+ sinon.assert.calledWithExactly(
+ 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, 'getEvaluator').returns({
+ evaluate: mockCustomAttributeConditionEvaluator,
+ });
+ });
+
+ afterEach(function() {
+ sandbox.restore();
+ });
+
+ it('logs correctly when conditionTreeEvaluator.evaluate returns null', function() {
+ conditionTreeEvaluator.evaluate.callsFake(function(conditions, leafEvaluator) {
+ return leafEvaluator(conditions[1]);
+ });
+
+ 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(mockCustomAttributeConditionEvaluator);
+ sinon.assert.calledWithExactly(
+ mockCustomAttributeConditionEvaluator,
+ iphoneUserAudience.conditions[1],
+ user
+ );
+ assert.isFalse(result);
+ 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]);
+ });
+
+ 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(mockCustomAttributeConditionEvaluator);
+ sinon.assert.calledWithExactly(
+ mockCustomAttributeConditionEvaluator,
+ iphoneUserAudience.conditions[1],
+ user,
+ );
+ assert.isTrue(result);
+ 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]);
+ });
+
+ 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(mockCustomAttributeConditionEvaluator);
+ sinon.assert.calledWithExactly(
+ mockCustomAttributeConditionEvaluator,
+ iphoneUserAudience.conditions[1],
+ user,
+ );
+ assert.isFalse(result);
+ 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'
+ )
+ });
+ });
+ });
+ });
+
+ context('with additional custom condition evaluator', function() {
+ describe('when passing a valid additional evaluator', function() {
+ beforeEach(function() {
+ const mockEnvironment = {
+ special: true,
+ };
+ audienceEvaluator = createAudienceEvaluator({
+ special_condition_type: {
+ evaluate: function(condition, user) {
+ const result = mockEnvironment[condition.value] && user.getAttributes()[condition.match] > 0;
+ return result;
+ },
+ },
+ });
+ });
+
+ it('should evaluate an audience properly using the custom condition evaluator', function() {
+ assert.isFalse(audienceEvaluator.evaluate(['3'], audiencesById, getMockUserContext({ interest_level: 0 })));
+ assert.isTrue(audienceEvaluator.evaluate(['3'], audiencesById, getMockUserContext({ interest_level: 1 })));
+ });
+ });
+
+ describe('when passing an invalid additional evaluator', function() {
+ beforeEach(function() {
+ audienceEvaluator = createAudienceEvaluator({
+ custom_attribute: {
+ evaluate: function() {
+ return false;
+ },
+ },
+ });
+ });
+
+ it('should not be able to overwrite built in `custom_attribute` evaluator', function() {
+ assert.isTrue(
+ audienceEvaluator.evaluate(['0'], audiencesById, getMockUserContext({
+ browser_type: 'chrome',
+ }))
+ );
+ });
+ });
+ });
+
+ context('with odp segment evaluator', function() {
+ describe('Single ODP Audience', () => {
+ const 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', () => {
+ assert.isTrue(audience.evaluate(['or', '0'], audiencesById, getMockUserContext({}, ['odp-segment-1'])));
+ });
+
+ it('should evaluate to false if segment is not found', () => {
+ assert.isFalse(audience.evaluate(['or', '0'], audiencesById, getMockUserContext({}, ['odp-segment-2'])));
+ });
+
+ it('should evaluate to false if not segments are provided', () => {
+ assert.isFalse(audience.evaluate(['or', '0'], audiencesById, getMockUserContext({})));
+ });
+ });
+
+ describe('Multiple ODP conditions in one Audience', () => {
+ const 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', () => {
+ assert.isTrue(audience.evaluate(['or', '0'], audiencesById, getMockUserContext({}, ['odp-segment-1', 'odp-segment-2', 'odp-segment-3'])));
+ assert.isTrue(audience.evaluate(['or', '0'], audiencesById, getMockUserContext({}, ['odp-segment-1', 'odp-segment-2', 'odp-segment-4'])));
+ assert.isTrue(audience.evaluate(['or', '0'], audiencesById, getMockUserContext({}, ['odp-segment-1', 'odp-segment-2', 'odp-segment-3', 'odp-segment-4'])));
+ assert.isFalse(audience.evaluate(['or', '0'], audiencesById, getMockUserContext({}, ['odp-segment-1', 'odp-segment-3', 'odp-segment-4'])));
+ assert.isFalse(audience.evaluate(['or', '0'], audiencesById, getMockUserContext({}, ['odp-segment-2', 'odp-segment-3', 'odp-segment-4'])));
+ });
+ });
+
+ describe('Multiple ODP conditions in multiple Audience', () => {
+ const 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 = {
+ "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 = {
+ "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', () => {
+ assert.isTrue(
+ audience.evaluate(
+ ['or', '0', '1', '2'],
+ audiencesById,
+ getMockUserContext({},['odp-segment-1', 'odp-segment-2'])
+ )
+ );
+ assert.isFalse(
+ audience.evaluate(
+ ['and', '0', '1', '2'],
+ audiencesById,
+ getMockUserContext({}, ['odp-segment-1', 'odp-segment-2'])
+ )
+ );
+ assert.isTrue(
+ audience.evaluate(
+ ['and', '0', '1', '2'],
+ audiencesById,
+ getMockUserContext({}, ['odp-segment-1', 'odp-segment-2', 'odp-segment-3', 'odp-segment-4', 'odp-segment-6'])
+ )
+ );
+ assert.isTrue(
+ audience.evaluate(
+ ['and', '0', '1',['not', '2']],
+ audiencesById,
+ getMockUserContext({}, ['odp-segment-1', 'odp-segment-2', 'odp-segment-3', 'odp-segment-4'])
+ )
+ );
+ });
+ });
+ });
+
+ context('with multiple types of evaluators', function() {
+ const 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 = {
+ "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', () => {
+ assert.isFalse(
+ audience.evaluate(
+ ['and', '0', '1', '2'],
+ audiencesById,
+ getMockUserContext({ browser_type: 'not_chrome' },
+ ['odp-segment-1', 'odp-segment-2', 'odp-segment-4'])
+ )
+ );
+ assert.isTrue(
+ audience.evaluate(
+ ['and', '0', '1', '2'],
+ audiencesById,
+ getMockUserContext({ browser_type: 'chrome' },
+ ['odp-segment-1', 'odp-segment-2', 'odp-segment-4'])
+ )
+ );
+ });
+ });
+ });
+});
diff --git a/lib/core/audience_evaluator/index.ts b/lib/core/audience_evaluator/index.ts
new file mode 100644
index 000000000..e2b3bce0a
--- /dev/null
+++ b/lib/core/audience_evaluator/index.ts
@@ -0,0 +1,121 @@
+/**
+ * Copyright 2016, 2018-2022, 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 * 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';
+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
+ };
+ };
+
+ /**
+ * Construct an instance of AudienceEvaluator with given options
+ * @param {Object=} UNSTABLE_conditionEvaluators A map of condition evaluators provided by the consumer. This enables matching
+ * condition types which are not supported natively by the SDK. Note that built in
+ * Optimizely evaluators cannot be overridden.
+ * @constructor
+ */
+ 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),
+ };
+ }
+
+ /**
+ * Determine if the given user attributes satisfy the given audience conditions
+ * @param {Array,
+ audiencesById: { [id: string]: Audience },
+ user: OptimizelyUserContext,
+ ): boolean {
+ // if there are no audiences, return true because that means ALL users are included in the experiment
+ if (!audienceConditions || audienceConditions.length === 0) {
+ return true;
+ }
+
+ const evaluateAudience = (audienceId: string) => {
+ const audience = audiencesById[audienceId];
+ if (audience) {
+ 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();
+ this.logger?.debug(AUDIENCE_EVALUATION_RESULT, audienceId, resultText);
+ return result;
+ }
+ return null;
+ };
+
+ return !!conditionTreeEvaluator.evaluate(audienceConditions, evaluateAudience);
+ }
+
+ /**
+ * Wrapper around evaluator.evaluate that is passed to the conditionTreeEvaluator.
+ * Evaluates the condition provided given the user attributes if an evaluator has been defined for the condition type.
+ * @param {OptimizelyUserContext} user Optimizely user context containing attributes and segments
+ * @param {Condition} condition A single condition object to evaluate.
+ * @return {boolean|null} true if the condition is satisfied, null if a matcher is not found.
+ */
+ evaluateConditionWithUserAttributes(user: OptimizelyUserContext, condition: Condition): boolean | null {
+ const evaluator = this.typeToEvaluatorMap[condition.type];
+ if (!evaluator) {
+ this.logger?.warn(UNKNOWN_CONDITION_TYPE, JSON.stringify(condition));
+ return null;
+ }
+ try {
+ return evaluator.evaluate(condition, user);
+ } catch (err: any) {
+ this.logger?.error(
+ CONDITION_EVALUATOR_ERROR, condition.type, err.message
+ );
+ }
+
+ return null;
+ }
+}
+
+export default AudienceEvaluator;
+
+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/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.tests.js b/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.tests.js
new file mode 100644
index 000000000..cc9218887
--- /dev/null
+++ b/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.tests.js
@@ -0,0 +1,76 @@
+/****************************************************************************
+ * Copyright 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. *
+ ***************************************************************************/
+import sinon from 'sinon';
+import { assert } from 'chai';
+import { sprintf } from '../../../utils/fns';
+
+import { LOG_LEVEL } from '../../../utils/enums';
+import * as odpSegmentEvalutor from './';
+import { UNKNOWN_MATCH_TYPE } from 'error_message';
+
+var odpSegment1Condition = {
+ "value": "odp-segment-1",
+ "type": "third_party_dimension",
+ "name": "odp.audiences",
+ "match": "qualified"
+};
+
+var getMockUserContext = (attributes, segments) => ({
+ getAttributes: () => ({ ... (attributes || {})}),
+ isQualifiedFor: segment => segments.indexOf(segment) > -1
+});
+
+var createLogger = () => ({
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => createLogger(),
+})
+
+describe('lib/core/audience_evaluator/odp_segment_condition_evaluator', function() {
+ const mockLogger = createLogger();
+ const { evaluate } = odpSegmentEvalutor.getEvaluator(mockLogger);
+
+ beforeEach(function() {
+ sinon.stub(mockLogger, 'warn');
+ sinon.stub(mockLogger, 'error');
+ });
+
+ afterEach(function() {
+ mockLogger.warn.restore();
+ mockLogger.error.restore();
+ });
+
+ it('should return true when segment qualifies and known match type is provided', () => {
+ 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(evaluate(odpSegment1Condition, getMockUserContext({}, ['odp-segment-2'])));
+ })
+
+ it('should return null when segment qualifies but unknown match type is provided', () => {
+ const invalidOdpMatchCondition = {
+ ... odpSegment1Condition,
+ "match": 'unknown',
+ };
+ 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/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.ts b/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.ts
new file mode 100644
index 000000000..7380c9269
--- /dev/null
+++ b/lib/core/audience_evaluator/odp_segment_condition_evaluator/index.ts
@@ -0,0 +1,68 @@
+/****************************************************************************
+ * Copyright 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. *
+ ***************************************************************************/
+import { UNKNOWN_MATCH_TYPE } from 'error_message';
+import { LoggerFacade } from '../../../logging/logger';
+import { Condition, OptimizelyUserContext } from '../../../shared_types';
+
+const QUALIFIED_MATCH_TYPE = 'qualified';
+
+const MATCH_TYPES = [
+ QUALIFIED_MATCH_TYPE,
+];
+
+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.
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @return {?boolean} true/false if the given user attributes match/don't match the given condition,
+ * null if the given user attributes and condition can't be evaluated
+ * TODO: Change to accept and object with named properties
+ */
+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(UNKNOWN_MATCH_TYPE, JSON.stringify(condition));
+ return null;
+ }
+
+ let evaluator;
+ if (!conditionMatch) {
+ evaluator = qualifiedEvaluator;
+ } else {
+ evaluator = EVALUATORS_BY_MATCH_TYPE[conditionMatch] || qualifiedEvaluator;
+ }
+
+ return evaluator(condition, user);
+}
+
+function qualifiedEvaluator(condition: Condition, user: OptimizelyUserContext): boolean {
+ return user.isQualifiedFor(condition.value as string);
+}
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/lib/core/bucketer/index.tests.js b/lib/core/bucketer/index.tests.js
new file mode 100644
index 000000000..a1e046088
--- /dev/null
+++ b/lib/core/bucketer/index.tests.js
@@ -0,0 +1,406 @@
+/**
+ * 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.
+ * 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, expect } from 'chai';
+import { cloneDeep, create } from 'lodash';
+import { sprintf } from '../../utils/fns';
+import * as bucketValueGenerator from './bucket_value_generator'
+import * as bucketer from './';
+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();
+ var bucketerParams;
+
+ beforeEach(function () {
+ sinon.stub(createdLogger, 'info');
+ sinon.stub(createdLogger, 'debug');
+ sinon.stub(createdLogger, 'warn');
+ sinon.stub(createdLogger, 'error');
+ });
+
+ afterEach(function () {
+ createdLogger.info.restore();
+ createdLogger.debug.restore();
+ createdLogger.warn.restore();
+ createdLogger.error.restore();
+ });
+
+ describe('return values for bucketing (excluding groups)', function () {
+ beforeEach(function () {
+ configObj = projectConfig.createProjectConfig(cloneDeep(testData));
+ 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: createdLogger,
+ validateEntity: true,
+ };
+ sinon
+ .stub(bucketValueGenerator, 'generateBucketValue')
+ .onFirstCall()
+ .returns(50)
+ .onSecondCall()
+ .returns(50000);
+ });
+
+ afterEach(function () {
+ bucketValueGenerator.generateBucketValue.restore();
+ });
+
+ it('should return decision response with correct variation ID when provided bucket value', function () {
+ var bucketerParamsTest1 = cloneDeep(bucketerParams);
+ bucketerParamsTest1.userId = 'ppid1';
+ var decisionResponse = bucketer.bucket(bucketerParamsTest1);
+ expect(decisionResponse.result).to.equal('111128');
+
+ 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);
+
+ expect(createdLogger.debug.args[1]).to.deep.equal([USER_ASSIGNED_TO_EXPERIMENT_BUCKET, 50000, 'ppid2']);
+ });
+ });
+
+ describe('return values for bucketing (including groups)', function () {
+ var bucketerStub;
+ beforeEach(function () {
+ configObj = projectConfig.createProjectConfig(cloneDeep(testData));
+ 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: createdLogger,
+ validateEntity: true,
+ };
+ bucketerStub = sinon.stub(bucketValueGenerator, 'generateBucketValue');
+ });
+
+ afterEach(function () {
+ bucketValueGenerator.generateBucketValue.restore();
+ });
+
+ describe('random groups', function () {
+ bucketerParams = {};
+ beforeEach(function () {
+ 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: createdLogger,
+ userId: 'testUser',
+ validateEntity: true,
+ };
+ });
+
+ it('should return decision response with the proper variation for a user in a grouped experiment', function () {
+ bucketerStub.onFirstCall().returns(50);
+ bucketerStub.onSecondCall().returns(50);
+
+ var decisionResponse = bucketer.bucket(bucketerParams);
+ expect(decisionResponse.result).to.equal('551');
+
+ sinon.assert.calledTwice(bucketerStub);
+ 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 () {
+ bucketerStub.returns(5000);
+
+ var decisionResponse = bucketer.bucket(bucketerParams);
+ expect(decisionResponse.result).to.equal(null);
+
+ sinon.assert.calledOnce(bucketerStub);
+ 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 () {
+ bucketerStub.returns(50000);
+
+ var decisionResponse = bucketer.bucket(bucketerParams);
+ expect(decisionResponse.result).to.equal(null);
+
+ sinon.assert.calledOnce(bucketerStub);
+ 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 () {
+ bucketerStub.returns(9000);
+
+ var decisionResponse = bucketer.bucket(bucketerParams);
+ expect(decisionResponse.result).to.equal(null);
+
+ sinon.assert.calledOnce(bucketerStub);
+ 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';
+
+ const ex = assert.throws(function () {
+ bucketer.bucket(bucketerParamsWithInvalidGroupId);
+ });
+ assert.equal(ex.baseMessage, INVALID_GROUP_ID);
+ assert.deepEqual(ex.params, ['6969']);
+ });
+ });
+
+ describe('overlapping groups', function () {
+ bucketerParams = {};
+ beforeEach(function () {
+ 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: createdLogger,
+ userId: 'testUser',
+ validateEntity: true,
+ };
+ });
+
+ it('should return decision response with variation when a user falls into an experiment within an overlapping group', function () {
+ bucketerStub.returns(0);
+
+ var decisionResponse = bucketer.bucket(bucketerParams);
+ expect(decisionResponse.result).to.equal('553');
+
+ sinon.assert.calledOnce(bucketerStub);
+ sinon.assert.calledOnce(createdLogger.debug);
+
+ 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 () {
+ bucketerStub.returns(3000);
+
+ var decisionResponse = bucketer.bucket(bucketerParams);
+ expect(decisionResponse.result).to.equal(null);
+ });
+ });
+ });
+
+ describe('when the bucket value falls into empty traffic allocation ranges', function () {
+ beforeEach(function () {
+ configObj = projectConfig.createProjectConfig(cloneDeep(testData));
+ 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: createdLogger,
+ validateEntity: true,
+ };
+ });
+
+ it('should return decision response with variation null', function () {
+ var bucketerParamsTest1 = cloneDeep(bucketerParams);
+ bucketerParamsTest1.userId = 'ppid1';
+ var decisionResponse = bucketer.bucket(bucketerParamsTest1);
+ expect(decisionResponse.result).to.equal(null);
+ });
+
+ it('should not log an invalid variation ID warning', function () {
+ bucketer.bucket(bucketerParams)
+ 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);
+ });
+ });
+
+ describe('when the traffic allocation has invalid variation ids', function () {
+ beforeEach(function () {
+ configObj = projectConfig.createProjectConfig(cloneDeep(testData));
+ 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: createdLogger,
+ validateEntity: true,
+ };
+ });
+
+ it('should return decision response with variation null', function () {
+ var bucketerParamsTest1 = cloneDeep(bucketerParams);
+ bucketerParamsTest1.userId = 'ppid1';
+ var decisionResponse = bucketer.bucket(bucketerParamsTest1);
+ expect(decisionResponse.result).to.equal(null);
+ });
+ });
+ });
+
+ describe('generateBucketValue', function () {
+ it('should return a bucket value for different inputs', function () {
+ var experimentId = 1886780721;
+ var bucketingKey1 = sprintf('%s%s', 'ppid1', experimentId);
+ var bucketingKey2 = sprintf('%s%s', 'ppid2', experimentId);
+ var bucketingKey3 = sprintf('%s%s', 'ppid2', 1886780722);
+ var bucketingKey4 = sprintf('%s%s', 'ppid3', experimentId);
+
+ 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() {
+ bucketValueGenerator.generateBucketValue(null);
+ } );
+ expect(response.baseMessage).to.equal(INVALID_BUCKETING_ID);
+ });
+ });
+
+ describe('testBucketWithBucketingId', function () {
+ var bucketerParams;
+ var createdLogger = createLogger({
+ logLevel: LOG_LEVEL.INFO,
+ logToConsole: false,
+ });
+
+ beforeEach(function () {
+ var configObj = projectConfig.createProjectConfig(cloneDeep(testData));
+ bucketerParams = {
+ trafficAllocationConfig: configObj.experiments[0].trafficAllocation,
+ variationIdMap: configObj.variationIdMap,
+ experimentIdMap: configObj.experimentIdMap,
+ groupIdMap: configObj.groupIdMap,
+ logger: createdLogger,
+ validateEntity: true,
+ };
+ });
+
+ it('check that a non null bucketingId buckets a variation different than the one expected with userId', function () {
+ var bucketerParams1 = cloneDeep(bucketerParams);
+ bucketerParams1['userId'] = 'testBucketingIdControl';
+ bucketerParams1['bucketingId'] = '123456789';
+ bucketerParams1['experimentKey'] = 'testExperiment';
+ bucketerParams1['experimentId'] = '111127';
+ expect(bucketer.bucket(bucketerParams1).result).to.equal('111129');
+ });
+
+ it('check that a null bucketing ID defaults to bucketing with the userId', function () {
+ var bucketerParams2 = cloneDeep(bucketerParams);
+ bucketerParams2['userId'] = 'testBucketingIdControl';
+ bucketerParams2['bucketingId'] = null;
+ bucketerParams2['experimentKey'] = 'testExperiment';
+ bucketerParams2['experimentId'] = '111127';
+ expect(bucketer.bucket(bucketerParams2).result).to.equal('111128');
+ });
+
+ it('check that bucketing works with an experiment in group', function () {
+ var bucketerParams4 = cloneDeep(bucketerParams);
+ bucketerParams4['userId'] = 'testBucketingIdControl';
+ bucketerParams4['bucketingId'] = '123456789';
+ bucketerParams4['experimentKey'] = 'groupExperiment2';
+ bucketerParams4['experimentId'] = '443';
+ expect(bucketer.bucket(bucketerParams4).result).to.equal('111128');
+ });
+ });
+ });
+});
diff --git a/lib/core/bucketer/index.ts b/lib/core/bucketer/index.ts
new file mode 100644
index 000000000..e31c8df4b
--- /dev/null
+++ b/lib/core/bucketer/index.ts
@@ -0,0 +1,210 @@
+/**
+ * Copyright 2016, 2019-2022, 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.
+ */
+
+/**
+ * Bucketer API for determining the variation id from the specified parameters
+ */
+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';
+
+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';
+
+/**
+ * Determines ID of variation to be shown for the given input params
+ * @param {Object} bucketerParams
+ * @param {string} bucketerParams.experimentId
+ * @param {string} bucketerParams.experimentKey
+ * @param {string} bucketerParams.userId
+ * @param {Object[]} bucketerParams.trafficAllocationConfig
+ * @param {Array} bucketerParams.experimentKeyMap
+ * @param {Object} bucketerParams.groupIdMap
+ * @param {Object} bucketerParams.variationIdMap
+ * @param {string} bucketerParams.varationIdMap[].key
+ * @param {Object} bucketerParams.logger
+ * @param {string} bucketerParams.bucketingId
+ * @return {Object} DecisionResponse DecisionResponse containing variation ID that user has been bucketed into,
+ * null if user is not bucketed into any experiment and the decide reasons.
+ */
+export const bucket = function(bucketerParams: BucketerParams): DecisionResponse {
+ 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];
+ // 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 OptimizelyError(INVALID_GROUP_ID, groupId);
+ }
+ if (group.policy === RANDOM_POLICY) {
+ const bucketedExperimentId = bucketUserIntoExperiment(
+ group,
+ bucketerParams.bucketingId,
+ bucketerParams.userId,
+ bucketerParams.logger
+ );
+
+ // Return if user is not bucketed into any experiment
+ if (bucketedExperimentId === null) {
+ bucketerParams.logger?.info(
+ USER_NOT_IN_ANY_EXPERIMENT,
+ bucketerParams.userId,
+ groupId,
+ );
+ decideReasons.push([
+ USER_NOT_IN_ANY_EXPERIMENT,
+ bucketerParams.userId,
+ groupId,
+ ]);
+ return {
+ result: null,
+ reasons: decideReasons,
+ };
+ }
+
+ // Return if user is bucketed into a different experiment than the one specified
+ if (bucketedExperimentId !== bucketerParams.experimentId) {
+ bucketerParams.logger?.info(
+ USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
+ bucketerParams.userId,
+ bucketerParams.experimentKey,
+ groupId,
+ );
+ decideReasons.push([
+ USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
+ bucketerParams.userId,
+ bucketerParams.experimentKey,
+ groupId,
+ ]);
+ return {
+ result: null,
+ reasons: decideReasons,
+ };
+ }
+
+ // Continue bucketing if user is bucketed into specified experiment
+ bucketerParams.logger?.info(
+ USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
+ bucketerParams.userId,
+ bucketerParams.experimentKey,
+ groupId,
+ );
+ decideReasons.push([
+ USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
+ bucketerParams.userId,
+ bucketerParams.experimentKey,
+ groupId,
+ ]);
+ }
+ }
+ const bucketingId = `${bucketerParams.bucketingId}${bucketerParams.experimentId}`;
+ const bucketValue = generateBucketValue(bucketingId);
+
+ bucketerParams.logger?.debug(
+ USER_ASSIGNED_TO_EXPERIMENT_BUCKET,
+ bucketValue,
+ bucketerParams.userId,
+ );
+ decideReasons.push([
+ USER_ASSIGNED_TO_EXPERIMENT_BUCKET,
+ bucketValue,
+ bucketerParams.userId,
+ ]);
+
+ const entityId = _findBucket(bucketValue, bucketerParams.trafficAllocationConfig);
+
+ 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 {
+ result: entityId,
+ reasons: decideReasons,
+ };
+};
+
+/**
+ * Returns bucketed experiment ID to compare against experiment user is being called into
+ * @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 {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?: LoggerFacade
+): string | null {
+ const bucketingKey = `${bucketingId}${group.id}`;
+ const bucketValue = generateBucketValue(bucketingKey);
+ logger?.debug(
+ USER_ASSIGNED_TO_EXPERIMENT_BUCKET,
+ bucketValue,
+ userId,
+ );
+ const trafficAllocationConfig = group.trafficAllocation;
+ const bucketedExperimentId = _findBucket(bucketValue, trafficAllocationConfig);
+ return bucketedExperimentId;
+};
+
+/**
+ * Returns entity ID associated with bucket value
+ * @param {number} bucketValue
+ * @param {TrafficAllocation[]} trafficAllocationConfig
+ * @param {number} trafficAllocationConfig[].endOfRange
+ * @param {string} trafficAllocationConfig[].entityId
+ * @return {string|null} Entity ID for bucketing if bucket value is within traffic allocation boundaries, null otherwise
+ */
+export const _findBucket = function(
+ bucketValue: number,
+ trafficAllocationConfig: TrafficAllocation[]
+): string | null {
+ for (let i = 0; i < trafficAllocationConfig.length; i++) {
+ if (bucketValue < trafficAllocationConfig[i].endOfRange) {
+ return trafficAllocationConfig[i].entityId;
+ }
+ }
+
+ return null;
+};
+
+export default {
+ bucket: bucket,
+ bucketUserIntoExperiment: bucketUserIntoExperiment,
+};
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 61%
rename from packages/optimizely-sdk/lib/core/condition_tree_evaluator/index.tests.js
rename to lib/core/condition_tree_evaluator/index.tests.js
index 22ca3c6e0..e01c0f9f0 100644
--- a/packages/optimizely-sdk/lib/core/condition_tree_evaluator/index.tests.js
+++ b/lib/core/condition_tree_evaluator/index.tests.js
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2018, Optimizely, Inc. and contributors *
+ * Copyright 2018, 2020-2021, 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. *
@@ -13,11 +13,10 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
***************************************************************************/
+import sinon from 'sinon';
+import { assert } from 'chai';
-var chai = require('chai');
-var sinon = require('sinon');
-var assert = chai.assert;
-var conditionTreeEvaluator = require('./');
+import * as conditionTreeEvaluator from './';
var conditionA = {
name: 'browser_type',
@@ -40,66 +39,63 @@ describe('lib/core/condition_tree_evaluator', function() {
describe('APIs', function() {
describe('evaluate', function() {
it('should return true for a leaf condition when the leaf condition evaluator returns true', function() {
- assert.isTrue(conditionTreeEvaluator.evaluate(conditionA, function() { return true; }));
+ assert.isTrue(
+ conditionTreeEvaluator.evaluate(conditionA, function() {
+ return true;
+ })
+ );
});
it('should return false for a leaf condition when the leaf condition evaluator returns false', function() {
- assert.isFalse(conditionTreeEvaluator.evaluate(conditionA, function() { return false; }));
+ assert.isFalse(
+ conditionTreeEvaluator.evaluate(conditionA, function() {
+ return false;
+ })
+ );
});
describe('and evaluation', function() {
it('should return true when ALL conditions evaluate to true', function() {
- assert.isTrue(conditionTreeEvaluator.evaluate(
- ['and', conditionA, conditionB],
- function() { return true; }
- ));
+ assert.isTrue(
+ conditionTreeEvaluator.evaluate(['and', conditionA, conditionB], function() {
+ return true;
+ })
+ );
});
it('should return false if one condition evaluates to false', function() {
var leafEvaluator = sinon.stub();
leafEvaluator.onCall(0).returns(true);
leafEvaluator.onCall(1).returns(false);
- assert.isFalse(conditionTreeEvaluator.evaluate(
- ['and', conditionA, conditionB],
- leafEvaluator
- ));
+ assert.isFalse(conditionTreeEvaluator.evaluate(['and', conditionA, conditionB], leafEvaluator));
});
describe('null handling', function() {
it('should return null when all operands evaluate to null', function() {
- assert.isNull(conditionTreeEvaluator.evaluate(
- ['and', conditionA, conditionB],
- function() { return null; }
- ));
+ assert.isNull(
+ conditionTreeEvaluator.evaluate(['and', conditionA, conditionB], function() {
+ return null;
+ })
+ );
});
it('should return null when operands evaluate to trues and nulls', function() {
var leafEvaluator = sinon.stub();
leafEvaluator.onCall(0).returns(true);
leafEvaluator.onCall(1).returns(null);
- assert.isNull(conditionTreeEvaluator.evaluate(
- ['and', conditionA, conditionB],
- leafEvaluator
- ));
+ assert.isNull(conditionTreeEvaluator.evaluate(['and', conditionA, conditionB], leafEvaluator));
});
it('should return false when operands evaluate to falses and nulls', function() {
var leafEvaluator = sinon.stub();
leafEvaluator.onCall(0).returns(false);
leafEvaluator.onCall(1).returns(null);
- assert.isFalse(conditionTreeEvaluator.evaluate(
- ['and', conditionA, conditionB],
- leafEvaluator
- ));
+ assert.isFalse(conditionTreeEvaluator.evaluate(['and', conditionA, conditionB], leafEvaluator));
leafEvaluator.reset();
leafEvaluator.onCall(0).returns(null);
leafEvaluator.onCall(1).returns(false);
- assert.isFalse(conditionTreeEvaluator.evaluate(
- ['and', conditionA, conditionB],
- leafEvaluator
- ));
-
+ assert.isFalse(conditionTreeEvaluator.evaluate(['and', conditionA, conditionB], leafEvaluator));
});
it('should return false when operands evaluate to trues, falses, and nulls', function() {
@@ -107,10 +103,7 @@ describe('lib/core/condition_tree_evaluator', function() {
leafEvaluator.onCall(0).returns(true);
leafEvaluator.onCall(1).returns(false);
leafEvaluator.onCall(2).returns(null);
- assert.isFalse(conditionTreeEvaluator.evaluate(
- ['and', conditionA, conditionB, conditionC],
- leafEvaluator
- ));
+ assert.isFalse(conditionTreeEvaluator.evaluate(['and', conditionA, conditionB, conditionC], leafEvaluator));
});
});
});
@@ -120,53 +113,43 @@ describe('lib/core/condition_tree_evaluator', function() {
var leafEvaluator = sinon.stub();
leafEvaluator.onCall(0).returns(false);
leafEvaluator.onCall(1).returns(true);
- assert.isTrue(conditionTreeEvaluator.evaluate(
- ['or', conditionA, conditionB],
- leafEvaluator
- ));
+ assert.isTrue(conditionTreeEvaluator.evaluate(['or', conditionA, conditionB], leafEvaluator));
});
it('should return false if all conditions evaluate to false', function() {
- assert.isFalse(conditionTreeEvaluator.evaluate(
- ['or', conditionA, conditionB],
- function() { return false; }
- ));
+ assert.isFalse(
+ conditionTreeEvaluator.evaluate(['or', conditionA, conditionB], function() {
+ return false;
+ })
+ );
});
describe('null handling', function() {
it('should return null when all operands evaluate to null', function() {
- assert.isNull(conditionTreeEvaluator.evaluate(
- ['or', conditionA, conditionB],
- function() { return null; }
- ));
+ assert.isNull(
+ conditionTreeEvaluator.evaluate(['or', conditionA, conditionB], function() {
+ return null;
+ })
+ );
});
it('should return true when operands evaluate to trues and nulls', function() {
var leafEvaluator = sinon.stub();
leafEvaluator.onCall(0).returns(true);
leafEvaluator.onCall(1).returns(null);
- assert.isTrue(conditionTreeEvaluator.evaluate(
- ['or', conditionA, conditionB],
- leafEvaluator
- ));
+ assert.isTrue(conditionTreeEvaluator.evaluate(['or', conditionA, conditionB], leafEvaluator));
});
it('should return null when operands evaluate to falses and nulls', function() {
var leafEvaluator = sinon.stub();
leafEvaluator.onCall(0).returns(null);
leafEvaluator.onCall(1).returns(false);
- assert.isNull(conditionTreeEvaluator.evaluate(
- ['or', conditionA, conditionB],
- leafEvaluator
- ));
+ assert.isNull(conditionTreeEvaluator.evaluate(['or', conditionA, conditionB], leafEvaluator));
leafEvaluator.reset();
leafEvaluator.onCall(0).returns(false);
leafEvaluator.onCall(1).returns(null);
- assert.isNull(conditionTreeEvaluator.evaluate(
- ['or', conditionA, conditionB],
- leafEvaluator
- ));
+ assert.isNull(conditionTreeEvaluator.evaluate(['or', conditionA, conditionB], leafEvaluator));
});
it('should return true when operands evaluate to trues, falses, and nulls', function() {
@@ -174,48 +157,58 @@ describe('lib/core/condition_tree_evaluator', function() {
leafEvaluator.onCall(0).returns(true);
leafEvaluator.onCall(1).returns(null);
leafEvaluator.onCall(2).returns(false);
- assert.isTrue(conditionTreeEvaluator.evaluate(
- ['or', conditionA, conditionB, conditionC],
- leafEvaluator
- ));
+ assert.isTrue(conditionTreeEvaluator.evaluate(['or', conditionA, conditionB, conditionC], leafEvaluator));
});
});
});
describe('not evaluation', function() {
it('should return true if the condition evaluates to false', function() {
- assert.isTrue(conditionTreeEvaluator.evaluate(['not', conditionA], function() { return false; }));
+ assert.isTrue(
+ conditionTreeEvaluator.evaluate(['not', conditionA], function() {
+ return false;
+ })
+ );
});
it('should return false if the condition evaluates to true', function() {
- assert.isFalse(conditionTreeEvaluator.evaluate(['not', conditionB], function() { return true; }));
+ assert.isFalse(
+ conditionTreeEvaluator.evaluate(['not', conditionB], function() {
+ return true;
+ })
+ );
});
it('should return the result of negating the first condition, and ignore any additional conditions', function() {
- var result = conditionTreeEvaluator.evaluate(
- ['not', '1', '2', '1'],
- function(id) { return id === '1'; }
- );
+ var result = conditionTreeEvaluator.evaluate(['not', '1', '2', '1'], function(id) {
+ return id === '1';
+ });
assert.isFalse(result);
- result = conditionTreeEvaluator.evaluate(
- ['not', '1', '2', '1'],
- function(id) { return id === '2'; }
- );
+ result = conditionTreeEvaluator.evaluate(['not', '1', '2', '1'], function(id) {
+ return id === '2';
+ });
assert.isTrue(result);
- result = conditionTreeEvaluator.evaluate(
- ['not', '1', '2', '3'],
- function(id) { return id === '1' ? null : id === '3'; }
- );
+ result = conditionTreeEvaluator.evaluate(['not', '1', '2', '3'], function(id) {
+ return id === '1' ? null : id === '3';
+ });
assert.isNull(result);
});
describe('null handling', function() {
it('should return null when operand evaluates to null', function() {
- assert.isNull(conditionTreeEvaluator.evaluate(['not', conditionA], function() { return null; }));
+ assert.isNull(
+ conditionTreeEvaluator.evaluate(['not', conditionA], function() {
+ return null;
+ })
+ );
});
it('should return null when there are no operands', function() {
- assert.isNull(conditionTreeEvaluator.evaluate(['not'], function() { return null; }));
+ assert.isNull(
+ conditionTreeEvaluator.evaluate(['not'], function() {
+ return null;
+ })
+ );
});
});
});
@@ -225,14 +218,12 @@ describe('lib/core/condition_tree_evaluator', function() {
var leafEvaluator = sinon.stub();
leafEvaluator.onCall(0).returns(true);
leafEvaluator.onCall(1).returns(false);
- assert.isTrue(conditionTreeEvaluator.evaluate(
- [conditionA, conditionB],
- leafEvaluator
- ));
- assert.isFalse(conditionTreeEvaluator.evaluate(
- [conditionA, conditionB],
- function() { return false; }
- ));
+ assert.isTrue(conditionTreeEvaluator.evaluate([conditionA, conditionB], leafEvaluator));
+ assert.isFalse(
+ conditionTreeEvaluator.evaluate([conditionA, conditionB], function() {
+ return false;
+ })
+ );
});
});
});
diff --git a/lib/core/condition_tree_evaluator/index.ts b/lib/core/condition_tree_evaluator/index.ts
new file mode 100644
index 000000000..7b0c8df9d
--- /dev/null
+++ b/lib/core/condition_tree_evaluator/index.ts
@@ -0,0 +1,131 @@
+/****************************************************************************
+ * Copyright 2018, 2021, 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. *
+ ***************************************************************************/
+
+const AND_CONDITION = 'and';
+const OR_CONDITION = 'or';
+const NOT_CONDITION = 'not';
+
+export const DEFAULT_OPERATOR_TYPES = [AND_CONDITION, OR_CONDITION, NOT_CONDITION];
+export type ConditionTree = Leaf | unknown[];
+
+type LeafEvaluator = (leaf: Leaf) => boolean | null;
+
+/**
+ * Top level method to evaluate conditions
+ * @param {ConditionTree} conditions Nested array of and/or conditions, or a single leaf
+ * condition value of any type
+ * Example: ['and', '0', ['or', '1', '2']]
+ * @param {LeafEvaluator} leafEvaluator Function which will be called to evaluate leaf condition
+ * values
+ * @return {?boolean} Result of evaluating the conditions using the operator
+ * rules and the leaf evaluator. A return value of null
+ * indicates that the conditions are invalid or unable to be
+ * evaluated.
+ */
+export function evaluate(conditions: ConditionTree, leafEvaluator: LeafEvaluator): boolean | null {
+ if (Array.isArray(conditions)) {
+ let firstOperator = conditions[0];
+ let restOfConditions = conditions.slice(1);
+
+ if (typeof firstOperator === 'string' && DEFAULT_OPERATOR_TYPES.indexOf(firstOperator) === -1) {
+ // Operator to apply is not explicit - assume 'or'
+ firstOperator = OR_CONDITION;
+ restOfConditions = conditions;
+ }
+
+ switch (firstOperator) {
+ case AND_CONDITION:
+ return andEvaluator(restOfConditions, leafEvaluator);
+ case NOT_CONDITION:
+ return notEvaluator(restOfConditions, leafEvaluator);
+ default:
+ // firstOperator is OR_CONDITION
+ return orEvaluator(restOfConditions, leafEvaluator);
+ }
+ }
+
+ const leafCondition = conditions;
+ return leafEvaluator(leafCondition);
+}
+
+/**
+ * Evaluates an array of conditions as if the evaluator had been applied
+ * to each entry and the results AND-ed together.
+ * @param {unknown[]} conditions Array of conditions ex: [operand_1, operand_2]
+ * @param {LeafEvaluator} leafEvaluator Function which will be called to evaluate leaf condition values
+ * @return {?boolean} Result of evaluating the conditions. A return value of null
+ * indicates that the conditions are invalid or unable to be
+ * evaluated.
+ */
+function andEvaluator(conditions: ConditionTree, leafEvaluator: LeafEvaluator): boolean | null {
+ let sawNullResult = false;
+ if (Array.isArray(conditions)) {
+ for (let i = 0; i < conditions.length; i++) {
+ const conditionResult = evaluate(conditions[i] as ConditionTree, leafEvaluator);
+ if (conditionResult === false) {
+ return false;
+ }
+ if (conditionResult === null) {
+ sawNullResult = true;
+ }
+ }
+ return sawNullResult ? null : true;
+ }
+ return null;
+}
+
+/**
+ * Evaluates an array of conditions as if the evaluator had been applied
+ * to a single entry and NOT was applied to the result.
+ * @param {unknown[]} conditions Array of conditions ex: [operand_1]
+ * @param {LeafEvaluator} leafEvaluator Function which will be called to evaluate leaf condition values
+ * @return {?boolean} Result of evaluating the conditions. A return value of null
+ * indicates that the conditions are invalid or unable to be
+ * evaluated.
+ */
+function notEvaluator(conditions: ConditionTree, leafEvaluator: LeafEvaluator): boolean | null {
+ if (Array.isArray(conditions) && conditions.length > 0) {
+ const result = evaluate(conditions[0] as ConditionTree, leafEvaluator);
+ return result === null ? null : !result;
+ }
+ return null;
+}
+
+/**
+ * Evaluates an array of conditions as if the evaluator had been applied
+ * to each entry and the results OR-ed together.
+ * @param {unknown[]} conditions Array of conditions ex: [operand_1, operand_2]
+ * @param {LeafEvaluator} leafEvaluator Function which will be called to evaluate leaf condition values
+ * @return {?boolean} Result of evaluating the conditions. A return value of null
+ * indicates that the conditions are invalid or unable to be
+ * evaluated.
+ */
+function orEvaluator(conditions: ConditionTree, leafEvaluator: LeafEvaluator): boolean | null {
+ let sawNullResult = false;
+ if (Array.isArray(conditions)) {
+ for (let i = 0; i < conditions.length; i++) {
+ const conditionResult = evaluate(conditions[i] as ConditionTree, leafEvaluator);
+ if (conditionResult === true) {
+ return true;
+ }
+ if (conditionResult === null) {
+ sawNullResult = true;
+ }
+ }
+ return sawNullResult ? null : false;
+ }
+ return null;
+}
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/lib/core/custom_attribute_condition_evaluator/index.tests.js b/lib/core/custom_attribute_condition_evaluator/index.tests.js
new file mode 100644
index 000000000..12607e001
--- /dev/null
+++ b/lib/core/custom_attribute_condition_evaluator/index.tests.js
@@ -0,0 +1,1126 @@
+/****************************************************************************
+ * Copyright 2018-2020, 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. *
+ ***************************************************************************/
+import sinon from 'sinon';
+import { assert } from 'chai';
+import { sprintf } from '../../utils/fns';
+
+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',
+ value: 'safari',
+ type: 'custom_attribute',
+};
+var booleanCondition = {
+ name: 'is_firefox',
+ value: true,
+ type: 'custom_attribute',
+};
+var integerCondition = {
+ name: 'num_users',
+ value: 10,
+ type: 'custom_attribute',
+};
+var doubleCondition = {
+ name: 'pi_value',
+ value: 3.14,
+ type: 'custom_attribute',
+};
+
+var getMockUserContext = (attributes) => ({
+ getAttributes: () => ({ ... (attributes || {})})
+});
+
+var createLogger = () => ({
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => createLogger(),
+});
+
+describe('lib/core/custom_attribute_condition_evaluator', function() {
+ var mockLogger = createLogger();
+
+ beforeEach(function() {
+ sinon.stub(mockLogger, 'error');
+ sinon.stub(mockLogger, 'debug');
+ sinon.stub(mockLogger, 'info');
+ sinon.stub(mockLogger, 'warn');
+ });
+
+ afterEach(function() {
+ 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() {
+ var userAttributes = {
+ browser_type: 'safari',
+ };
+
+ 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() {
+ var userAttributes = {
+ browser_type: 'firefox',
+ };
+
+ assert.isFalse(customAttributeEvaluator.getEvaluator().evaluate(browserConditionSafari, getMockUserContext(userAttributes)));
+ });
+
+ it('should evaluate different typed attributes', function() {
+ var userAttributes = {
+ browser_type: 'safari',
+ is_firefox: true,
+ num_users: 10,
+ pi_value: 3.14,
+ };
+
+ 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.getEvaluator(mockLogger).evaluate(
+ invalidMatchCondition,
+ getMockUserContext({ weird_condition: 'bye' })
+ );
+ assert.isNull(result);
+ 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() {
+ var existsCondition = {
+ match: 'exists',
+ name: 'input_value',
+ type: 'custom_attribute',
+ };
+
+ it('should return false if there is no user-provided value', function() {
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(existsCondition, getMockUserContext({}));
+ assert.isFalse(result);
+ 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.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.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.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.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.getEvaluator().evaluate(existsCondition, getMockUserContext({ input_value: true }));
+ assert.isTrue(result);
+ });
+ });
+
+ describe('exact match type', function() {
+ describe('with a string condition value', function() {
+ var exactStringCondition = {
+ match: 'exact',
+ name: 'favorite_constellation',
+ type: 'custom_attribute',
+ value: 'Lacerta',
+ };
+
+ it('should return true if the user-provided value is equal to the condition value', function() {
+ var result = customAttributeEvaluator.getEvaluator().evaluate(
+ exactStringCondition,
+ getMockUserContext({ favorite_constellation: 'Lacerta' })
+ );
+ assert.isTrue(result);
+ });
+
+ it('should return false if the user-provided value is not equal to the condition value', function() {
+ var result = customAttributeEvaluator.getEvaluator().evaluate(
+ exactStringCondition,
+ getMockUserContext({ favorite_constellation: 'The Big Dipper' })
+ );
+ assert.isFalse(result);
+ });
+
+ it('should log and return null if condition value is of an unexpected type', function() {
+ var invalidExactCondition = {
+ match: 'exact',
+ name: 'favorite_constellation',
+ type: 'custom_attribute',
+ value: [],
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ invalidExactCondition,
+ getMockUserContext({ favorite_constellation: 'Lacerta' })
+ );
+ assert.isNull(result);
+ 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.getEvaluator(mockLogger).evaluate(
+ exactStringCondition,
+ getMockUserContext(unexpectedTypeUserAttributes)
+ );
+ assert.isNull(result);
+
+ var userValue = unexpectedTypeUserAttributes[exactStringCondition.name];
+ var userValueType = typeof userValue;
+ 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.getEvaluator(mockLogger).evaluate(
+ exactStringCondition,
+ getMockUserContext({ favorite_constellation: null })
+ );
+ assert.isNull(result);
+ 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.getEvaluator(mockLogger).evaluate(exactStringCondition, getMockUserContext({}));
+ assert.isNull(result);
+ 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.getEvaluator(mockLogger).evaluate(
+ exactStringCondition,
+ getMockUserContext(unexpectedTypeUserAttributes)
+ );
+ assert.isNull(result);
+ var userValue = unexpectedTypeUserAttributes[exactStringCondition.name];
+ var userValueType = typeof userValue;
+ sinon.assert.calledOnce(mockLogger.warn);
+
+ assert.deepEqual(mockLogger.warn.args[0], [UNEXPECTED_TYPE, JSON.stringify(exactStringCondition), userValueType, exactStringCondition.name]);
+ });
+ });
+
+ describe('with a number condition value', function() {
+ var exactNumberCondition = {
+ match: 'exact',
+ name: 'lasers_count',
+ type: 'custom_attribute',
+ value: 9000,
+ };
+
+ it('should return true if the user-provided value is equal to the condition value', function() {
+ 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.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.getEvaluator(mockLogger).evaluate(
+ exactNumberCondition,
+ getMockUserContext(unexpectedTypeUserAttributes1)
+ );
+ assert.isNull(result);
+
+ var unexpectedTypeUserAttributes2 = { lasers_count: '1000' };
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ exactNumberCondition,
+ getMockUserContext(unexpectedTypeUserAttributes2)
+ );
+ assert.isNull(result);
+
+ var userValue1 = unexpectedTypeUserAttributes1[exactNumberCondition.name];
+ var userValueType1 = typeof userValue1;
+ var userValue2 = unexpectedTypeUserAttributes2[exactNumberCondition.name];
+ var userValueType2 = typeof userValue2;
+ 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.getEvaluator(mockLogger).evaluate(exactNumberCondition, getMockUserContext({ lasers_count: -Infinity }));
+ assert.isNull(result);
+
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ exactNumberCondition,
+ getMockUserContext({ lasers_count: -Math.pow(2, 53) - 2 })
+ );
+ assert.isNull(result);
+
+ assert.strictEqual(2, mockLogger.warn.callCount);
+
+ 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.getEvaluator(mockLogger).evaluate(exactNumberCondition, getMockUserContext({}));
+ assert.isNull(result);
+ });
+
+ it('should log and return null if the condition value is not finite', function() {
+ var invalidValueCondition1 = {
+ match: 'exact',
+ name: 'lasers_count',
+ type: 'custom_attribute',
+ value: Infinity,
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition1, getMockUserContext({ lasers_count: 9000 }));
+ assert.isNull(result);
+
+ var 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 }));
+ assert.isNull(result);
+
+ assert.strictEqual(2, mockLogger.warn.callCount);
+
+ assert.deepEqual(mockLogger.warn.args[0], [UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition1)]);
+
+ assert.deepEqual(mockLogger.warn.args[1], [UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition2)]);
+ });
+ });
+
+ describe('with a boolean condition value', function() {
+ var exactBoolCondition = {
+ match: 'exact',
+ name: 'did_register_user',
+ type: 'custom_attribute',
+ value: false,
+ };
+
+ it('should return true if the user-provided value is equal to the condition value', function() {
+ 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.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.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.getEvaluator(mockLogger).evaluate(exactBoolCondition, getMockUserContext({}));
+ assert.isNull(result);
+ });
+ });
+ });
+
+ describe('substring match type', function() {
+ var substringCondition = {
+ match: 'substring',
+ name: 'headline_text',
+ type: 'custom_attribute',
+ value: 'buy now',
+ };
+
+ it('should return true if the condition value is a substring of the user-provided value', function() {
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ substringCondition,
+ getMockUserContext({
+ headline_text: 'Limited time, buy now!',
+ })
+ );
+ assert.isTrue(result);
+ });
+
+ it('should return false if the user-provided value is not a substring of the condition value', function() {
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ substringCondition,
+ getMockUserContext({
+ headline_text: 'Breaking news!',
+ })
+ );
+ assert.isFalse(result);
+ });
+
+ it('should log and return null if the user-provided value is not a string', function() {
+ var unexpectedTypeUserAttributes = { headline_text: 10 };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ substringCondition,
+ getMockUserContext(unexpectedTypeUserAttributes)
+ );
+ assert.isNull(result);
+ var userValue = unexpectedTypeUserAttributes[substringCondition.name];
+ var userValueType = typeof userValue;
+ 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() {
+ var nonStringCondition = {
+ match: 'substring',
+ name: 'headline_text',
+ type: 'custom_attribute',
+ value: 10,
+ };
+
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(nonStringCondition, getMockUserContext({ headline_text: 'hello' }));
+ assert.isNull(result);
+ 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.getEvaluator(mockLogger).evaluate(substringCondition, getMockUserContext({ headline_text: null }));
+ assert.isNull(result);
+ 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.getEvaluator(mockLogger).evaluate(substringCondition, getMockUserContext({}));
+ assert.isNull(result);
+ });
+ });
+
+ describe('greater than match type', function() {
+ var gtCondition = {
+ match: 'gt',
+ name: 'meters_travelled',
+ type: 'custom_attribute',
+ value: 48.2,
+ };
+
+ it('should return true if the user-provided value is greater than the condition value', function() {
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ gtCondition,
+ getMockUserContext({
+ meters_travelled: 58.4,
+ })
+ );
+ assert.isTrue(result);
+ });
+
+ it('should return false if the user-provided value is not greater than the condition value', function() {
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ gtCondition,
+ getMockUserContext({
+ meters_travelled: 20,
+ })
+ );
+ assert.isFalse(result);
+ });
+
+ 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.getEvaluator(mockLogger).evaluate(
+ gtCondition,
+ getMockUserContext(unexpectedTypeUserAttributes1)
+ );
+ assert.isNull(result);
+
+ var unexpectedTypeUserAttributes2 = { meters_travelled: '1000' };
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ gtCondition,
+ getMockUserContext(unexpectedTypeUserAttributes2)
+ );
+ assert.isNull(result);
+
+ var userValue1 = unexpectedTypeUserAttributes1[gtCondition.name];
+ var userValueType1 = typeof userValue1;
+ var userValue2 = unexpectedTypeUserAttributes2[gtCondition.name];
+ var userValueType2 = typeof userValue2;
+ 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.getEvaluator(mockLogger).evaluate(
+ gtCondition,
+ getMockUserContext({ meters_travelled: -Infinity })
+ );
+ assert.isNull(result);
+
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ gtCondition,
+ getMockUserContext({ meters_travelled: Math.pow(2, 53) + 2 })
+ );
+ assert.isNull(result);
+
+ assert.strictEqual(2, mockLogger.warn.callCount);
+
+ 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.getEvaluator(mockLogger).evaluate(gtCondition, getMockUserContext({ meters_travelled: null }));
+ assert.isNull(result);
+ 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.getEvaluator(mockLogger).evaluate(gtCondition, getMockUserContext({}));
+ assert.isNull(result);
+ });
+
+ it('should return null if the condition value is not a finite number', function() {
+ var userAttributes = { meters_travelled: 58.4 };
+ var invalidValueCondition = {
+ match: 'gt',
+ name: 'meters_travelled',
+ type: 'custom_attribute',
+ value: Infinity,
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+ assert.isNull(result);
+
+ invalidValueCondition.value = null;
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+ assert.isNull(result);
+
+ invalidValueCondition.value = Math.pow(2, 53) + 2;
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+ assert.isNull(result);
+
+ sinon.assert.calledThrice(mockLogger.warn);
+
+ assert.deepEqual(mockLogger.warn.args[2], [UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition)]);
+ });
+ });
+
+ describe('less than match type', function() {
+ var ltCondition = {
+ match: 'lt',
+ name: 'meters_travelled',
+ type: 'custom_attribute',
+ value: 48.2,
+ };
+
+ it('should return true if the user-provided value is less than the condition value', function() {
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ ltCondition,
+ getMockUserContext({
+ meters_travelled: 10,
+ })
+ );
+ assert.isTrue(result);
+ });
+
+ it('should return false if the user-provided value is not less than the condition value', function() {
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ ltCondition,
+ getMockUserContext({
+ meters_travelled: 64.64,
+ })
+ );
+ assert.isFalse(result);
+ });
+
+ it('should log and return null if the user-provided value is not a number', function() {
+ var unexpectedTypeUserAttributes1 = { meters_travelled: true };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ ltCondition,
+ getMockUserContext(unexpectedTypeUserAttributes1)
+ );
+ assert.isNull(result);
+
+ var unexpectedTypeUserAttributes2 = { meters_travelled: '48.2' };
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ ltCondition,
+ getMockUserContext(unexpectedTypeUserAttributes2)
+ );
+ assert.isNull(result);
+
+ var userValue1 = unexpectedTypeUserAttributes1[ltCondition.name];
+ var userValueType1 = typeof userValue1;
+ var userValue2 = unexpectedTypeUserAttributes2[ltCondition.name];
+ var userValueType2 = typeof userValue2;
+
+ 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.getEvaluator(mockLogger).evaluate(
+ ltCondition,
+ getMockUserContext({
+ meters_travelled: Infinity,
+ })
+ );
+ assert.isNull(result);
+
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ ltCondition,
+ getMockUserContext({
+ meters_travelled: Math.pow(2, 53) + 2,
+ })
+ );
+ assert.isNull(result);
+
+ assert.strictEqual(2, mockLogger.warn.callCount);
+
+ 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.getEvaluator(mockLogger).evaluate(ltCondition, getMockUserContext({ meters_travelled: null }));
+ assert.isNull(result);
+ 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.getEvaluator(mockLogger).evaluate(ltCondition, getMockUserContext({}));
+ assert.isNull(result);
+ });
+
+ it('should return null if the condition value is not a finite number', function() {
+ var userAttributes = { meters_travelled: 10 };
+ var invalidValueCondition = {
+ match: 'lt',
+ name: 'meters_travelled',
+ type: 'custom_attribute',
+ value: Infinity,
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+ assert.isNull(result);
+
+ invalidValueCondition.value = {};
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+ assert.isNull(result);
+
+ invalidValueCondition.value = Math.pow(2, 53) + 2;
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(invalidValueCondition, getMockUserContext(userAttributes));
+ assert.isNull(result);
+
+ 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() {
+ var leCondition = {
+ match: 'le',
+ name: 'meters_travelled',
+ type: 'custom_attribute',
+ value: 48.2,
+ };
+
+ it('should return false if the user-provided value is greater than the condition value', function() {
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ leCondition,
+ getMockUserContext({
+ meters_travelled: 48.3,
+ })
+ );
+ assert.isFalse(result);
+ });
+
+ 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.getEvaluator(mockLogger).evaluate(
+ leCondition,
+ getMockUserContext({
+ meters_travelled: userValue,
+ })
+ );
+ assert.isTrue(result, `Got result ${result}. Failed for condition value: ${leCondition.value} and user value: ${userValue}`);
+ }
+ });
+ });
+
+
+ describe('greater than and equal to match type', function() {
+ var geCondition = {
+ match: 'ge',
+ name: 'meters_travelled',
+ type: 'custom_attribute',
+ value: 48.2,
+ };
+
+ it('should return false if the user-provided value is less than the condition value', function() {
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ geCondition,
+ getMockUserContext({
+ meters_travelled: 48,
+ })
+ );
+ assert.isFalse(result);
+ });
+
+ 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.getEvaluator(mockLogger).evaluate(
+ geCondition,
+ getMockUserContext({
+ meters_travelled: userValue,
+ })
+ );
+ assert.isTrue(result, `Got result ${result}. Failed for condition value: ${geCondition.value} and user value: ${userValue}`);
+ }
+ });
+ });
+
+ describe('semver greater than match type', function() {
+ var semvergtCondition = {
+ match: 'semver_gt',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: '2.0.0',
+ };
+
+ it('should return true if the user-provided version is greater than the condition version', function() {
+ var versions = [
+ ['1.8.1', '1.9']
+ ];
+ for (let [targetVersion, userVersion] of versions) {
+ var customSemvergtCondition = {
+ match: 'semver_gt',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvergtCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+ assert.isTrue(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`);
+ }
+ });
+
+ it('should return false if the user-provided version is not greater than the condition version', function() {
+ var versions = [
+ ['2.0.1', '2.0.1'],
+ ['2.0', '2.0.0'],
+ ['2.0', '2.0.1'],
+ ['2.0.1', '2.0.0'],
+ ];
+ for (let [targetVersion, userVersion] of versions) {
+ var customSemvergtCondition = {
+ match: 'semver_gt',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvergtCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+ assert.isFalse(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`);
+ }
+ });
+
+ it('should log and return null if the user-provided version is not a string', function() {
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semvergtCondition,
+ getMockUserContext({
+ app_version: 22,
+ })
+ );
+ assert.isNull(result);
+
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semvergtCondition,
+ getMockUserContext({
+ app_version: false,
+ })
+ );
+ assert.isNull(result);
+
+ 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.getEvaluator(mockLogger).evaluate(semvergtCondition, getMockUserContext({ app_version: null }));
+ assert.isNull(result);
+ 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.getEvaluator(mockLogger).evaluate(semvergtCondition, getMockUserContext({}));
+ assert.isNull(result);
+ });
+ });
+
+ describe('semver less than match type', function() {
+ var semverltCondition = {
+ match: 'semver_lt',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: '2.0.0',
+ };
+
+ it('should return false if the user-provided version is greater than the condition version', function() {
+ var versions = [
+ ['2.0.0', '2.0.1'],
+ ['1.9', '2.0.0'],
+ ['2.0.0', '2.0.0'],
+
+ ];
+ for (let [targetVersion, userVersion] of versions) {
+ var customSemverltCondition = {
+ match: 'semver_lt',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemverltCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+ assert.isFalse(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`);
+ }
+ });
+
+ it('should return true if the user-provided version is less than the condition version', function() {
+ var versions = [
+ ['2.0.1', '2.0.0'],
+ ['2.0.0', '1.9']
+ ];
+ for (let [targetVersion, userVersion] of versions) {
+ var customSemverltCondition = {
+ match: 'semver_lt',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemverltCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+ assert.isTrue(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`);
+ }
+ });
+
+ it('should log and return null if the user-provided version is not a string', function() {
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semverltCondition,
+ getMockUserContext({
+ app_version: 22,
+ })
+ );
+ assert.isNull(result);
+
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semverltCondition,
+ getMockUserContext({
+ app_version: false,
+ })
+ );
+ assert.isNull(result);
+
+ 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.getEvaluator(mockLogger).evaluate(semverltCondition, getMockUserContext({ app_version: null }));
+ assert.isNull(result);
+ 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.getEvaluator(mockLogger).evaluate(semverltCondition, getMockUserContext({}));
+ assert.isNull(result);
+ });
+ });
+
+ describe('semver equal to match type', function() {
+ var semvereqCondition = {
+ match: 'semver_eq',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: '2.0',
+ };
+
+ it('should return false if the user-provided version is greater than the condition version', function() {
+ var versions = [
+ ['2.0.0', '2.0.1'],
+ ['2.0.1', '2.0.0'],
+ ['1.9.1', '1.9']
+ ];
+ for (let [targetVersion, userVersion] of versions) {
+ var customSemvereqCondition = {
+ match: 'semver_eq',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvereqCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+ assert.isFalse(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`);
+ }
+ });
+
+ it('should return true if the user-provided version is equal to the condition version', function() {
+ var versions = [
+ ['2.0.1', '2.0.1'],
+ ['1.9', '1.9.1']
+ ];
+ for (let [targetVersion, userVersion] of versions) {
+ var customSemvereqCondition = {
+ match: 'semver_eq',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvereqCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+ assert.isTrue(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`);
+ }
+ });
+
+ it('should log and return null if the user-provided version is not a string', function() {
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semvereqCondition,
+ getMockUserContext({
+ app_version: 22,
+ })
+ );
+ assert.isNull(result);
+
+ result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semvereqCondition,
+ getMockUserContext({
+ app_version: false,
+ })
+ );
+ assert.isNull(result);
+
+ 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.getEvaluator(mockLogger).evaluate(semvereqCondition, getMockUserContext({ app_version: null }));
+ assert.isNull(result);
+ 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.getEvaluator(mockLogger).evaluate(semvereqCondition, getMockUserContext({}));
+ assert.isNull(result);
+ });
+ });
+
+ describe('semver less than or equal to match type', function() {
+ var semverleCondition = {
+ match: 'semver_le',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: '2.0.0',
+ };
+
+ it('should return false if the user-provided version is greater than the condition version', function() {
+ var versions = [
+ ['2.0.0', '2.0.1']
+ ]
+ for (let [targetVersion, userVersion] of versions) {
+ var customSemvereqCondition = {
+ match: 'semver_le',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvereqCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+ assert.isFalse(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`);
+ }
+ });
+
+ it('should return true if the user-provided version is less than or equal to the condition version', function() {
+ var versions = [
+ ['2.0.1', '2.0.0'],
+ ['2.0.1', '2.0.1'],
+ ['1.9', '1.9.1'],
+ ['1.9.1', '1.9'],
+ ]; for (let [targetVersion, userVersion] of versions) {
+ var customSemvereqCondition = {
+ match: 'semver_le',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvereqCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+ assert.isTrue(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`);
+ }
+ });
+
+ it('should return true if the user-provided version is equal to the condition version', function() {
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ semverleCondition,
+ getMockUserContext({
+ app_version: '2.0',
+ })
+ );
+ assert.isTrue(result);
+ });
+ });
+
+ describe('semver greater than or equal to match type', function() {
+ var semvergeCondition = {
+ match: 'semver_ge',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: '2.0',
+ };
+
+ it('should return true if the user-provided version is greater than or equal to the condition version', function() {
+ var versions = [
+ ['2.0.0', '2.0.1'],
+ ['2.0.1', '2.0.1'],
+ ['1.9', '1.9.1']
+ ];
+ for (let [targetVersion, userVersion] of versions) {
+ var customSemvereqCondition = {
+ match: 'semver_ge',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvereqCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+ assert.isTrue(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`);
+ }
+ });
+
+ it('should return false if the user-provided version is less than the condition version', function() {
+ var versions = [
+ ['2.0.1', '2.0.0'],
+ ['1.9.1', '1.9']
+ ];
+ for (let [targetVersion, userVersion] of versions) {
+ var customSemvereqCondition = {
+ match: 'semver_ge',
+ name: 'app_version',
+ type: 'custom_attribute',
+ value: targetVersion,
+ };
+ var result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(
+ customSemvereqCondition,
+ getMockUserContext({
+ app_version: userVersion,
+ })
+ );
+ assert.isFalse(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`);
+ }
+ });
+ });
+});
diff --git a/lib/core/custom_attribute_condition_evaluator/index.ts b/lib/core/custom_attribute_condition_evaluator/index.ts
new file mode 100644
index 000000000..797a7d4e0
--- /dev/null
+++ b/lib/core/custom_attribute_condition_evaluator/index.ts
@@ -0,0 +1,480 @@
+/****************************************************************************
+ * Copyright 2018-2019, 2020, 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. *
+ ***************************************************************************/
+import { Condition, OptimizelyUserContext } from '../../shared_types';
+
+import fns from '../../utils/fns';
+import { compareVersion } from '../../utils/semantic_version';
+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';
+const GREATER_OR_EQUAL_THAN_MATCH_TYPE = 'ge';
+const GREATER_THAN_MATCH_TYPE = 'gt';
+const LESS_OR_EQUAL_THAN_MATCH_TYPE = 'le';
+const LESS_THAN_MATCH_TYPE = 'lt';
+const SEMVER_EXACT_MATCH_TYPE = 'semver_eq';
+const SEMVER_GREATER_OR_EQUAL_THAN_MATCH_TYPE = 'semver_ge';
+const SEMVER_GREATER_THAN_MATCH_TYPE = 'semver_gt';
+const SEMVER_LESS_OR_EQUAL_THAN_MATCH_TYPE = 'semver_le';
+const SEMVER_LESS_THAN_MATCH_TYPE = 'semver_lt';
+const SUBSTRING_MATCH_TYPE = 'substring';
+
+const MATCH_TYPES = [
+ EXACT_MATCH_TYPE,
+ EXISTS_MATCH_TYPE,
+ GREATER_THAN_MATCH_TYPE,
+ GREATER_OR_EQUAL_THAN_MATCH_TYPE,
+ LESS_THAN_MATCH_TYPE,
+ LESS_OR_EQUAL_THAN_MATCH_TYPE,
+ SUBSTRING_MATCH_TYPE,
+ SEMVER_EXACT_MATCH_TYPE,
+ SEMVER_LESS_THAN_MATCH_TYPE,
+ SEMVER_LESS_OR_EQUAL_THAN_MATCH_TYPE,
+ SEMVER_GREATER_THAN_MATCH_TYPE,
+ SEMVER_GREATER_OR_EQUAL_THAN_MATCH_TYPE
+];
+
+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;
+EVALUATORS_BY_MATCH_TYPE[EXISTS_MATCH_TYPE] = existsEvaluator;
+EVALUATORS_BY_MATCH_TYPE[GREATER_THAN_MATCH_TYPE] = greaterThanEvaluator;
+EVALUATORS_BY_MATCH_TYPE[GREATER_OR_EQUAL_THAN_MATCH_TYPE] = greaterThanOrEqualEvaluator;
+EVALUATORS_BY_MATCH_TYPE[LESS_THAN_MATCH_TYPE] = lessThanEvaluator;
+EVALUATORS_BY_MATCH_TYPE[LESS_OR_EQUAL_THAN_MATCH_TYPE] = lessThanOrEqualEvaluator;
+EVALUATORS_BY_MATCH_TYPE[SUBSTRING_MATCH_TYPE] = substringEvaluator;
+EVALUATORS_BY_MATCH_TYPE[SEMVER_EXACT_MATCH_TYPE] = semverEqualEvaluator;
+EVALUATORS_BY_MATCH_TYPE[SEMVER_GREATER_THAN_MATCH_TYPE] = semverGreaterThanEvaluator;
+EVALUATORS_BY_MATCH_TYPE[SEMVER_GREATER_OR_EQUAL_THAN_MATCH_TYPE] = semverGreaterThanOrEqualEvaluator;
+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.
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @return {?boolean} true/false if the given user attributes match/don't match the given condition,
+ * null if the given user attributes and condition can't be evaluated
+ * TODO: Change to accept and object with named properties
+ */
+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(UNKNOWN_MATCH_TYPE, JSON.stringify(condition));
+ return null;
+ }
+
+ const attributeKey = condition.name;
+ if (!userAttributes.hasOwnProperty(attributeKey) && conditionMatch != EXISTS_MATCH_TYPE) {
+ logger?.debug(
+ MISSING_ATTRIBUTE_VALUE, JSON.stringify(condition), attributeKey
+ );
+ return null;
+ }
+
+ let evaluatorForMatch;
+ if (!conditionMatch) {
+ evaluatorForMatch = exactEvaluator;
+ } else {
+ evaluatorForMatch = EVALUATORS_BY_MATCH_TYPE[conditionMatch] || exactEvaluator;
+ }
+
+ return evaluatorForMatch(condition, user, logger);
+}
+
+/**
+ * Returns true if the value is valid for exact conditions. Valid values include
+ * strings, booleans, and numbers that aren't NaN, -Infinity, or Infinity.
+ * @param value
+ * @returns {boolean}
+ */
+function isValueTypeValidForExactConditions(value: unknown): boolean {
+ return typeof value === 'string' || typeof value === 'boolean' || fns.isNumber(value);
+}
+
+/**
+ * Evaluate the given exact match condition for the given user attributes
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @return {?boolean} true if the user attribute value is equal (===) to the condition value,
+ * false if the user attribute value is not equal (!==) to the condition value,
+ * null if the condition value or user attribute value has an invalid type, or
+ * if there is a mismatch between the user attribute type and the condition value
+ * type
+ */
+function exactEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
+ const userAttributes = user.getAttributes();
+ const conditionValue = condition.value;
+ const conditionValueType = typeof conditionValue;
+ const conditionName = condition.name;
+ const userValue = userAttributes[conditionName];
+ const userValueType = typeof userValue;
+
+ if (
+ !isValueTypeValidForExactConditions(conditionValue) ||
+ (fns.isNumber(conditionValue) && !fns.isSafeInteger(conditionValue))
+ ) {
+ logger?.warn(
+ UNEXPECTED_CONDITION_VALUE, JSON.stringify(condition)
+ );
+ return null;
+ }
+
+ if (userValue === null) {
+ logger?.debug(
+ UNEXPECTED_TYPE_NULL, JSON.stringify(condition), conditionName
+ );
+ return null;
+ }
+
+ if (!isValueTypeValidForExactConditions(userValue) || conditionValueType !== userValueType) {
+ logger?.warn(
+ UNEXPECTED_TYPE, JSON.stringify(condition), userValueType, conditionName
+ );
+ return null;
+ }
+
+ if (fns.isNumber(userValue) && !fns.isSafeInteger(userValue)) {
+ logger?.warn(
+ OUT_OF_BOUNDS, JSON.stringify(condition), conditionName
+ );
+ return null;
+ }
+
+ return conditionValue === userValue;
+}
+
+/**
+ * Evaluate the given exists match condition for the given user attributes
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @returns {boolean} true if both:
+ * 1) the user attributes have a value for the given condition, and
+ * 2) the user attribute value is neither null nor undefined
+ * Returns false otherwise
+ */
+function existsEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean {
+ const userAttributes = user.getAttributes();
+ const userValue = userAttributes[condition.name];
+ return typeof userValue !== 'undefined' && userValue !== null;
+}
+
+/**
+ * Validate user and condition values
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @returns {?boolean} true if values are valid,
+ * false if values are not valid
+ */
+function validateValuesForNumericCondition(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean {
+ const userAttributes = user.getAttributes();
+ const conditionName = condition.name;
+ const userValue = userAttributes[conditionName];
+ const userValueType = typeof userValue;
+ const conditionValue = condition.value;
+
+ if (conditionValue === null || !fns.isSafeInteger(conditionValue)) {
+ logger?.warn(
+ UNEXPECTED_CONDITION_VALUE, JSON.stringify(condition)
+ );
+ return false;
+ }
+
+ if (userValue === null) {
+ logger?.debug(
+ UNEXPECTED_TYPE_NULL, JSON.stringify(condition), conditionName
+ );
+ return false;
+ }
+
+ if (!fns.isNumber(userValue)) {
+ logger?.warn(
+ UNEXPECTED_TYPE, JSON.stringify(condition), userValueType, conditionName
+ );
+ return false;
+ }
+
+ if (!fns.isSafeInteger(userValue)) {
+ logger?.warn(
+ OUT_OF_BOUNDS, JSON.stringify(condition), conditionName
+ );
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Evaluate the given greater than match condition for the given user attributes
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @returns {?boolean} true if the user attribute value is greater than the condition value,
+ * false if the user attribute value is less than or equal to the condition value,
+ * null if the condition value isn't a number or the user attribute value
+ * isn't a number
+ */
+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, logger) || conditionValue === null) {
+ return null;
+ }
+ return userValue! > conditionValue;
+}
+
+/**
+ * Evaluate the given greater or equal than match condition for the given user attributes
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @returns {?Boolean} true if the user attribute value is greater or equal than the condition value,
+ * false if the user attribute value is less than to the condition value,
+ * null if the condition value isn't a number or the user attribute value isn't a
+ * number
+ */
+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, logger) || conditionValue === null) {
+ return null;
+ }
+
+ return userValue! >= conditionValue;
+}
+
+/**
+ * Evaluate the given less than match condition for the given user attributes
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @returns {?boolean} true if the user attribute value is less than the condition value,
+ * false if the user attribute value is greater than or equal to the condition value,
+ * null if the condition value isn't a number or the user attribute value isn't a
+ * number
+ */
+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, logger) || conditionValue === null) {
+ return null;
+ }
+
+ return userValue! < conditionValue;
+}
+
+/**
+ * Evaluate the given less or equal than match condition for the given user attributes
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @returns {?Boolean} true if the user attribute value is less or equal than the condition value,
+ * false if the user attribute value is greater than to the condition value,
+ * null if the condition value isn't a number or the user attribute value isn't a
+ * number
+ */
+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, logger) || conditionValue === null) {
+ return null;
+ }
+
+ return userValue! <= conditionValue;
+}
+
+/**
+ * Evaluate the given substring match condition for the given user attributes
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @returns {?Boolean} true if the condition value is a substring of the user attribute value,
+ * false if the condition value is not a substring of the user attribute value,
+ * null if the condition value isn't a string or the user attribute value
+ * isn't a string
+ */
+function substringEvaluator(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): boolean | null {
+ const userAttributes = user.getAttributes();
+ const conditionName = condition.name;
+ const userValue = userAttributes[condition.name];
+ const userValueType = typeof userValue;
+ const conditionValue = condition.value;
+
+ if (typeof conditionValue !== 'string') {
+ logger?.warn(
+ UNEXPECTED_CONDITION_VALUE, JSON.stringify(condition)
+ );
+ return null;
+ }
+
+ if (userValue === null) {
+ logger?.debug(
+ UNEXPECTED_TYPE_NULL, JSON.stringify(condition), conditionName
+ );
+ return null;
+ }
+
+ if (typeof userValue !== 'string') {
+ logger?.warn(
+ UNEXPECTED_TYPE, JSON.stringify(condition), userValueType, conditionName
+ );
+ return null;
+ }
+
+ return userValue.indexOf(conditionValue) !== -1;
+}
+
+/**
+ * Evaluate the given semantic version match condition for the given user attributes
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @returns {?number} returns compareVersion result
+ * null if the user attribute version has an invalid type
+ */
+function evaluateSemanticVersion(condition: Condition, user: OptimizelyUserContext, logger?: LoggerFacade): number | null {
+ const userAttributes = user.getAttributes();
+ const conditionName = condition.name;
+ const userValue = userAttributes[conditionName];
+ const userValueType = typeof userValue;
+ const conditionValue = condition.value;
+
+ if (typeof conditionValue !== 'string') {
+ logger?.warn(
+ UNEXPECTED_CONDITION_VALUE, JSON.stringify(condition)
+ );
+ return null;
+ }
+
+ if (userValue === null) {
+ logger?.debug(
+ UNEXPECTED_TYPE_NULL, JSON.stringify(condition), conditionName
+ );
+ return null;
+ }
+
+ if (typeof userValue !== 'string') {
+ logger?.warn(
+ UNEXPECTED_TYPE, JSON.stringify(condition), userValueType, conditionName
+ );
+ return null;
+ }
+
+ return compareVersion(conditionValue, userValue, logger);
+}
+
+/**
+ * Evaluate the given version match condition for the given user attributes
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @returns {?Boolean} true if the user attribute version is equal (===) to the condition version,
+ * 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, logger?: LoggerFacade): boolean | null {
+ const result = evaluateSemanticVersion(condition, user, logger);
+ if (result === null) {
+ return null;
+ }
+ return result === 0;
+}
+
+/**
+ * Evaluate the given version match condition for the given user attributes
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @returns {?Boolean} true if the user attribute version is greater (>) than the condition version,
+ * 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, logger?: LoggerFacade): boolean | null {
+ const result = evaluateSemanticVersion(condition, user, logger);
+ if (result === null) {
+ return null;
+ }
+ return result > 0;
+}
+
+/**
+ * Evaluate the given version match condition for the given user attributes
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @returns {?Boolean} true if the user attribute version is less (<) than the condition version,
+ * 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, logger?: LoggerFacade): boolean | null {
+ const result = evaluateSemanticVersion(condition, user, logger);
+ if (result === null) {
+ return null;
+ }
+ return result < 0;
+}
+
+/**
+ * Evaluate the given version match condition for the given user attributes
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @returns {?Boolean} true if the user attribute version is greater than or equal (>=) to the condition version,
+ * 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, logger?: LoggerFacade): boolean | null {
+ const result = evaluateSemanticVersion(condition, user, logger);
+ if (result === null) {
+ return null;
+ }
+ return result >= 0;
+}
+
+/**
+ * Evaluate the given version match condition for the given user attributes
+ * @param {Condition} condition
+ * @param {OptimizelyUserContext} user
+ * @returns {?Boolean} true if the user attribute version is less than or equal (<=) to the condition version,
+ * 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, 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/lib/core/decision/index.tests.js b/lib/core/decision/index.tests.js
new file mode 100644
index 000000000..3ae72b362
--- /dev/null
+++ b/lib/core/decision/index.tests.js
@@ -0,0 +1,105 @@
+/**
+ * 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.
+ */
+import { assert } from 'chai';
+import { rolloutDecisionObj, featureTestDecisionObj } from '../../tests/test_data';
+import * as decision from './';
+
+describe('lib/core/decision', function() {
+ describe('getExperimentKey method', function() {
+ it('should return empty string when experiment is null', function() {
+ var experimentKey = decision.getExperimentKey(rolloutDecisionObj);
+ assert.strictEqual(experimentKey, '');
+ });
+
+ it('should return empty string when experiment is not defined', function() {
+ var experimentKey = decision.getExperimentKey({});
+ assert.strictEqual(experimentKey, '');
+ });
+
+ it('should return experiment key when experiment is defined', function() {
+ var experimentKey = decision.getExperimentKey(featureTestDecisionObj);
+ assert.strictEqual(experimentKey, 'testing_my_feature');
+ });
+ });
+
+ describe('getExperimentId method', function() {
+ it('should return null when experiment is null', function() {
+ var experimentId = decision.getExperimentId(rolloutDecisionObj);
+ assert.strictEqual(experimentId, null);
+ });
+
+ it('should return null when experiment is not defined', function() {
+ var experimentId = decision.getExperimentId({});
+ assert.strictEqual(experimentId, null);
+ });
+
+ it('should return experiment id when experiment is defined', function() {
+ var experimentId = decision.getExperimentId(featureTestDecisionObj);
+ assert.strictEqual(experimentId, '594098');
+ });
+ });
+
+ describe('getVariationKey method', function() {
+ it('should return empty string when variation is null', function() {
+ var variationKey = decision.getVariationKey(rolloutDecisionObj);
+ assert.strictEqual(variationKey, '');
+ });
+
+ it('should return empty string when variation is not defined', function() {
+ var variationKey = decision.getVariationKey({});
+ assert.strictEqual(variationKey, '');
+ });
+
+ it('should return variation key when variation is defined', function() {
+ var variationKey = decision.getVariationKey(featureTestDecisionObj);
+ assert.strictEqual(variationKey, 'variation');
+ });
+ });
+
+ describe('getVariationId method', function() {
+ it('should return null when variation is null', function() {
+ var variationId = decision.getVariationId(rolloutDecisionObj);
+ assert.strictEqual(variationId, null);
+ });
+
+ it('should return null when variation is not defined', function() {
+ var variationId = decision.getVariationId({});
+ assert.strictEqual(variationId, null);
+ });
+
+ it('should return variation id when variation is defined', function() {
+ var variationId = decision.getVariationId(featureTestDecisionObj);
+ assert.strictEqual(variationId, '594096');
+ });
+ });
+
+ describe('getFeatureEnabledFromVariation method', function() {
+ it('should return false when variation is null', function() {
+ var featureEnabled = decision.getFeatureEnabledFromVariation(rolloutDecisionObj);
+ assert.strictEqual(featureEnabled, false);
+ });
+
+ it('should return false when variation is not defined', function() {
+ var featureEnabled = decision.getFeatureEnabledFromVariation({});
+ assert.strictEqual(featureEnabled, false);
+ });
+
+ it('should return featureEnabled boolean when variation is defined', function() {
+ var featureEnabled = decision.getFeatureEnabledFromVariation(featureTestDecisionObj);
+ assert.strictEqual(featureEnabled, true);
+ });
+ });
+})
diff --git a/lib/core/decision/index.ts b/lib/core/decision/index.ts
new file mode 100644
index 000000000..27fd1c734
--- /dev/null
+++ b/lib/core/decision/index.ts
@@ -0,0 +1,62 @@
+/**
+ * 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.
+ */
+
+import { DecisionObj } from '../decision_service';
+
+/**
+ * Get experiment key from the provided decision object
+ * @param {DecisionObj} decisionObj Object representing decision
+ * @returns {string} Experiment key or empty string if experiment is null
+ */
+export function getExperimentKey(decisionObj: DecisionObj): string {
+ return decisionObj.experiment?.key ?? '';
+}
+
+/**
+ * Get variation key from the provided decision object
+ * @param {DecisionObj} decisionObj Object representing decision
+ * @returns {string} Variation key or empty string if variation is null
+ */
+export function getVariationKey(decisionObj: DecisionObj): string {
+ return decisionObj.variation?.key ?? '';
+}
+
+/**
+ * Get featureEnabled from variation in the provided decision object
+ * @param {DecisionObj} decisionObj Object representing decision
+ * @returns {boolean} featureEnabled boolean or false if variation is null
+ */
+export function getFeatureEnabledFromVariation(decisionObj: DecisionObj): boolean {
+ return decisionObj.variation?.featureEnabled ?? false;
+}
+
+/**
+ * Get experiment id from the provided decision object
+ * @param {DecisionObj} decisionObj Object representing decision
+ * @returns {string} Experiment id or null if experiment is null
+ */
+export function getExperimentId(decisionObj: DecisionObj): string | null {
+ return decisionObj.experiment?.id ?? null;
+}
+
+/**
+ * Get variation id from the provided decision object
+ * @param {DecisionObj} decisionObj Object representing decision
+ * @returns {string} Variation id or null if variation is null
+ */
+export function getVariationId(decisionObj: DecisionObj): string | null {
+ return decisionObj.variation?.id ?? null;
+}
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..04c7246ca
--- /dev/null
+++ b/lib/core/decision_service/cmab/cmab_client.spec.ts
@@ -0,0 +1,357 @@
+/**
+ * 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');
+ });
+});
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..efe3a72ed
--- /dev/null
+++ b/lib/core/decision_service/cmab/cmab_client.ts
@@ -0,0 +1,116 @@
+/**
+ * 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 CMAB_PREDICTION_ENDPOINT = 'https://prediction.cmab.optimizely.com/predict/%s';
+
+export type RetryConfig = {
+ maxRetries: number,
+ backoffProvider?: Producer;
+}
+
+export type CmabClientConfig = {
+ requestHandler: RequestHandler,
+ retryConfig?: RetryConfig;
+}
+
+export class DefaultCmabClient implements CmabClient {
+ private requestHandler: RequestHandler;
+ private retryConfig?: RetryConfig;
+
+ constructor(config: CmabClientConfig) {
+ this.requestHandler = config.requestHandler;
+ this.retryConfig = config.retryConfig;
+ }
+
+ async fetchDecision(
+ ruleId: string,
+ userId: string,
+ attributes: UserAttributes,
+ cmabUuid: string,
+ ): Promise {
+ const url = sprintf(CMAB_PREDICTION_ENDPOINT, 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..cd3ab99ea
--- /dev/null
+++ b/lib/core/decision_service/cmab/cmab_service.ts
@@ -0,0 +1,180 @@
+/**
+ * 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 { Cache, 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";
+
+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;
+
+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;
+ }
+
+ 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 filteredAttributes = this.filterAttributes(projectConfig, userContext, ruleId);
+
+ if (options[OptimizelyDecideOption.IGNORE_CMAB_CACHE]) {
+ return this.fetchDecision(ruleId, userContext.getUserId(), filteredAttributes);
+ }
+
+ if (options[OptimizelyDecideOption.RESET_CMAB_CACHE]) {
+ this.cmabCache.reset();
+ }
+
+ const cacheKey = this.getCacheKey(userContext.getUserId(), ruleId);
+
+ if (options[OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE]) {
+ 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) {
+ return { variationId: cachedValue.variationId, cmabUuid: cachedValue.cmabUuid };
+ } else {
+ this.cmabCache.remove(cacheKey);
+ }
+ }
+
+ const variation = await this.fetchDecision(ruleId, userContext.getUserId(), 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/lib/core/decision_service/index.tests.js b/lib/core/decision_service/index.tests.js
new file mode 100644
index 000000000..346814857
--- /dev/null
+++ b/lib/core/decision_service/index.tests.js
@@ -0,0 +1,1988 @@
+/**
+ * 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';
+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 { getForwardingEventProcessor } from '../../event_processor/event_processor_factory';
+import { createNotificationCenter } from '../../notification_center';
+import Optimizely from '../../optimizely';
+import OptimizelyUserContext from '../../optimizely_user_context';
+import projectConfig, { createProjectConfig } from '../../project_config/project_config';
+import AudienceEvaluator from '../audience_evaluator';
+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));
+ var decisionServiceInstance;
+ var mockLogger = createLogger({ logLevel: LOG_LEVEL.INFO });
+ var bucketerStub;
+ var experiment;
+ var user;
+
+ beforeEach(function() {
+ bucketerStub = sinon.stub(bucketer, 'bucket');
+ sinon.stub(mockLogger, 'info');
+ sinon.stub(mockLogger, 'debug');
+ sinon.stub(mockLogger, 'warn');
+ sinon.stub(mockLogger, 'error');
+
+ decisionServiceInstance = createDecisionService({
+ logger: mockLogger,
+ });
+ });
+
+ afterEach(function() {
+ bucketer.bucket.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'
+ });
+ var fakeDecisionResponse = {
+ result: '111128',
+ reasons: [],
+ };
+ experiment = configObj.experimentIdMap['111127'];
+ bucketerStub.returns(fakeDecisionResponse); // contains variation ID of the 'control' variation from `test_data`
+ assert.strictEqual(
+ 'control',
+ decisionServiceInstance.getVariation(configObj, experiment, user).result
+ );
+ sinon.assert.calledOnce(bucketerStub);
+ });
+
+ it('should return the whitelisted variation if the user is whitelisted', function() {
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'user2'
+ });
+ experiment = configObj.experimentIdMap['122227'];
+ assert.strictEqual(
+ 'variationWithAudience',
+ decisionServiceInstance.getVariation(configObj, experiment, user).result
+ );
+ sinon.assert.notCalled(bucketerStub);
+ 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'
+ });
+ experiment = configObj.experimentIdMap['122227'];
+ assert.isNull(
+ decisionServiceInstance.getVariation(configObj, experiment, user, { foo: 'bar' }).result
+ );
+
+ 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.info.callCount);
+
+ assert.deepEqual(mockLogger.info.args[0], [EXPERIMENT_NOT_RUNNING, 'testExperimentNotRunning']);
+ });
+
+ describe('when attributes.$opt_experiment_bucket_map is supplied', function() {
+ it('should respect the sticky bucketing information for attributes', function() {
+ var fakeDecisionResponse = {
+ result: '111128',
+ reasons: [],
+ };
+ experiment = configObj.experimentIdMap['111127'];
+ bucketerStub.returns(fakeDecisionResponse); // ID of the 'control' variation from `test_data`
+ var attributes = {
+ $opt_experiment_bucket_map: {
+ '111127': {
+ variation_id: '111129', // ID of the 'variation' variation
+ },
+ },
+ };
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'decision_service_user',
+ attributes,
+ });
+
+ assert.strictEqual(
+ 'variation',
+ decisionServiceInstance.getVariation(configObj, experiment, user).result
+ );
+ sinon.assert.notCalled(bucketerStub);
+ });
+ });
+
+ describe('when a user profile service is provided', function() {
+ var fakeDecisionResponse = {
+ result: '111128',
+ reasons: [],
+ };
+ var userProfileServiceInstance = null;
+ var userProfileLookupStub;
+ var userProfileSaveStub;
+ var fakeDecisionWhitelistedVariation = {
+ result: null,
+ reasons: [],
+ }
+ beforeEach(function() {
+ userProfileServiceInstance = {
+ lookup: function() {},
+ save: function() {},
+ };
+
+ decisionServiceInstance = createDecisionService({
+ logger: mockLogger,
+ userProfileService: userProfileServiceInstance,
+ });
+ userProfileLookupStub = sinon.stub(userProfileServiceInstance, 'lookup');
+ userProfileSaveStub = sinon.stub(userProfileServiceInstance, 'save');
+ sinon.stub(decisionServiceInstance, 'getWhitelistedVariation').returns(fakeDecisionWhitelistedVariation);
+ });
+
+ afterEach(function() {
+ userProfileServiceInstance.lookup.restore();
+ userProfileServiceInstance.save.restore();
+ decisionServiceInstance.getWhitelistedVariation.restore();
+ });
+
+ it('should return the previously bucketed variation', function() {
+ userProfileLookupStub.returns({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128', // ID of the 'control' variation
+ },
+ },
+ });
+ 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.notCalled(bucketerStub);
+
+ 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() {
+ bucketerStub.returns(fakeDecisionResponse); // ID of the 'control' variation
+ userProfileLookupStub.returns({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {},
+ });
+ 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);
+ // make sure we save the decision
+ sinon.assert.calledWith(userProfileSaveStub, {
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128',
+ },
+ },
+ });
+ });
+
+ it('should bucket if the user profile service returns null', function() {
+ bucketerStub.returns(fakeDecisionResponse); // ID of the 'control' variation
+ userProfileLookupStub.returns(null);
+ 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);
+ // make sure we save the decision
+ sinon.assert.calledWith(userProfileSaveStub, {
+ 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() {
+ bucketerStub.returns(fakeDecisionResponse); // ID of the 'control' variation
+ userProfileLookupStub.returns({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: 'not valid variation',
+ },
+ },
+ });
+ 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);
+ // 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',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128',
+ },
+ },
+ });
+ });
+
+ it('should store the bucketed variation for the user', function() {
+ bucketerStub.returns(fakeDecisionResponse); // ID of the 'control' variation
+ userProfileLookupStub.returns({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {}, // no decisions for user
+ });
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'decision_service_user',
+ });
+ experiment = configObj.experimentIdMap['111127'];
+
+ assert.strictEqual(
+ 'control',
+ decisionServiceInstance.getVariation(configObj, experiment, user).result
+ );
+ sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
+ sinon.assert.calledOnce(bucketerStub);
+
+ sinon.assert.calledWith(userProfileServiceInstance.save, {
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128',
+ },
+ },
+ });
+
+
+ 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() {
+ bucketerStub.returns(fakeDecisionResponse); // ID of the 'control' variation
+ 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.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() {
+ bucketerStub.returns(fakeDecisionResponse); // ID of the 'control' variation
+ userProfileLookupStub.returns(null);
+ userProfileSaveStub.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.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, {
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128',
+ },
+ },
+ });
+ });
+
+ describe('when passing `attributes.$opt_experiment_bucket_map`', function() {
+ it('should respect attributes over the userProfileService for the matching experiment id', function() {
+ userProfileLookupStub.returns({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128', // ID of the 'control' variation
+ },
+ },
+ });
+
+ var attributes = {
+ $opt_experiment_bucket_map: {
+ '111127': {
+ variation_id: '111129', // ID of the 'variation' variation
+ },
+ },
+ };
+
+ experiment = configObj.experimentIdMap['111127'];
+
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'decision_service_user',
+ attributes,
+ });
+
+ assert.strictEqual(
+ 'variation',
+ decisionServiceInstance.getVariation(configObj, experiment, user).result
+ );
+ sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
+ sinon.assert.notCalled(bucketerStub);
+
+ 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() {
+ userProfileLookupStub.returns({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '111127': {
+ // 'testExperiment' ID
+ variation_id: '111128', // ID of the 'control' variation
+ },
+ },
+ });
+
+ experiment = configObj.experimentIdMap['111127'];
+
+ var attributes = {
+ $opt_experiment_bucket_map: {
+ '122227': {
+ // other experiment ID
+ variation_id: '122229', // ID of the 'variationWithAudience' variation
+ },
+ },
+ };
+
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'decision_service_user',
+ attributes,
+ });
+
+ assert.strictEqual(
+ 'control',
+ decisionServiceInstance.getVariation(configObj, experiment, user).result
+ );
+ sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
+ sinon.assert.notCalled(bucketerStub);
+
+ 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() {
+ userProfileLookupStub.returns({
+ user_id: 'decision_service_user',
+ experiment_bucket_map: {
+ '122227': {
+ // other experiment ID
+ variation_id: '122229', // ID of the 'variationWithAudience' variation
+ },
+ },
+ });
+
+ experiment = configObj.experimentIdMap['111127'];
+
+ var attributes = {
+ $opt_experiment_bucket_map: {
+ '111127': {
+ // 'testExperiment' ID
+ variation_id: '111129', // ID of the 'variation' variation
+ },
+ },
+ };
+
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'decision_service_user',
+ attributes,
+ });
+
+ assert.strictEqual(
+ 'variation',
+ decisionServiceInstance.getVariation(configObj, experiment, user).result
+ );
+ sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
+ sinon.assert.notCalled(bucketerStub);
+
+ 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() {
+ userProfileLookupStub.returns(null);
+
+ experiment = configObj.experimentIdMap['111127'];
+
+ var attributes = {
+ $opt_experiment_bucket_map: {
+ '111127': {
+ variation_id: '111129', // ID of the 'variation' variation
+ },
+ },
+ };
+
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'decision_service_user',
+ attributes,
+ });
+
+ assert.strictEqual(
+ 'variation',
+ decisionServiceInstance.getVariation(configObj, experiment, user).result
+ );
+ sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
+ sinon.assert.notCalled(bucketerStub);
+
+ 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']);
+ });
+ });
+ });
+ });
+
+ describe('buildBucketerParams', function() {
+ it('should return params object with correct properties', function() {
+ experiment = configObj.experimentIdMap['111127'];
+ var bucketerParams = decisionServiceInstance.buildBucketerParams(
+ configObj,
+ experiment,
+ 'testUser',
+ 'testUser'
+ );
+
+ var expectedParams = {
+ bucketingId: 'testUser',
+ experimentKey: 'testExperiment',
+ userId: 'testUser',
+ experimentId: '111127',
+ trafficAllocationConfig: [
+ {
+ entityId: '111128',
+ endOfRange: 4000,
+ },
+ {
+ entityId: '111129',
+ endOfRange: 9000,
+ },
+ ],
+ variationIdMap: configObj.variationIdMap,
+ logger: mockLogger,
+ experimentIdMap: configObj.experimentIdMap,
+ experimentKeyMap: configObj.experimentKeyMap,
+ groupIdMap: configObj.groupIdMap,
+ validateEntity: true,
+ };
+
+ assert.deepEqual(bucketerParams, expectedParams);
+ });
+ });
+
+ describe('checkIfUserIsInAudience', function() {
+ var __audienceEvaluateSpy;
+
+ beforeEach(function() {
+ __audienceEvaluateSpy = sinon.spy(AudienceEvaluator.prototype, 'evaluate');
+ });
+
+ afterEach(function() {
+ __audienceEvaluateSpy.restore();
+ });
+
+ it('should return decision response with result true when audience conditions are met', function() {
+ experiment = configObj.experimentIdMap['122227'];
+ assert.isTrue(
+ decisionServiceInstance.checkIfUserIsInAudience(
+ configObj,
+ experiment,
+ "experiment",
+ { getAttributes: () => ({ browser_type: 'firefox' }) },
+ ''
+ ).result
+ );
+
+ 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() {
+ experiment = configObj.experimentIdMap['111127'];
+ assert.isTrue(
+ decisionServiceInstance.checkIfUserIsInAudience(
+ configObj,
+ experiment,
+ 'experiment',
+ {},
+ ''
+ ).result
+ );
+ assert.isTrue(__audienceEvaluateSpy.alwaysReturned(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() {
+ experiment = configObj.experimentIdMap['122227'];
+ assert.isFalse(
+ decisionServiceInstance.checkIfUserIsInAudience(
+ configObj,
+ experiment,
+ "experiment",
+ {},
+ ''
+ ).result
+ );
+ assert.isTrue(__audienceEvaluateSpy.alwaysReturned(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() {
+ experiment = configObj.experimentIdMap['122227'];
+ assert.isFalse(
+ decisionServiceInstance.checkIfUserIsInAudience(
+ configObj,
+ experiment,
+ "experiment",
+ { browser_type: 'chrome' },
+ ''
+ ).result
+ );
+ assert.isTrue(__audienceEvaluateSpy.alwaysReturned(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']);
+ });
+ });
+
+ describe('getWhitelistedVariation', function() {
+ it('should return forced variation ID if forced variation is provided for the user ID', function() {
+ var testExperiment = configObj.experimentKeyMap['testExperiment'];
+ var expectedVariation = configObj.variationIdMap['111128'];
+ assert.strictEqual(
+ decisionServiceInstance.getWhitelistedVariation(testExperiment, 'user1').result,
+ expectedVariation
+ );
+ });
+
+ it('should return null if forced variation is not provided for the user ID', function() {
+ var testExperiment = configObj.experimentKeyMap['testExperiment'];
+ assert.isNull(decisionServiceInstance.getWhitelistedVariation(testExperiment, 'notInForcedVariations').result);
+ });
+ });
+
+ describe('getForcedVariation', function() {
+ it('should return null for valid experimentKey, not set', function() {
+ var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1').result;
+ assert.strictEqual(variation, null);
+ });
+
+ it('should return null for invalid experimentKey, not set', function() {
+ var variation = decisionServiceInstance.getForcedVariation(configObj, 'definitely_not_valid_exp_key', 'user1').result;
+ assert.strictEqual(variation, null);
+ });
+
+ it('should return null for invalid experimentKey when a variation was previously successfully forced on another experiment for the same user', function() {
+ decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control');
+ var variation = decisionServiceInstance.getForcedVariation(configObj, 'definitely_not_valid_exp_key', 'user1').result;
+ assert.strictEqual(variation, null);
+ });
+
+ it('should return null for valid experiment key, not set on this experiment key, but set on another experiment key', function() {
+ decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control');
+ var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperimentLaunched', 'user1').result;
+ assert.strictEqual(variation, null);
+ });
+ });
+
+ describe('#setForcedVariation', function() {
+ it('should return true for a valid forcedVariation in setForcedVariation', function() {
+ var didSetVariation = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ assert.strictEqual(didSetVariation, true);
+ });
+
+ it('should return the same variation from getVariation as was set in setVariation', function() {
+ decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control');
+ var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1').result;
+ assert.strictEqual(variation, 'control');
+ });
+
+ it('should not set for an invalid variation key', function() {
+ decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperiment',
+ 'user1',
+ 'definitely_not_valid_variation_key'
+ );
+ var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1').result;
+ assert.strictEqual(variation, null);
+ });
+
+ it('should reset the forcedVariation if passed null', function() {
+ var didSetVariation = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ assert.strictEqual(didSetVariation, true);
+
+ var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1').result;
+ assert.strictEqual(variation, 'control' );
+
+ var didSetVariationAgain = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperiment',
+ 'user1',
+ null
+ );
+ assert.strictEqual(didSetVariationAgain, true);
+
+ var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1').result;
+ assert.strictEqual(variation, null);
+ });
+
+ it('should be able to add variations for multiple experiments for one user', function() {
+ var didSetVariation1 = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ assert.strictEqual(didSetVariation1, true);
+
+ var didSetVariation2 = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperimentLaunched',
+ 'user1',
+ 'controlLaunched'
+ );
+ assert.strictEqual(didSetVariation2, true);
+
+ var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1').result;
+ var variation2 = decisionServiceInstance.getForcedVariation(configObj, 'testExperimentLaunched', 'user1').result;
+ assert.strictEqual(variation, 'control');
+ assert.strictEqual(variation2, 'controlLaunched');
+ });
+
+ it('should be able to add experiments for multiple users', function() {
+ var didSetVariation1 = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ assert.strictEqual(didSetVariation1, true);
+
+ var didSetVariation2 = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperiment',
+ 'user2',
+ 'variation'
+ );
+ assert.strictEqual(didSetVariation2, true);
+
+ var variationControl = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1').result;
+ var variationVariation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user2').result;
+
+ assert.strictEqual(variationControl, 'control');
+ assert.strictEqual(variationVariation, 'variation');
+ });
+
+ it('should be able to reset a variation for a user with multiple experiments', function() {
+ //set the first time
+ var didSetVariation1 = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ assert.strictEqual(didSetVariation1, true);
+
+ var didSetVariation2 = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperimentLaunched',
+ 'user1',
+ 'controlLaunched'
+ );
+ assert.strictEqual(didSetVariation2, true);
+
+ let variation1 = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1').result;
+ let variation2 = decisionServiceInstance.getForcedVariation(configObj, 'testExperimentLaunched', 'user1').result;
+
+ assert.strictEqual(variation1, 'control');
+ assert.strictEqual(variation2, 'controlLaunched');
+
+ //reset for one of the experiments
+ var didSetVariationAgain = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperiment',
+ 'user1',
+ 'variation'
+ );
+ assert.strictEqual(didSetVariationAgain, true);
+
+ variation1 = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1').result;
+ variation2 = decisionServiceInstance.getForcedVariation(configObj, 'testExperimentLaunched', 'user1').result;
+
+ assert.strictEqual(variation1, 'variation');
+ assert.strictEqual(variation2, 'controlLaunched');
+ });
+
+ it('should be able to unset a variation for a user with multiple experiments', function() {
+ //set the first time
+ var didSetVariation1 = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ assert.strictEqual(didSetVariation1, true);
+
+ var didSetVariation2 = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperimentLaunched',
+ 'user1',
+ 'controlLaunched'
+ );
+ assert.strictEqual(didSetVariation2, true);
+
+ let variation1 = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1').result;
+ let variation2 = decisionServiceInstance.getForcedVariation(configObj, 'testExperimentLaunched', 'user1').result;
+
+ assert.strictEqual(variation1, 'control');
+ assert.strictEqual(variation2, 'controlLaunched');
+
+ //reset for one of the experiments
+ decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', null);
+ assert.strictEqual(didSetVariation1, true);
+
+ variation1 = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1').result;
+ variation2 = decisionServiceInstance.getForcedVariation(configObj, 'testExperimentLaunched', 'user1').result;
+
+ assert.strictEqual(variation1, null);
+ assert.strictEqual(variation2, 'controlLaunched');
+ });
+
+ it('should return false for an empty variation key', function() {
+ var didSetVariation = decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', '');
+ assert.strictEqual(didSetVariation, false);
+ });
+
+ it('should return null when a variation was previously set, and that variation no longer exists on the config object', function() {
+ var didSetVariation = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ assert.strictEqual(didSetVariation, true);
+ var 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'
+ var newConfigObj = projectConfig.createProjectConfig(newDatafile);
+ var forcedVar = decisionServiceInstance.getForcedVariation(newConfigObj, 'testExperiment', 'user1').result;
+ assert.strictEqual(forcedVar, null);
+ });
+
+ it("should return null when a variation was previously set, and that variation's experiment no longer exists on the config object", function() {
+ var didSetVariation = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'testExperiment',
+ 'user1',
+ 'control'
+ );
+ assert.strictEqual(didSetVariation, true);
+ var newConfigObj = projectConfig.createProjectConfig(cloneDeep(testDataWithFeatures));
+ var forcedVar = decisionServiceInstance.getForcedVariation(newConfigObj, 'testExperiment', 'user1').result;
+ assert.strictEqual(forcedVar, null);
+ });
+
+ it('should return false from setForcedVariation and not set for invalid experiment key', function() {
+ var didSetVariation = decisionServiceInstance.setForcedVariation(
+ configObj,
+ 'definitelyNotAValidExperimentKey',
+ 'user1',
+ 'definitely_not_valid_variation_key'
+ );
+ assert.strictEqual(didSetVariation, false);
+ var variation = decisionServiceInstance.getForcedVariation(
+ configObj,
+ 'definitelyNotAValidExperimentKey',
+ 'user1'
+ ).result;
+ assert.strictEqual(variation, null);
+ });
+ });
+ });
+
+ // TODO: Move tests that test methods of Optimizely to lib/optimizely/index.tests.js
+ describe('when a bucketingID is provided', function() {
+ var configObj = projectConfig.createProjectConfig(cloneDeep(testData));
+ var createdLogger = createLogger({
+ logLevel: LOG_LEVEL.DEBUG,
+ logToConsole: false,
+ });
+ var optlyInstance;
+ var user;
+ beforeEach(function() {
+ optlyInstance = new Optimizely({
+ clientEngine: 'node-sdk',
+ projectConfigManager: getMockProjectConfigManager({
+ initConfig: createProjectConfig(cloneDeep(testData))
+ }),
+ jsonSchemaValidator: jsonSchemaValidator,
+ isValidInstance: true,
+ logger: createdLogger,
+ eventProcessor: getForwardingEventProcessor(eventDispatcher),
+ notificationCenter: createNotificationCenter(createdLogger),
+ });
+
+ sinon.stub(eventDispatcher, 'dispatchEvent');
+ });
+
+ afterEach(function() {
+ eventDispatcher.dispatchEvent.restore();
+ });
+
+ var testUserAttributes = {
+ browser_type: 'firefox',
+ };
+ var userAttributesWithBucketingId = {
+ browser_type: 'firefox',
+ $opt_bucketing_id: '123456789',
+ };
+ var invalidUserAttributesWithBucketingId = {
+ browser_type: 'safari',
+ $opt_bucketing_id: 'testBucketingIdControl!',
+ };
+
+ it('confirm normal bucketing occurs before setting bucketingId', function() {
+ assert.strictEqual('variation', optlyInstance.getVariation('testExperiment', 'test_user', testUserAttributes));
+ });
+
+ it('confirm valid bucketing with bucketing ID set in attributes', function() {
+ assert.strictEqual(
+ 'variationWithAudience',
+ optlyInstance.getVariation('testExperimentWithAudiences', 'test_user', userAttributesWithBucketingId)
+ );
+ });
+
+ it('check invalid audience with bucketingId', function() {
+ assert.strictEqual(
+ null,
+ optlyInstance.getVariation('testExperimentWithAudiences', 'test_user', invalidUserAttributesWithBucketingId)
+ );
+ });
+
+ it('test that an experiment that is not running returns a null variation', function() {
+ assert.strictEqual(
+ null,
+ optlyInstance.getVariation('testExperimentNotRunning', 'test_user', userAttributesWithBucketingId)
+ );
+ });
+
+ it('test that an invalid experiment key gets a null variation', function() {
+ assert.strictEqual(
+ null,
+ optlyInstance.getVariation('invalidExperiment', 'test_user', userAttributesWithBucketingId)
+ );
+ });
+
+ it('check forced variation', function() {
+ assert.isTrue(
+ optlyInstance.setForcedVariation('testExperiment', 'test_user', 'control'),
+ sprintf('Set variation to "%s" failed', 'control')
+ );
+ assert.strictEqual(
+ 'control',
+ optlyInstance.getVariation('testExperiment', 'test_user', userAttributesWithBucketingId)
+ );
+ });
+
+ it('check whitelisted variation', function() {
+ assert.strictEqual(
+ 'control',
+ optlyInstance.getVariation('testExperiment', 'user1', userAttributesWithBucketingId)
+ );
+ });
+
+ it('check user profile', function() {
+ var userProfileLookupStub;
+ var userProfileServiceInstance = {
+ lookup: function() {},
+ };
+ userProfileLookupStub = sinon.stub(userProfileServiceInstance, 'lookup');
+ userProfileLookupStub.returns({
+ user_id: 'test_user',
+ experiment_bucket_map: {
+ '111127': {
+ variation_id: '111128', // ID of the 'control' variation
+ },
+ },
+ });
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'test_user',
+ attributes: userAttributesWithBucketingId,
+ });
+ var experiment = configObj.experimentIdMap['111127'];
+
+ var decisionServiceInstance = createDecisionService({
+ logger: createdLogger,
+ userProfileService: userProfileServiceInstance,
+ });
+
+ assert.strictEqual(
+ 'control',
+ decisionServiceInstance.getVariation(configObj, experiment, user).result
+ );
+ sinon.assert.calledWithExactly(userProfileLookupStub, 'test_user');
+ });
+ });
+
+ describe('getBucketingId', function() {
+ var configObj;
+ var decisionService;
+ var mockLogger = createLogger({ logLevel: LOG_LEVEL.INFO });
+ var userId = 'testUser1';
+ var userAttributesWithBucketingId = {
+ browser_type: 'firefox',
+ $opt_bucketing_id: '123456789',
+ };
+ var userAttributesWithInvalidBucketingId = {
+ browser_type: 'safari',
+ $opt_bucketing_id: 50,
+ };
+
+ beforeEach(function() {
+ 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,
+ });
+ });
+
+ afterEach(function() {
+ 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() {
+ assert.strictEqual(userId, decisionService.getBucketingId(userId, null));
+ assert.strictEqual(userId, decisionService.getBucketingId(userId, { browser_type: 'safari' }));
+ });
+
+ it('should log warning in case of invalid bucketingId', function() {
+ assert.strictEqual(userId, decisionService.getBucketingId(userId, userAttributesWithInvalidBucketingId));
+ 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.debug.callCount);
+ assert.deepEqual(mockLogger.debug.args[0], [VALID_BUCKETING_ID, '123456789']);
+ });
+ });
+
+ describe('feature management', function() {
+ describe('#getVariationForFeature', function() {
+ var configObj;
+ var decisionServiceInstance;
+ var sandbox;
+ var mockLogger = createLogger({ logLevel: LOG_LEVEL.INFO });
+ var fakeDecisionResponseWithArgs;
+ var fakeDecisionResponse = Value.of('sync', {
+ result: {},
+ reasons: [],
+ });
+ var user;
+ beforeEach(function() {
+ configObj = projectConfig.createProjectConfig(cloneDeep(testDataWithFeatures));
+ sandbox = sinon.sandbox.create();
+ sandbox.stub(mockLogger, 'debug');
+ sandbox.stub(mockLogger, 'info');
+ sandbox.stub(mockLogger, 'warn');
+ sandbox.stub(mockLogger, 'error');
+
+ decisionServiceInstance = createDecisionService({
+ logger: mockLogger,
+ });
+ });
+
+ afterEach(function() {
+ sandbox.restore();
+ });
+
+ describe('feature attached to an experiment, and not attached to a rollout', function() {
+ var feature;
+ beforeEach(function() {
+ feature = configObj.featureKeyMap.test_feature_for_experiment;
+ });
+
+ describe('user bucketed into this experiment', function() {
+ var getVariationStub;
+ var experiment;
+ beforeEach(function() {
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'user1',
+ attributes: {
+ test_attribute: 'test_value',
+ },
+ });
+ fakeDecisionResponseWithArgs = Value.of('sync', {
+ result: { variationKey: 'variation' },
+ reasons: [],
+ });
+ experiment = configObj.experimentIdMap['594098'];
+ getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation');
+ getVariationStub.returns(fakeDecisionResponse);
+ 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;
+ const expectedDecision = {
+ cmabUuid: undefined,
+ experiment: configObj.experimentIdMap['594098'],
+ variation: configObj.variationIdMap['594096'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ };
+
+ assert.deepEqual(decision, expectedDecision);
+ sinon.assert.calledWith(
+ getVariationStub,
+ 'sync',
+ configObj,
+ experiment,
+ user,
+ sinon.match.any,
+ sinon.match.any
+ );
+ });
+ });
+
+ describe('user not bucketed into this experiment', function() {
+ var getVariationStub;
+ beforeEach(function() {
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'user1',
+ });
+ getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation');
+ getVariationStub.returns(fakeDecisionResponse);
+ });
+
+ it('returns a decision with no variation and source rollout', function() {
+ var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
+ var expectedDecision = {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ };
+ assert.deepEqual(decision, expectedDecision);
+
+ assert.deepEqual(mockLogger.debug.lastCall.args, [USER_NOT_IN_ROLLOUT, 'user1', 'test_feature_for_experiment']);
+ });
+ });
+ });
+
+ describe('feature attached to an experiment in a group, and not attached to a rollout', function() {
+ var feature;
+ beforeEach(function() {
+ feature = configObj.featureKeyMap.feature_with_group;
+ });
+
+ describe('user bucketed into an experiment in the group', function() {
+ var getVariationStub;
+ var user;
+ beforeEach(function() {
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'user1',
+ });
+ fakeDecisionResponseWithArgs = Value.of('sync', {
+ result: { variationKey: 'var' },
+ reasons: [],
+ });
+ getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation');
+ getVariationStub.returns(fakeDecisionResponseWithArgs);
+ getVariationStub.withArgs(configObj, 'exp_with_group', user).returns(fakeDecisionResponseWithArgs);
+ });
+
+ it('returns a decision with a variation in an experiment in a group', function() {
+ var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
+ var expectedDecision = {
+ cmabUuid: undefined,
+ experiment: configObj.experimentIdMap['595010'],
+ variation: configObj.variationIdMap['595008'],
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ };
+
+ assert.deepEqual(decision, expectedDecision);
+ });
+ });
+
+ describe('user not bucketed into an experiment in the group', function() {
+ var getVariationStub;
+ var user;
+ beforeEach(function() {
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'user1',
+ });
+ getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation');
+ getVariationStub.returns(fakeDecisionResponse);
+ });
+
+ it('returns a decision with no experiment, no variation and source rollout', function() {
+ var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
+ var expectedDecision = {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ };
+ assert.deepEqual(decision, expectedDecision);
+
+ 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() {
+ var noTrafficExpFeature = configObj.featureKeyMap.feature_exp_no_traffic;
+ var decision = decisionServiceInstance.getVariationForFeature(configObj, noTrafficExpFeature, user).result;
+ var expectedDecision = {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ };
+ assert.deepEqual(decision, expectedDecision);
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ NO_ROLLOUT_EXISTS,
+ 'feature_exp_no_traffic'
+ );
+ });
+ });
+ });
+
+ describe('feature attached to a rollout', function() {
+ var feature;
+ var bucketStub;
+ beforeEach(function() {
+ feature = configObj.featureKeyMap.test_feature;
+ bucketStub = sandbox.stub(bucketer, 'bucket');
+ });
+
+ describe('user bucketed into an audience targeting rule', function() {
+ beforeEach(function() {
+ fakeDecisionResponse = {
+ result: '594032', // ID of variation in rollout experiment - audience targeting rule for 'test_audience'
+ reasons: [],
+ };
+ bucketStub.returns(fakeDecisionResponse)
+ });
+
+ 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: configObj.experimentIdMap['594031'],
+ variation: configObj.variationIdMap['594032'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ };
+
+ assert.deepEqual(decision, expectedDecision);
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ USER_MEETS_CONDITIONS_FOR_TARGETING_RULE,
+ 'user1', 1
+ );
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ USER_BUCKETED_INTO_TARGETING_RULE,
+ 'user1', 1
+
+ );
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ USER_IN_ROLLOUT,
+ 'user1', 'test_feature'
+ );
+ });
+ });
+
+ describe('user bucketed into everyone else targeting rule', function() {
+ beforeEach(function() {
+ fakeDecisionResponse = {
+ result: '594038', // ID of variation in rollout experiment - everyone else targeting rule
+ reasons: [],
+ };
+ bucketStub.returns(fakeDecisionResponse)
+ });
+
+ 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: configObj.experimentIdMap['594037'],
+ variation: configObj.variationIdMap['594038'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ };
+
+ assert.deepEqual(decision, expectedDecision);
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ USER_DOESNT_MEET_CONDITIONS_FOR_TARGETING_RULE,
+ 'user1', 1
+ );
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ USER_BUCKETED_INTO_TARGETING_RULE,
+ 'user1', 'Everyone Else'
+ );
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ USER_IN_ROLLOUT,
+ 'user1', 'test_feature'
+ );
+ });
+ });
+
+ describe('user not bucketed into audience targeting rule or everyone else rule', function() {
+ beforeEach(function() {
+ fakeDecisionResponse = {
+ result: null,
+ reasons: [],
+ };
+ bucketStub.returns(fakeDecisionResponse)
+ });
+
+ it('returns a decision with no variation, no experiment and source rollout', function() {
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'user1',
+ });
+ var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
+ var expectedDecision = {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ };
+ assert.deepEqual(decision, expectedDecision);
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ USER_DOESNT_MEET_CONDITIONS_FOR_TARGETING_RULE,
+ 'user1', 1
+ );
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ USER_NOT_IN_ROLLOUT,
+ 'user1', 'test_feature'
+ );
+ });
+ });
+
+ describe('user excluded from audience targeting rule due to traffic allocation, and bucketed into everyone else', function() {
+ beforeEach(function() {
+ fakeDecisionResponse = {
+ result: null,
+ reasons: [],
+ };
+ fakeDecisionResponseWithArgs = {
+ result: '594038',
+ reasons: [],
+ };
+ bucketStub.returns(fakeDecisionResponse); // returns no variation for other calls
+ bucketStub
+ .withArgs(
+ sinon.match({
+ experimentKey: '594037',
+ })
+ )
+ .returns(fakeDecisionResponseWithArgs); // returns variation from everyone else targeitng rule when called with everyone else experiment key;
+ });
+
+ 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: configObj.experimentIdMap['594037'],
+ variation: configObj.variationIdMap['594038'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ };
+
+ assert.deepEqual(decision, expectedDecision);
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ USER_MEETS_CONDITIONS_FOR_TARGETING_RULE,
+ 'user1', 1
+ );
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ USER_NOT_BUCKETED_INTO_TARGETING_RULE,
+ 'user1', 1
+ );
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ USER_BUCKETED_INTO_TARGETING_RULE,
+ 'user1', 'Everyone Else'
+ );
+ });
+ });
+ });
+
+ describe('feature attached to both an experiment and a rollout', function() {
+ var feature;
+ var getVariationStub;
+ var bucketStub;
+ fakeDecisionResponse = Value.of('sync', {
+ result: {},
+ reasons: [],
+ });
+ var fakeBucketStubDecisionResponse = {
+ result: '599057',
+ reasons: [],
+ }
+ beforeEach(function() {
+ feature = configObj.featureKeyMap.shared_feature;
+ getVariationStub = sandbox.stub(decisionServiceInstance, 'getVariation');
+ getVariationStub.returns(fakeDecisionResponse); // No variation returned by getVariation
+ bucketStub = sandbox.stub(bucketer, 'bucket');
+ bucketStub.returns(fakeBucketStubDecisionResponse); // Id of variation in rollout of shared feature
+ });
+
+ it('can bucket a user into the rollout when the user is not bucketed into the experiment', 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: configObj.experimentIdMap['599056'],
+ variation: configObj.variationIdMap['599057'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ };
+
+ assert.deepEqual(decision, expectedDecision);
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ USER_BUCKETED_INTO_TARGETING_RULE,
+ 'user1', 'Everyone Else'
+ );
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ USER_IN_ROLLOUT,
+ 'user1', 'shared_feature'
+ );
+ });
+ });
+
+ describe('feature not attached to an experiment or a rollout', function() {
+ var feature;
+ beforeEach(function() {
+ feature = configObj.featureKeyMap.unused_flag;
+ });
+
+ it('returns a decision with no variation, no experiment and source rollout', function() {
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'user1'
+ });
+ var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
+ var expectedDecision = {
+ experiment: null,
+ variation: null,
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ };
+ assert.deepEqual(decision, expectedDecision);
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ FEATURE_HAS_NO_EXPERIMENTS,
+ 'unused_flag'
+ );
+ sinon.assert.calledWithExactly(
+ mockLogger.debug,
+ NO_ROLLOUT_EXISTS,
+ 'unused_flag'
+ );
+ });
+ });
+
+ describe('feature attached to exclusion group', function() {
+ var feature;
+ var generateBucketValueStub;
+ beforeEach(function() {
+ feature = configObj.featureKeyMap.test_feature_in_exclusion_group;
+ 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' }
+ });
+ 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',
+ key: 'var_1',
+ featureEnabled: false,
+ },
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ };
+ assert.deepEqual(decision, expectedDecision);
+
+ sinon.assert.calledWithExactly(
+ generateBucketValueStub,
+ 'user142222'
+ );
+ });
+
+ 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' }
+ });
+ 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',
+ key: 'var_1',
+ featureEnabled: false,
+ },
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ };
+ assert.deepEqual(decision, expectedDecision);
+
+ sinon.assert.calledWithExactly(
+ generateBucketValueStub,
+ 'user142223'
+ );
+ });
+
+ 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' }
+ });
+ 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',
+ key: 'var_1',
+ featureEnabled: false,
+ },
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ };
+ assert.deepEqual(decision, expectedDecision);
+
+ sinon.assert.calledWithExactly(
+ generateBucketValueStub,
+ 'user142224'
+ );
+ });
+
+ 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' }
+ });
+ var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
+ var expectedExperiment = projectConfig.getExperimentFromId(configObj, '594066');
+ var expectedDecision = {
+ experiment: expectedExperiment,
+ variation: configObj.variationIdMap['594067'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ }
+ assert.deepEqual(decision, expectedDecision);
+
+ sinon.assert.calledWithExactly(
+ generateBucketValueStub,
+ 'user1594066'
+ );
+ });
+
+ 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' }
+ });
+ var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
+ var expectedExperiment = projectConfig.getExperimentFromId(configObj, '594066', mockLogger);
+ var expectedDecision = {
+ experiment: expectedExperiment,
+ variation: configObj.variationIdMap['594067'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ }
+ assert.deepEqual(decision, expectedDecision);
+
+ sinon.assert.calledWithExactly(
+ generateBucketValueStub,
+ 'user1594066'
+ );
+ });
+ });
+
+ describe('feature attached to multiple experiments', function() {
+ var feature;
+ var generateBucketValueStub;
+ beforeEach(function() {
+ feature = configObj.featureKeyMap.test_feature_in_multiple_experiments;
+ 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' }
+ });
+ var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
+ var expectedExperiment = projectConfig.getExperimentFromKey(configObj, 'test_experiment3');
+ var expectedDecision = {
+ cmabUuid: undefined,
+ experiment: expectedExperiment,
+ variation: {
+ id: '222239',
+ key: 'control',
+ featureEnabled: false,
+ variables: [],
+ },
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ };
+ assert.deepEqual(decision, expectedDecision);
+
+ sinon.assert.calledWithExactly(
+ generateBucketValueStub,
+ 'user1111134'
+ );
+ });
+
+ 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' }
+ });
+ var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
+ var expectedExperiment = projectConfig.getExperimentFromKey(configObj, 'test_experiment4');
+ var expectedDecision = {
+ cmabUuid: undefined,
+ experiment: expectedExperiment,
+ variation: {
+ id: '222240',
+ key: 'control',
+ featureEnabled: false,
+ variables: [],
+ },
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ };
+ assert.deepEqual(decision, expectedDecision);
+
+ sinon.assert.calledWithExactly(
+ generateBucketValueStub,
+ 'user1111135'
+ );
+ });
+
+ 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' }
+ });
+ var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
+ var expectedExperiment = projectConfig.getExperimentFromKey(configObj, 'test_experiment5');
+ var expectedDecision = {
+ cmabUuid: undefined,
+ experiment: expectedExperiment,
+ variation: {
+ id: '222241',
+ key: 'control',
+ featureEnabled: false,
+ variables: [],
+ },
+ decisionSource: DECISION_SOURCES.FEATURE_TEST,
+ };
+ assert.deepEqual(decision, expectedDecision);
+
+ sinon.assert.calledWithExactly(
+ generateBucketValueStub,
+ 'user1111136'
+ );
+ });
+
+ 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' }
+ });
+ var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
+ var expectedExperiment = projectConfig.getExperimentFromId(configObj, '594066');
+ var expectedDecision = {
+ experiment: expectedExperiment,
+ variation: configObj.variationIdMap['594067'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ }
+ assert.deepEqual(decision, expectedDecision);
+
+ sinon.assert.calledWithExactly(
+ generateBucketValueStub,
+ 'user1594066'
+ );
+ });
+
+ 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' }
+ });
+ var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result;
+ var expectedExperiment = projectConfig.getExperimentFromId(configObj, '594066', mockLogger);
+ var expectedDecision = {
+ experiment: expectedExperiment,
+ variation: configObj.variationIdMap['594067'],
+ decisionSource: DECISION_SOURCES.ROLLOUT,
+ }
+ assert.deepEqual(decision, expectedDecision);
+
+ sinon.assert.calledWithExactly(
+ generateBucketValueStub,
+ 'user1594066'
+ );
+ });
+ });
+ });
+
+ describe('getVariationForRollout', function() {
+ var feature;
+ var configObj;
+ var decisionService;
+ var buildBucketerParamsSpy;
+ var user;
+
+ beforeEach(function() {
+ configObj = projectConfig.createProjectConfig(cloneDeep(testDataWithFeatures));
+ feature = configObj.featureKeyMap.test_feature;
+ decisionService = createDecisionService({
+ logger: createLogger({ logLevel: LOG_LEVEL.INFO }),
+ });
+ buildBucketerParamsSpy = sinon.spy(decisionService, 'buildBucketerParams');
+ });
+
+ afterEach(function() {
+ buildBucketerParamsSpy.restore();
+ });
+
+ 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' }
+ });
+ decisionService.getVariationForRollout(configObj, feature, user).result;
+
+ sinon.assert.callCount(buildBucketerParamsSpy, 2);
+ sinon.assert.calledWithExactly(buildBucketerParamsSpy, configObj, configObj.experimentIdMap['594031'], 'testUser', 'testUser');
+ sinon.assert.calledWithExactly(buildBucketerParamsSpy, configObj, configObj.experimentIdMap['594037'], 'testUser', 'testUser');
+ });
+
+ it('should call buildBucketerParams with bucketing Id when bucketing Id is provided in the attributes', function() {
+ var attributes = {
+ test_attribute: 'test_value',
+ $opt_bucketing_id: 'abcdefg',
+ };
+ user = new OptimizelyUserContext({
+ shouldIdentifyUser: false,
+ optimizely: {},
+ userId: 'testUser',
+ attributes,
+ });
+ decisionService.getVariationForRollout(configObj, feature, user).result;
+
+ sinon.assert.callCount(buildBucketerParamsSpy, 2);
+ sinon.assert.calledWithExactly(buildBucketerParamsSpy, configObj, configObj.experimentIdMap['594031'], 'abcdefg', 'testUser');
+ sinon.assert.calledWithExactly(buildBucketerParamsSpy, configObj, configObj.experimentIdMap['594037'], 'abcdefg', 'testUser');
+ });
+ });
+ });
+});
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.js b/lib/error/error_handler.ts
similarity index 71%
rename from packages/optimizely-sdk/lib/plugins/error_handler/index.js
rename to lib/error/error_handler.ts
index 105451f6a..4a772c71c 100644
--- a/packages/optimizely-sdk/lib/plugins/error_handler/index.js
+++ b/lib/error/error_handler.ts
@@ -1,5 +1,5 @@
/**
- * Copyright 2016, 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,16 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
/**
- * Default error handler implementation
+ * @export
+ * @interface ErrorHandler
*/
-module.exports = {
+export interface ErrorHandler {
/**
- * Handle given exception
- * @param {Object} exception An exception object
+ * @param {Error} exception
+ * @memberof ErrorHandler
*/
- handleError: function(exception) {
- // no-op
- }
-};
+ 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/lib/event_processor/event_builder/log_event.ts b/lib/event_processor/event_builder/log_event.ts
new file mode 100644
index 000000000..4d4048950
--- /dev/null
+++ b/lib/event_processor/event_builder/log_event.ts
@@ -0,0 +1,232 @@
+/**
+ * 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.
+ * 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, UserEvent } from './user_event';
+
+import { CONTROL_ATTRIBUTES } from '../../utils/enums';
+
+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'
+
+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
+ client_name: string
+ client_version: string
+ anonymize_ip: boolean
+ enrich_decisions: boolean
+ visitors: Visitor[]
+}
+
+type Visitor = {
+ snapshots: Snapshot[]
+ visitor_id: string
+ attributes: Attribute[]
+}
+
+type AttributeType = 'custom'
+
+export type Attribute = {
+ // attribute id
+ entity_id: string
+ // attribute key
+ key: string
+ type: AttributeType
+ value: string | number | boolean
+}
+
+export type Snapshot = {
+ decisions?: Decision[]
+ events: SnapshotEvent[]
+}
+
+type Decision = {
+ campaign_id: string | null
+ experiment_id: string | null
+ variation_id: string | null
+ metadata: Metadata
+}
+
+type Metadata = {
+ flag_key: string;
+ rule_key: string;
+ rule_type: string;
+ variation_key: string;
+ enabled: boolean;
+ cmab_uuid?: string;
+}
+
+export type SnapshotEvent = {
+ entity_id: string | null
+ timestamp: number
+ uuid: string
+ key: string
+ revenue?: number
+ value?: number
+ tags?: EventTags
+}
+
+/**
+ * Given an array of batchable Decision or ConversionEvent events it returns
+ * a single EventV1 with proper batching
+ *
+ * @param {UserEvent[]} events
+ * @returns {EventBatch}
+ */
+export function makeEventBatch(events: UserEvent[]): EventBatch {
+ const visitors: Visitor[] = []
+ const data = events[0]
+
+ events.forEach(event => {
+ if (event.type === 'conversion' || event.type === 'impression') {
+ const visitor = makeVisitor(event)
+
+ if (event.type === 'impression') {
+ visitor.snapshots.push(makeDecisionSnapshot(event))
+ } else if (event.type === 'conversion') {
+ visitor.snapshots.push(makeConversionSnapshot(event))
+ }
+
+ visitors.push(visitor)
+ }
+ })
+
+ 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,
+ }
+}
+
+function makeConversionSnapshot(conversion: ConversionEvent): Snapshot {
+ const tags: EventTags = {
+ ...conversion.tags,
+ }
+
+ delete tags['revenue']
+ delete tags['value']
+
+ const event: SnapshotEvent = {
+ entity_id: conversion.event.id,
+ key: conversion.event.key,
+ timestamp: conversion.timestamp,
+ uuid: conversion.uuid,
+ }
+
+ if (conversion.tags) {
+ event.tags = conversion.tags
+ }
+
+ if (conversion.value != null) {
+ event.value = conversion.value
+ }
+
+ if (conversion.revenue != null) {
+ event.revenue = conversion.revenue
+ }
+
+ return {
+ events: [event],
+ }
+}
+
+function makeDecisionSnapshot(event: ImpressionEvent): Snapshot {
+ const { layer, experiment, variation, ruleKey, flagKey, ruleType, enabled, cmabUuid } = event
+ const layerId = layer ? layer.id : null
+ const experimentId = experiment?.id ?? ''
+ const variationId = variation?.id ?? ''
+ const variationKey = variation ? variation.key : ''
+
+ return {
+ decisions: [
+ {
+ campaign_id: layerId,
+ experiment_id: experimentId,
+ variation_id: variationId,
+ metadata: {
+ flag_key: flagKey,
+ rule_key: ruleKey,
+ rule_type: ruleType,
+ variation_key: variationKey,
+ enabled: enabled,
+ cmab_uuid: cmabUuid,
+ },
+ },
+ ],
+ events: [
+ {
+ entity_id: layerId,
+ timestamp: event.timestamp,
+ key: ACTIVATE_EVENT_KEY,
+ uuid: event.uuid,
+ },
+ ],
+ }
+}
+
+function makeVisitor(data: ImpressionEvent | ConversionEvent): Visitor {
+ const visitor: Visitor = {
+ snapshots: [],
+ visitor_id: data.user.id,
+ attributes: [],
+ }
+
+ data.user.attributes.forEach(attr => {
+ visitor.attributes.push({
+ entity_id: attr.entityId,
+ key: attr.key,
+ type: 'custom' as const, // tell the compiler this is always string "custom"
+ value: attr.value,
+ })
+ })
+
+ if (typeof data.context.botFiltering === 'boolean') {
+ visitor.attributes.push({
+ entity_id: CONTROL_ATTRIBUTES.BOT_FILTERING,
+ key: CONTROL_ATTRIBUTES.BOT_FILTERING,
+ type: CUSTOM_ATTRIBUTE_FEATURE_TYPE,
+ value: data.context.botFiltering,
+ })
+ }
+ return visitor
+}
+
+export function buildLogEvent(events: UserEvent[]): LogEvent {
+ const region = events[0]?.context.region || 'US';
+ const url = logxEndpoint[region] || logxEndpoint['US'];
+
+ return {
+ url,
+ httpVerb: 'POST',
+ 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/lib/event_processor/event_builder/user_event.tests.js b/lib/event_processor/event_builder/user_event.tests.js
new file mode 100644
index 000000000..30f271d0e
--- /dev/null
+++ b/lib/event_processor/event_builder/user_event.tests.js
@@ -0,0 +1,380 @@
+/**
+ * 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.
+ * 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 fns from '../../utils/fns';
+import * as projectConfig from '../../project_config/project_config';
+import * as decision from '../../core/decision';
+import { buildImpressionEvent, buildConversionEvent } from './user_event';
+
+describe('user_event', function() {
+ var configObj;
+
+ beforeEach(function() {
+ configObj = {
+ region: 'US',
+ accountId: 'accountId',
+ projectId: 'projectId',
+ revision: '69',
+ anonymizeIP: true,
+ botFiltering: true,
+ };
+
+ sinon.stub(projectConfig, 'getEventId');
+ sinon.stub(projectConfig, 'getLayerId');
+ sinon.stub(projectConfig, 'getAttributeId');
+
+ sinon.stub(decision, 'getExperimentId');
+
+ sinon.stub(fns, 'uuid').returns('uuid');
+ sinon.stub(fns, 'currentTimestamp').returns(100);
+ });
+
+ afterEach(function() {
+ decision.getExperimentId.restore();
+
+ projectConfig.getEventId.restore();
+ projectConfig.getLayerId.restore();
+ projectConfig.getAttributeId.restore();
+
+ fns.uuid.restore();
+ fns.currentTimestamp.restore();
+ });
+
+ describe('buildImpressionEvent', function() {
+ describe('when botFiltering and anonymizeIP are true', function() {
+ it('should build an ImpressionEvent with the correct attributes', function() {
+ var decisionObj = {
+ experiment: {
+ key: 'exp1',
+ status: 'Running',
+ forcedVariations: {},
+ audienceIds: [],
+ layerId: 'layer-id',
+ trafficAllocation: [],
+ variationKeyMap: {
+ 'variation': {
+ key: 'var1',
+ id: 'var1-id',
+ }
+ },
+ id: 'exp1-id',
+ variations: [{ key: 'var1', id: 'var1-id' }],
+ },
+ variation: {
+ key: 'var1',
+ id: 'var1-id',
+ },
+ decisionSource: 'experiment',
+ }
+ decision.getExperimentId.withArgs(decisionObj).returns('exp1-id');
+
+ projectConfig.getLayerId.withArgs(configObj, 'exp1-id').returns('layer-id');
+
+ projectConfig.getAttributeId.withArgs(configObj, 'plan_type').returns('plan_type_id');
+
+
+ var result = buildImpressionEvent({
+ configObj: configObj,
+ decisionObj: decisionObj,
+ enabled: true,
+ flagKey: 'flagkey1',
+ userId: 'user1',
+ userAttributes: {
+ plan_type: 'bronze',
+ invalid: 'value',
+ },
+ clientEngine: 'node',
+ clientVersion: '3.0.11',
+ });
+
+ assert.deepEqual(result, {
+ type: 'impression',
+ timestamp: 100,
+ uuid: 'uuid',
+ context: {
+ region: 'US',
+ accountId: 'accountId',
+ projectId: 'projectId',
+ revision: '69',
+ clientName: 'node',
+ clientVersion: '3.0.11',
+ anonymizeIP: true,
+ botFiltering: true,
+ },
+
+ user: {
+ id: 'user1',
+ attributes: [
+ {
+ entityId: 'plan_type_id',
+ key: 'plan_type',
+ value: 'bronze',
+ },
+ ],
+ },
+
+ layer: {
+ id: 'layer-id',
+ },
+ experiment: {
+ id: 'exp1-id',
+ key: 'exp1',
+ },
+ variation: {
+ id: 'var1-id',
+ key: 'var1',
+ },
+
+ ruleKey: "exp1",
+ flagKey: 'flagkey1',
+ ruleType: 'experiment',
+ enabled: true,
+ cmabUuid: undefined,
+ });
+ });
+ });
+
+ describe('when botFiltering and anonymizeIP are undefined', function() {
+ it('should create an ImpressionEvent with the correct attributes', function() {
+ var decisionObj = {
+ experiment: {
+ key: 'exp1',
+ status: 'Running',
+ forcedVariations: {},
+ audienceIds: [],
+ layerId: '253442',
+ trafficAllocation: [],
+ variationKeyMap: {
+ 'variation': {
+ key: 'var1',
+ id: 'var1-id',
+ }
+ },
+ id: '1237847778',
+ variations: [{ key: 'var1', id: 'var1-id' }],
+ },
+ variation: {
+ key: 'var1',
+ id: 'var1-id',
+ },
+ decisionSource: 'experiment',
+ }
+ decision.getExperimentId.withArgs(decisionObj).returns('exp1-id');
+
+ projectConfig.getLayerId.withArgs(configObj, 'exp1-id').returns('layer-id');
+
+ projectConfig.getAttributeId.withArgs(configObj, 'plan_type').returns('plan_type_id');
+
+ delete configObj['anonymizeIP'];
+ delete configObj['botFiltering'];
+
+ var result = buildImpressionEvent({
+ configObj: configObj,
+ decisionObj: decisionObj,
+ flagKey: 'flagkey1',
+ enabled: false,
+ userId: 'user1',
+ userAttributes: {
+ plan_type: 'bronze',
+ invalid: 'value',
+ },
+ clientEngine: 'node',
+ clientVersion: '3.0.11',
+ });
+
+ assert.deepEqual(result, {
+ type: 'impression',
+ timestamp: 100,
+ uuid: 'uuid',
+ context: {
+ region: 'US',
+ accountId: 'accountId',
+ projectId: 'projectId',
+ revision: '69',
+ clientName: 'node',
+ clientVersion: '3.0.11',
+ anonymizeIP: false,
+ botFiltering: undefined,
+ },
+
+ user: {
+ id: 'user1',
+ attributes: [
+ {
+ entityId: 'plan_type_id',
+ key: 'plan_type',
+ value: 'bronze',
+ },
+ ],
+ },
+
+ layer: {
+ id: 'layer-id',
+ },
+ experiment: {
+ id: 'exp1-id',
+ key: 'exp1',
+ },
+ variation: {
+ id: 'var1-id',
+ key: 'var1',
+ },
+
+ ruleKey: "exp1",
+ flagKey: 'flagkey1',
+ ruleType: 'experiment',
+ enabled: false,
+ cmabUuid: undefined,
+ });
+ });
+ });
+ });
+
+ describe('buildConversionEvent', function() {
+ describe('when botFiltering and anonymizeIP are true', function() {
+ it('should build an ConversionEvent with the correct attributes', function() {
+ projectConfig.getEventId.withArgs(configObj, 'event').returns('event-id');
+ projectConfig.getAttributeId.withArgs(configObj, 'plan_type').returns('plan_type_id');
+
+ var result = buildConversionEvent({
+ configObj: configObj,
+ eventKey: 'event',
+ eventTags: {
+ value: '123',
+ revenue: 1000,
+ tag1: 'value1',
+ },
+ userId: 'user1',
+ userAttributes: {
+ plan_type: 'bronze',
+ invalid: 'value',
+ },
+ clientEngine: 'node',
+ clientVersion: '3.0.11',
+ });
+
+ assert.deepEqual(result, {
+ type: 'conversion',
+ timestamp: 100,
+ uuid: 'uuid',
+ context: {
+ region: 'US',
+ accountId: 'accountId',
+ projectId: 'projectId',
+ revision: '69',
+ clientName: 'node',
+ clientVersion: '3.0.11',
+ anonymizeIP: true,
+ botFiltering: true,
+ },
+
+ user: {
+ id: 'user1',
+ attributes: [
+ {
+ entityId: 'plan_type_id',
+ key: 'plan_type',
+ value: 'bronze',
+ },
+ ],
+ },
+
+ event: {
+ id: 'event-id',
+ key: 'event',
+ },
+
+ revenue: 1000,
+ value: 123,
+ tags: {
+ value: '123',
+ revenue: 1000,
+ tag1: 'value1',
+ },
+ });
+ });
+ });
+
+ describe('when botFiltering and anonymizeIP are undefined', function() {
+ it('should create an ImpressionEvent with the correct attributes', function() {
+ projectConfig.getEventId.withArgs(configObj, 'event').returns('event-id');
+ projectConfig.getAttributeId.withArgs(configObj, 'plan_type').returns('plan_type_id');
+
+ delete configObj['anonymizeIP'];
+ delete configObj['botFiltering'];
+
+ var result = buildConversionEvent({
+ configObj: configObj,
+ eventKey: 'event',
+ eventTags: {
+ value: '123',
+ revenue: 1000,
+ tag1: 'value1',
+ },
+ userId: 'user1',
+ userAttributes: {
+ plan_type: 'bronze',
+ invalid: 'value',
+ },
+ clientEngine: 'node',
+ clientVersion: '3.0.11',
+ });
+
+ assert.deepEqual(result, {
+ type: 'conversion',
+ timestamp: 100,
+ uuid: 'uuid',
+ context: {
+ region: 'US',
+ accountId: 'accountId',
+ projectId: 'projectId',
+ revision: '69',
+ clientName: 'node',
+ clientVersion: '3.0.11',
+ anonymizeIP: false,
+ botFiltering: undefined,
+ },
+
+ user: {
+ id: 'user1',
+ attributes: [
+ {
+ entityId: 'plan_type_id',
+ key: 'plan_type',
+ value: 'bronze',
+ },
+ ],
+ },
+
+ event: {
+ id: 'event-id',
+ key: 'event',
+ },
+
+ revenue: 1000,
+ value: 123,
+ tags: {
+ value: '123',
+ revenue: 1000,
+ tag1: 'value1',
+ },
+ });
+ });
+ });
+ });
+});
diff --git a/lib/event_processor/event_builder/user_event.ts b/lib/event_processor/event_builder/user_event.ts
new file mode 100644
index 000000000..ae33d65da
--- /dev/null
+++ b/lib/event_processor/event_builder/user_event.ts
@@ -0,0 +1,306 @@
+/**
+ * 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 { 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 fns from '../../utils/fns';
+import {
+ getAttributeId,
+ getEventId,
+ getLayerId,
+ ProjectConfig,
+ Region,
+} from '../../project_config/project_config';
+
+import { EventTags, UserAttributes } from '../../shared_types';
+import { LoggerFacade } from '../../logging/logger';
+import { DECISION_SOURCES } from '../../common_exports';
+
+export 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;
+}
+
+type EventType = 'impression' | 'conversion';
+
+
+export type BaseUserEvent = {
+ type: T;
+ timestamp: number;
+ uuid: string;
+ context: EventContext;
+ user: {
+ id: string;
+ attributes: VisitorAttribute[];
+ };
+};
+
+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;
+ cmabUuid?: string;
+};
+
+export type ConversionEvent = BaseUserEvent<'conversion'> & {
+ event: {
+ id: string | null;
+ key: string;
+ }
+
+ revenue: number | null;
+ value: number | null;
+ tags?: EventTags;
+}
+
+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
+ )
+}
+
+const buildBaseEvent = ({
+ configObj,
+ userId,
+ userAttributes,
+ clientEngine,
+ clientVersion,
+ type,
+}: {
+ configObj: ProjectConfig;
+ userId: string;
+ userAttributes?: UserAttributes;
+ clientEngine: string;
+ clientVersion: 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),
+ },
+ };
+
+}
+
+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
+ * @param {ImpressionConfig} config
+ * @return {ImpressionEvent} an ImpressionEvent object
+ */
+export const buildImpressionEvent = function({
+ configObj,
+ decisionObj,
+ userId,
+ flagKey,
+ enabled,
+ userAttributes,
+ clientEngine,
+ clientVersion,
+}: ImpressionConfig): ImpressionEvent {
+
+ const ruleType = decisionObj.decisionSource;
+ const experimentKey = decision.getExperimentKey(decisionObj);
+ const experimentId = decision.getExperimentId(decisionObj);
+ const variationKey = decision.getVariationKey(decisionObj);
+ const variationId = decision.getVariationId(decisionObj);
+ const cmabUuid = decisionObj.cmabUuid;
+ const layerId =
+ experimentId !== null ? (ruleType === DECISION_SOURCES.HOLDOUT ? '' : getLayerId(configObj, experimentId)) : null;
+
+ return {
+ ...buildBaseEvent({
+ configObj,
+ userId,
+ userAttributes,
+ clientEngine,
+ clientVersion,
+ type: 'impression',
+ }),
+
+ layer: {
+ id: layerId,
+ },
+
+ experiment: {
+ id: experimentId,
+ key: experimentKey,
+ },
+
+ variation: {
+ id: variationId,
+ key: variationKey,
+ },
+
+ ruleKey: experimentKey,
+ 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
+ * @return {ConversionEvent} a ConversionEvent object
+ */
+export const buildConversionEvent = function({
+ configObj,
+ userId,
+ userAttributes,
+ clientEngine,
+ clientVersion,
+ eventKey,
+ eventTags,
+}: ConversionConfig, logger?: LoggerFacade): ConversionEvent {
+
+ const eventId = getEventId(configObj, eventKey);
+
+ const revenue = eventTags ? eventTagUtils.getRevenueValue(eventTags, logger) : null;
+ const eventValue = eventTags ? eventTagUtils.getEventValue(eventTags, logger) : null;
+
+ return {
+ ...buildBaseEvent({
+ configObj,
+ userId,
+ userAttributes,
+ clientEngine,
+ clientVersion,
+ type: 'conversion',
+ }),
+
+ event: {
+ id: eventId,
+ key: eventKey,
+ },
+
+ revenue: revenue,
+ value: eventValue,
+ tags: eventTags,
+ };
+};
+
+
+const buildVisitorAttributes = (
+ configObj: ProjectConfig,
+ attributes?: UserAttributes,
+ logger?: LoggerFacade
+): VisitorAttribute[] => {
+ if (!attributes) {
+ return [];
+ }
+
+ // Omit attribute values that are not supported by the log endpoint.
+ 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/lib/event_processor/event_dispatcher/default_dispatcher.browser.ts b/lib/event_processor/event_dispatcher/default_dispatcher.browser.ts
new file mode 100644
index 000000000..d38d266aa
--- /dev/null
+++ b/lib/event_processor/event_dispatcher/default_dispatcher.browser.ts
@@ -0,0 +1,23 @@
+/**
+ * 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 { BrowserRequestHandler } from "../../utils/http_request_handler/request_handler.browser";
+import { EventDispatcher } from './event_dispatcher';
+import { DefaultEventDispatcher } from './default_dispatcher';
+
+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/lib/event_processor/event_dispatcher/default_dispatcher.node.ts b/lib/event_processor/event_dispatcher/default_dispatcher.node.ts
new file mode 100644
index 000000000..65dc115af
--- /dev/null
+++ b/lib/event_processor/event_dispatcher/default_dispatcher.node.ts
@@ -0,0 +1,22 @@
+/**
+ * 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 { EventDispatcher } from './event_dispatcher';
+import { NodeRequestHandler } from '../../utils/http_request_handler/request_handler.node';
+import { DefaultEventDispatcher } from './default_dispatcher';
+
+const eventDispatcher: EventDispatcher = new DefaultEventDispatcher(new NodeRequestHandler());
+
+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/lib/event_processor/event_dispatcher/event_dispatcher.ts b/lib/event_processor/event_dispatcher/event_dispatcher.ts
new file mode 100644
index 000000000..4dfda8f30
--- /dev/null
+++ b/lib/event_processor/event_dispatcher/event_dispatcher.ts
@@ -0,0 +1,30 @@
+/**
+ * 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 { EventBatch } from "../event_builder/log_event";
+
+export type EventDispatcherResponse = {
+ statusCode?: number
+}
+
+export interface EventDispatcher {
+ dispatchEvent(event: LogEvent): Promise
+}
+
+export interface LogEvent {
+ url: string
+ httpVerb: 'POST' | 'PUT' | 'GET' | 'PATCH'
+ 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..ce795dbaa
--- /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 { Cache } from './utils/cache/cache';
+export type { 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..8b2e93e7f
--- /dev/null
+++ b/lib/index.browser.tests.js
@@ -0,0 +1,352 @@
+/**
+ * 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(),
+})
+
+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.1.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 84%
rename from packages/optimizely-sdk/lib/index.browser.umdtests.js
rename to lib/index.browser.umdtests.js
index 32181928e..a13f5046b 100644
--- a/packages/optimizely-sdk/lib/index.browser.umdtests.js
+++ b/lib/index.browser.umdtests.js
@@ -1,5 +1,5 @@
/**
- * Copyright 2018, Optimizely
+ * Copyright 2018-2020 Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,22 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-var configValidator = require('./utils/config_validator');
-var enums = require('./utils/enums');
-var logger = require('./plugins/logger');
-
-var packageJSON = require('../package.json');
-var eventDispatcher = require('./plugins/event_dispatcher/index.browser');
-var testData = require('./tests/test_data');
-
-var chai = require('chai');
-var assert = chai.assert;
-var sinon = require('sinon');
+import { assert } from 'chai';
+import sinon from 'sinon';
+
+import configValidator from './utils/config_validator';
+import * as enums from './utils/enums';
+import * as logger from './plugins/logger';
+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() {
- var xhr;
- var requests;
describe('createInstance', function() {
var fakeErrorHandler = { handleError: function() {} };
var fakeEventDispatcher = { dispatchEvent: function() {} };
@@ -40,18 +38,16 @@ describe('javascript-sdk', function() {
logToConsole: false,
});
sinon.stub(configValidator, 'validate');
+ sinon.stub(Optimizely.prototype, 'close');
- xhr = sinon.useFakeXMLHttpRequest();
- global.XMLHttpRequest = xhr;
- requests = [];
- xhr.onCreate = function(req) {
- requests.push(req);
- };
+ global.XMLHttpRequest = sinon.useFakeXMLHttpRequest();
sinon.spy(console, 'log');
sinon.spy(console, 'info');
sinon.spy(console, 'warn');
sinon.spy(console, 'error');
+
+ sinon.spy(window, 'addEventListener');
});
afterEach(function() {
@@ -59,8 +55,10 @@ describe('javascript-sdk', function() {
console.info.restore();
console.warn.restore();
console.error.restore();
+ window.addEventListener.restore();
configValidator.validate.restore();
- xhr.restore();
+ Optimizely.prototype.close.restore();
+ delete global.XMLHttpRequest
});
// this test has to come first due to local state of the logLevel
@@ -68,12 +66,11 @@ describe('javascript-sdk', function() {
// checking that INFO logs log for an unspecified logLevel
var optlyInstance = window.optimizelySdk.createInstance({
datafile: testData.getTestProjectConfig(),
- skipJSONValidation: true,
});
assert.strictEqual(console.info.getCalls().length, 1);
- call = console.info.getCalls()[0];
+ var call = console.info.getCalls()[0];
assert.strictEqual(call.args.length, 1);
- assert(call.args[0].indexOf('OPTIMIZELY: Skipping JSON schema validation.') > -1);
+ assert(call.args[0].indexOf('PROJECT_CONFIG: Skipping JSON schema validation.') > -1);
});
it('should instantiate the logger with a custom logLevel when provided', function() {
@@ -81,7 +78,6 @@ describe('javascript-sdk', function() {
var optlyInstance = window.optimizelySdk.createInstance({
datafile: testData.getTestProjectConfig(),
logLevel: enums.LOG_LEVEL.ERROR,
- skipJSONValidation: true,
});
assert.strictEqual(console.log.getCalls().length, 0);
@@ -92,15 +88,19 @@ describe('javascript-sdk', function() {
});
optlyInstance.activate('testExperiment', 'testUser');
assert.strictEqual(console.error.getCalls().length, 1);
+ // Invalid datafile causes onReady Promise rejection - catch this error
+ optlyInstanceInvalid.onReady().catch(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() {
- window.optimizelySdk.createInstance({
+ var optlyInstance = window.optimizelySdk.createInstance({
datafile: {},
logger: silentLogger,
});
+ // Invalid datafile causes onReady Promise rejection - catch this error
+ optlyInstance.onReady().catch(function() {});
});
});
@@ -113,6 +113,8 @@ describe('javascript-sdk', function() {
});
assert.equal('javascript-sdk', optlyInstance.clientEngine);
assert.equal(packageJSON.version, optlyInstance.clientVersion);
+ // Invalid datafile causes onReady Promise rejection - catch this error
+ optlyInstance.onReady().catch(function() {});
});
it('should activate with provided event dispatcher', function() {
@@ -286,6 +288,18 @@ describe('javascript-sdk', function() {
var variation = optlyInstance.getVariation('testExperimentNotRunning', 'testUser');
assert.strictEqual(variation, null);
});
+
+ it('should hook into window `pagehide` event', function() {
+ var optlyInstance = window.optimizelySdk.createInstance({
+ datafile: testData.getTestProjectConfig(),
+ errorHandler: fakeErrorHandler,
+ eventDispatcher: eventDispatcher,
+ logger: silentLogger,
+ });
+
+ sinon.assert.calledOnce(window.addEventListener);
+ sinon.assert.calledWith(window.addEventListener, sinon.match('pagehide').or(sinon.match('unload')));
+ });
});
});
});
diff --git a/lib/index.node.tests.js b/lib/index.node.tests.js
new file mode 100644
index 000000000..343312174
--- /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.1.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..3fa5a6357
--- /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.1.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..9aaaa8cd3
--- /dev/null
+++ b/lib/index.universal.ts
@@ -0,0 +1,136 @@
+/**
+ * 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 { Cache } from './utils/cache/cache';
+
+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