diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e7ba7782e..9ed2f0107 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,7 +6,7 @@ on: action: required: true type: string - travis_tag: + github_tag: required: true type: string secrets: @@ -22,13 +22,14 @@ jobs: run_build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + with: + persist-credentials: false - name: set up JDK 8 uses: actions/setup-java@v2 with: java-version: '8' distribution: 'temurin' - cache: gradle - name: Grant execute permission for gradlew run: chmod +x gradlew - name: ${{ inputs.action }} @@ -37,4 +38,4 @@ jobs: MAVEN_SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }} MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - run: TRAVIS_TAG=${{ inputs.travis_tag }} ./gradlew ${{ inputs.action }} + run: GITHUB_TAG=${{ inputs.github_tag }} ./gradlew ${{ inputs.action }} diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index c54149edd..0d6fc346a 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -9,33 +9,30 @@ on: secrets: CI_USER_TOKEN: required: true - TRAVIS_COM_TOKEN: - required: true jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: # You should create a personal access token and store it in your repository token: ${{ secrets.CI_USER_TOKEN }} - repository: 'optimizely/travisci-tools' - path: 'home/runner/travisci-tools' + repository: 'optimizely/ci-helper-tools' + path: 'home/runner/ci-helper-tools' ref: 'master' + persist-credentials: false - 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: java @@ -45,16 +42,13 @@ jobs: 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 }} - TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} EVENT_MESSAGE: ${{ github.event.message }} HOME: 'home/runner' run: | echo "$GITHUB_CONTEXT" - home/runner/travisci-tools/trigger-script-with-status-update.sh + home/runner/ci-helper-tools/trigger-script-with-status-update.sh diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index 1c6c57a02..6373e1942 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -17,7 +17,9 @@ jobs: lint_markdown_files: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -31,10 +33,9 @@ jobs: integration_tests: if: ${{ startsWith(github.ref, 'refs/tags/') != true && github.event.inputs.SNAPSHOT != 'true' }} - uses: optimizely/java-sdk/.github/workflows/integration_test.yml@mnoman/fsc-gitaction-test + uses: optimizely/java-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: if: ${{ startsWith(github.ref, 'refs/tags/') != true && github.event.inputs.SNAPSHOT != 'true' }} @@ -43,7 +44,6 @@ jobs: FULLSTACK_TEST_REPO: ProdTesting secrets: CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} - TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} test: if: ${{ startsWith(github.ref, 'refs/tags/') != true && github.event.inputs.SNAPSHOT != 'true' }} @@ -51,23 +51,26 @@ jobs: strategy: fail-fast: false matrix: - jdk: [8, 9] + # github not support JVM 8 anymore + jdk: [11, 17] optimizely_default_parser: [GSON_CONFIG_PARSER, JACKSON_CONFIG_PARSER, JSON_CONFIG_PARSER, JSON_SIMPLE_CONFIG_PARSER] steps: - name: checkout - uses: actions/checkout@v2 - + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: set up JDK ${{ matrix.jdk }} - uses: AdoptOpenJDK/install-jdk@v1 + uses: actions/setup-java@v4 with: - version: ${{ matrix.jdk }} - architecture: x64 + java-version: ${{ matrix.jdk }} + distribution: 'temurin' - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Gradle cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: | ~/.gradle/caches @@ -85,19 +88,19 @@ jobs: - name: Check on failures if: always() && steps.unit_tests.outcome != 'success' run: | - cat /home/runner/java-sdk/core-api/build/reports/findbugs/main.html - cat /home/runner/java-sdk/core-api/build/reports/findbugs/test.html + cat /Users/runner/work/java-sdk/core-api/build/reports/spotbugs/main.html + cat /Users/runner/work/java-sdk/core-api/build/reports/spotbugs/test.html - name: Check on success if: always() && steps.unit_tests.outcome == 'success' run: | - ./gradlew coveralls uploadArchives --console plain + ./gradlew coveralls --console plain publish: if: startsWith(github.ref, 'refs/tags/') uses: optimizely/java-sdk/.github/workflows/build.yml@master with: action: ship - travis_tag: ${GITHUB_REF#refs/*/} + github_tag: ${GITHUB_REF#refs/*/} secrets: MAVEN_SIGNING_KEY_BASE64: ${{ secrets.MAVEN_SIGNING_KEY_BASE64 }} MAVEN_SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }} @@ -109,7 +112,7 @@ jobs: uses: optimizely/java-sdk/.github/workflows/build.yml@master with: action: ship - travis_tag: BB-SNAPSHOT + github_tag: BB-SNAPSHOT secrets: MAVEN_SIGNING_KEY_BASE64: ${{ secrets.MAVEN_SIGNING_KEY_BASE64 }} MAVEN_SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }} diff --git a/.github/workflows/source_clear_cron.yml b/.github/workflows/source_clear_cron.yml deleted file mode 100644 index 54eca5358..000000000 --- a/.github/workflows/source_clear_cron.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Source clear - -on: - schedule: - # Runs "weekly" - - cron: '0 0 * * 0' - -jobs: - source_clear: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Source clear scan - env: - SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} - run: curl -sSL https://download.sourceclear.com/ci.sh | bash -s – scan diff --git a/.gitignore b/.gitignore index aefc53cb6..dcf3ee891 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ classes .vagrant .DS_Store .venv + +.vscode/mcp.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 104422c93..bce2de543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Optimizely Java X SDK Changelog +## [4.4.2] +May 29, 2026 + +### Fixes +- Include commonIdentifiers when counting identifiers in identifyUser ([#631](https://github.com/optimizely/java-sdk/pull/631)) + + +## [4.4.1] +May 28, 2026 + +### Fixes +- Block ODP identify event for single identifier ([#629](https://github.com/optimizely/java-sdk/pull/629)) +- Add local holdouts support ([#628](https://github.com/optimizely/java-sdk/pull/628)) + + +## [4.4.0] +May 4, 2026 + +### New Features + +**Feature Rollout**: Added support for Feature Rollouts, a new experiment type +combining Targeted Delivery simplicity with A/B test measurement capabilities. +Feature Rollouts enable progressive rollouts with full impact analytics, metric tracking, +and confidence intervals. +See [Feature Rollout docs](https://support.optimizely.com/hc/en-us/articles/45552846481037-Run-Feature-Rollouts-in-Feature-Experimentation) for more information. + +- Remove experiment type validation from config parsing ([#602](https://github.com/optimizely/java-sdk/pull/602)) +- Add Feature Rollout support ([#601](https://github.com/optimizely/java-sdk/pull/601)) + +### Fixes and Improvements +- Remove legacy flag-level holdout fields ([#604](https://github.com/optimizely/java-sdk/pull/604)) + + +## [4.3.1] +Jan 20, 2025 + +### Fixes +* [FSSDK-12030] Exclude CMAB from UserProfileService ([#595](https://github.com/optimizely/java-sdk/pull/595)) +* [FSSDK-11953] Fix missing bucketing reasons in CMAB decision path ([#592](https://github.com/optimizely/java-sdk/pull/592)) + + +## [4.3.0] +Dec 10th, 2025 + +### New Features +- **CMAB (Contextual Multi-Armed Bandit) Support**: Added support for CMAB experiments with new configuration options and cache control ([#577](https://github.com/optimizely/java-sdk/pull/577), [#578](https://github.com/optimizely/java-sdk/pull/578), [#579](https://github.com/optimizely/java-sdk/pull/579), [#582](https://github.com/optimizely/java-sdk/pull/582), [#583](https://github.com/optimizely/java-sdk/pull/583), [#584](https://github.com/optimizely/java-sdk/pull/584), [#585](https://github.com/optimizely/java-sdk/pull/585), [#590](https://github.com/optimizely/java-sdk/pull/590), [#593](https://github.com/optimizely/java-sdk/pull/593)) +- **Add Holdouts Feature**: Add Holdout support for feature experimentation ([#572](https://github.com/optimizely/java-sdk/pull/572), [#576](https://github.com/optimizely/java-sdk/pull/576)) +- **Multi-Region Support for Data Hosting**: Added SDK support for multi-region data hosting ([#573](https://github.com/optimizely/java-sdk/pull/573)) + +### API Changes +- **OptimizelyUserContext**: New asynchronous decision-making methods + - `decideAsync()`: Asynchronous method to make a decision for a single flag with CMAB support + - `decideAllAsync()`: Asynchronous method to make decisions for all flags + - `decideForKeysAsync()`: Asynchronous method to make decisions for multiple flag keys + +- **Client Initialization**: + - `CmabClientConfig` can be injected when initializing the client for custom CMAB configuration + - `CmabService` can be provided to `OptimizelyFactory` for custom CMAB service implementation + +- **New Decide Options**: Added cache control options for CMAB + - `IGNORE_CMAB_CACHE`: Skip reading from CMAB cache + - `RESET_CMAB_CACHE`: Clear and reset CMAB cache before decision + - `INVALIDATE_USER_CMAB_CACHE`: Invalidate cache entries for specific user + +## [4.2.2] +May 28th, 2025 + +### Fixes +- Added experimentId and variationId to decision notification ([#569](https://github.com/optimizely/java-sdk/pull/569)). + +## [4.2.1] +Feb 19th, 2025 + +### Fixes +- Fix big integer conversion ([#556](https://github.com/optimizely/java-sdk/pull/556)). + ## [4.2.0] November 6th, 2024 diff --git a/README.md b/README.md index 33e55928d..1b2bfd2c0 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,19 @@ # Optimizely Java SDK -[](https://travis-ci.org/optimizely/java-sdk) +[](https://mvnrepository.com/artifact/com.optimizely.ab/core-api) +[](https://github.com/optimizely/java-sdk/actions/workflows/java.yml?query=branch%3Amaster) +[](https://coveralls.io/github/optimizely/java-sdk?branch=master) [](http://www.apache.org/licenses/LICENSE-2.0) This repository houses the Java SDK for use with Optimizely Feature Experimentation and Optimizely Full Stack (legacy). -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/experimentation/v4.0.0-full-stack/docs/welcome). +Optimizely Feature Experimentation is an A/B testing and feature management tool for product development teams that enables you to experiment at every step. Using Optimizely Feature Experimentation allows for every feature on your roadmap to be an opportunity to discover hidden insights. Learn more at [Optimizely.com](https://www.optimizely.com/products/experiment/feature-experimentation/), or see the [developer documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/introduction). Optimizely Rollouts is [free feature flags](https://www.optimizely.com/free-feature-flagging/) for development teams. You can easily roll out and roll back features in any application without code deploys, mitigating risk for every feature on your roadmap. ## Get started -Refer to the [Java SDK's developer documentation](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/java-sdk) for detailed instructions on getting started with using the SDK. +Refer to the [Java SDK's developer documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/java-sdk) for detailed instructions on getting started with using the SDK. ### Requirements @@ -45,16 +47,18 @@ dependencies { compile 'com.optimizely.ab:core-api:{VERSION}' compile 'com.optimizely.ab:core-httpclient-impl:{VERSION}' // The SDK integrates with multiple JSON parsers, here we use Jackson. - compile 'com.fasterxml.jackson.core:jackson-core:2.7.1' - compile 'com.fasterxml.jackson.core:jackson-annotations:2.7.1' - compile 'com.fasterxml.jackson.core:jackson-databind:2.7.1' + compile 'com.fasterxml.jackson.core:jackson-core:2.13.5' + compile 'com.fasterxml.jackson.core:jackson-annotations:2.13.5' + compile 'com.fasterxml.jackson.core:jackson-databind:2.13.5' } ``` +## Feature Management Access +To access the Feature Management configuration in the Optimizely dashboard, please contact your Optimizely customer success manager. ## Use the Java SDK -See the Optimizely Feature Experimentation [developer documentation](https://docs.developers.optimizely.com/experimentation/v4.0-full-stack/docs/java-sdk) to learn how to set up your first Java project and use the SDK. +See the Optimizely Feature Experimentation [developer documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/java-sdk) to learn how to set up your first Java project and use the SDK. ## SDK Development @@ -71,7 +75,7 @@ You can run all unit tests with: ### Checking for bugs -We utilize [FindBugs](http://findbugs.sourceforge.net/) to identify possible bugs in the SDK. To run the check: +We utilize [SpotBugs](https://spotbugs.github.io/) to identify possible bugs in the SDK. To run the check: ``` @@ -163,8 +167,6 @@ License (Apache 2.0): [https://github.com/apache/httpcomponents-client/blob/mast - Go - https://github.com/optimizely/go-sdk -- Java - https://github.com/optimizely/java-sdk - - JavaScript - https://github.com/optimizely/javascript-sdk - PHP - https://github.com/optimizely/php-sdk @@ -176,3 +178,4 @@ License (Apache 2.0): [https://github.com/apache/httpcomponents-client/blob/mast - Ruby - https://github.com/optimizely/ruby-sdk - Swift - https://github.com/optimizely/swift-sdk + diff --git a/build.gradle b/build.gradle index b8405e39b..5b449a47e 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,13 @@ plugins { - id 'com.github.kt3k.coveralls' version '2.8.2' + id 'com.github.kt3k.coveralls' version '2.12.2' id 'jacoco' - id 'me.champeau.gradle.jmh' version '0.4.5' - id 'nebula.optional-base' version '3.2.0' - id 'com.github.hierynomus.license' version '0.15.0' - id 'com.github.spotbugs' version "4.5.0" + id 'me.champeau.gradle.jmh' version '0.5.3' + id 'nebula.optional-base' version '3.1.0' + id 'com.github.hierynomus.license' version '0.16.1' + id 'com.github.spotbugs' version "6.0.14" + id 'maven-publish' + id 'signing' + id 'io.github.gradle-nexus.publish-plugin' version '2.0.0' } allprojects { @@ -12,7 +15,10 @@ allprojects { apply plugin: 'jacoco' repositories { - jcenter() + mavenCentral() + maven { + url 'https://plugins.gradle.org/m2/' + } } jacoco { @@ -23,9 +29,9 @@ allprojects { allprojects { group = 'com.optimizely.ab' - def travis_defined_version = System.getenv('TRAVIS_TAG') - if (travis_defined_version != null) { - version = travis_defined_version + def github_tagged_version = System.getenv('GITHUB_TAG') + if (github_tagged_version != null) { + version = github_tagged_version } ext.isReleaseVersion = !version.endsWith("SNAPSHOT") @@ -46,13 +52,6 @@ configure(publishedProjects) { sourceCompatibility = 1.8 targetCompatibility = 1.8 - repositories { - jcenter() - maven { - url 'https://plugins.gradle.org/m2/' - } - } - task sourcesJar(type: Jar, dependsOn: classes) { archiveClassifier.set('sources') from sourceSets.main.allSource @@ -72,6 +71,7 @@ configure(publishedProjects) { spotbugs { spotbugsJmh.enabled = false + reportLevel = com.github.spotbugs.snom.Confidence.valueOf('HIGH') } test { @@ -94,21 +94,28 @@ configure(publishedProjects) { } dependencies { - compile group: 'commons-codec', name: 'commons-codec', version: commonCodecVersion + implementation group: 'commons-codec', name: 'commons-codec', version: commonCodecVersion - testCompile group: 'junit', name: 'junit', version: junitVersion - testCompile group: 'org.mockito', name: 'mockito-core', version: mockitoVersion - testCompile group: 'org.hamcrest', name: 'hamcrest-all', version: hamcrestVersion - testCompile group: 'com.google.guava', name: 'guava', version: guavaVersion + testImplementation group: 'junit', name: 'junit', version: junitVersion + testImplementation group: 'org.mockito', name: 'mockito-core', version: mockitoVersion + testImplementation group: 'org.hamcrest', name: 'hamcrest-all', version: hamcrestVersion + testImplementation group: 'com.google.guava', name: 'guava', version: guavaVersion // logging dependencies (logback) - testCompile group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion - testCompile group: 'ch.qos.logback', name: 'logback-core', version: logbackVersion + testImplementation group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion + testImplementation group: 'ch.qos.logback', name: 'logback-core', version: logbackVersion + + testImplementation group: 'com.google.code.gson', name: 'gson', version: gsonVersion + testImplementation group: 'org.json', name: 'json', version: jsonVersion + testImplementation group: 'com.googlecode.json-simple', name: 'json-simple', version: jsonSimpleVersion + testImplementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion + } - testCompile group: 'com.google.code.gson', name: 'gson', version: gsonVersion - testCompile group: 'org.json', name: 'json', version: jsonVersion - testCompile group: 'com.googlecode.json-simple', name: 'json-simple', version: jsonSimpleVersion - testCompile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion + configurations.all { + resolutionStrategy { + force "junit:junit:${junitVersion}" + force 'com.netflix.nebula:nebula-gradle-interop:2.2.2' + } } def docTitle = "Optimizely Java SDK" @@ -127,17 +134,6 @@ configure(publishedProjects) { artifact javadocJar } } - repositories { - maven { - def releaseUrl = "https://oss.sonatype.org/service/local/staging/deploy/maven2" - def snapshotUrl = "https://oss.sonatype.org/content/repositories/snapshots" - url = isReleaseVersion ? releaseUrl : snapshotUrl - credentials { - username System.getenv('MAVEN_CENTRAL_USERNAME') - password System.getenv('MAVEN_CENTRAL_PASSWORD') - } - } - } } signing { @@ -173,7 +169,18 @@ configure(publishedProjects) { } task ship() { - dependsOn(':core-api:ship', ':core-httpclient-impl:ship') + dependsOn(':core-httpclient-impl:ship', ':core-api:ship', 'publishToSonatype', 'closeSonatypeStagingRepository') +} + +nexusPublishing { + repositories { + sonatype { + nexusUrl.set(uri('https://ossrh-staging-api.central.sonatype.com/service/local/')) + snapshotRepositoryUrl.set(uri('https://central.sonatype.com/repository/maven-snapshots/')) + username = System.getenv('MAVEN_CENTRAL_USERNAME') + password = System.getenv('MAVEN_CENTRAL_PASSWORD') + } + } } task jacocoMerge(type: JacocoMerge) { @@ -214,7 +221,6 @@ tasks.coveralls { } // standard POM format required by MavenCentral - def customizePom(pom, title) { pom.withXml { asNode().children().last() + { diff --git a/core-api/build.gradle b/core-api/build.gradle index d2609a97d..602131cd3 100644 --- a/core-api/build.gradle +++ b/core-api/build.gradle @@ -1,9 +1,10 @@ dependencies { - compile group: 'org.slf4j', name: 'slf4j-api', version: slf4jVersion - compile group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: jacksonVersion - - compile group: 'com.google.code.findbugs', name: 'annotations', version: findbugsAnnotationVersion - compile group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsJsrVersion + implementation group: 'org.slf4j', name: 'slf4j-api', version: slf4jVersion + implementation group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: jacksonVersion + implementation group: 'com.google.code.findbugs', name: 'annotations', version: findbugsAnnotationVersion + implementation group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsJsrVersion + testImplementation group: 'junit', name: 'junit', version: junitVersion + testImplementation group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion // an assortment of json parsers compileOnly group: 'com.google.code.gson', name: 'gson', version: gsonVersion, optional @@ -12,6 +13,11 @@ dependencies { compileOnly group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion, optional } +tasks.named('processJmhResources') { + duplicatesStrategy = DuplicatesStrategy.WARN +} + + test { useJUnit { excludeCategories 'com.optimizely.ab.categories.ExhaustiveTest' @@ -24,6 +30,7 @@ task exhaustiveTest(type: Test) { } } + task generateVersionFile { // add the build version information into a file that'll go into the distribution ext.buildVersion = new File(projectDir, "src/main/resources/optimizely-build-version") diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 0e260072e..d2db01c90 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -16,26 +16,54 @@ package com.optimizely.ab; import com.optimizely.ab.annotations.VisibleForTesting; -import com.optimizely.ab.bucketing.Bucketer; -import com.optimizely.ab.bucketing.DecisionService; -import com.optimizely.ab.bucketing.FeatureDecision; -import com.optimizely.ab.bucketing.UserProfileService; -import com.optimizely.ab.config.*; +import com.optimizely.ab.bucketing.*; +import com.optimizely.ab.cmab.service.CmabService; +import com.optimizely.ab.config.AtomicProjectConfigManager; +import com.optimizely.ab.config.DatafileProjectConfig; +import com.optimizely.ab.config.EventType; +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.ExperimentCore; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; +import com.optimizely.ab.config.FeatureVariableUsageInstance; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.ProjectConfigManager; +import com.optimizely.ab.config.Variation; import com.optimizely.ab.config.parser.ConfigParseException; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.error.NoOpErrorHandler; -import com.optimizely.ab.event.*; -import com.optimizely.ab.event.internal.*; +import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.event.EventProcessor; +import com.optimizely.ab.event.ForwardingEventProcessor; +import com.optimizely.ab.event.LogEvent; +import com.optimizely.ab.event.NoopEventHandler; +import com.optimizely.ab.event.internal.BuildVersionInfo; +import com.optimizely.ab.event.internal.ClientEngineInfo; +import com.optimizely.ab.event.internal.EventFactory; +import com.optimizely.ab.event.internal.UserEvent; +import com.optimizely.ab.event.internal.UserEventFactory; import com.optimizely.ab.event.internal.payload.EventBatch; import com.optimizely.ab.internal.NotificationRegistry; -import com.optimizely.ab.notification.*; -import com.optimizely.ab.odp.*; +import com.optimizely.ab.notification.ActivateNotification; +import com.optimizely.ab.notification.DecisionNotification; +import com.optimizely.ab.notification.FeatureTestSourceInfo; +import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.notification.NotificationHandler; +import com.optimizely.ab.notification.RolloutSourceInfo; +import com.optimizely.ab.notification.SourceInfo; +import com.optimizely.ab.notification.TrackNotification; +import com.optimizely.ab.notification.UpdateConfigNotification; +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.ODPManager; +import com.optimizely.ab.odp.ODPSegmentManager; +import com.optimizely.ab.odp.ODPSegmentOption; +import com.optimizely.ab.odp.ODPUserKey; import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigManager; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigService; import com.optimizely.ab.optimizelydecision.*; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; -import java.util.concurrent.locks.ReentrantLock; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,20 +72,25 @@ import javax.annotation.concurrent.ThreadSafe; import java.io.Closeable; -import java.util.*; -import java.util.stream.Collectors; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.locks.ReentrantLock; import static com.optimizely.ab.internal.SafetyUtils.tryClose; /** * Top-level container class for Optimizely functionality. * Thread-safe, so can be created as a singleton and safely passed around. - * + *
* Example instantiation: *
* Optimizely optimizely = Optimizely.builder(projectWatcher, eventHandler).build(); *- * + *
* To activate an experiment and perform variation specific processing: *
* Variation variation = optimizely.activate(experimentKey, userId, attributes);
@@ -104,8 +137,11 @@ public class Optimizely implements AutoCloseable {
@Nullable
private final ODPManager odpManager;
+ private final CmabService cmabService;
+
private final ReentrantLock lock = new ReentrantLock();
+
private Optimizely(@Nonnull EventHandler eventHandler,
@Nonnull EventProcessor eventProcessor,
@Nonnull ErrorHandler errorHandler,
@@ -115,7 +151,8 @@ private Optimizely(@Nonnull EventHandler eventHandler,
@Nullable OptimizelyConfigManager optimizelyConfigManager,
@Nonnull NotificationCenter notificationCenter,
@Nonnull List defaultDecideOptions,
- @Nullable ODPManager odpManager
+ @Nullable ODPManager odpManager,
+ @Nonnull CmabService cmabService
) {
this.eventHandler = eventHandler;
this.eventProcessor = eventProcessor;
@@ -127,6 +164,7 @@ private Optimizely(@Nonnull EventHandler eventHandler,
this.notificationCenter = notificationCenter;
this.defaultDecideOptions = defaultDecideOptions;
this.odpManager = odpManager;
+ this.cmabService = cmabService;
if (odpManager != null) {
odpManager.getEventManager().start();
@@ -136,7 +174,9 @@ private Optimizely(@Nonnull EventHandler eventHandler,
if (projectConfigManager.getSDKKey() != null) {
NotificationRegistry.getInternalNotificationCenter(projectConfigManager.getSDKKey()).
addNotificationHandler(UpdateConfigNotification.class,
- configNotification -> { updateODPSettings(); });
+ configNotification -> {
+ updateODPSettings();
+ });
}
}
@@ -266,7 +306,7 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig,
@Nonnull Map filteredAttributes,
@Nonnull Variation variation,
@Nonnull String ruleType) {
- sendImpression(projectConfig, experiment, userId, filteredAttributes, variation, "", ruleType, true);
+ sendImpression(projectConfig, experiment, userId, filteredAttributes, variation, "", ruleType, true, null);
}
/**
@@ -279,15 +319,17 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig,
* @param variation the variation that was returned from activate.
* @param flagKey It can either be empty if ruleType is experiment or it's feature key in case ruleType is feature-test or rollout
* @param ruleType It can either be experiment in case impression event is sent from activate or it's feature-test or rollout
+ * @param cmabUuid The cmabUuid if the experiment is a cmab experiment.
*/
private boolean sendImpression(@Nonnull ProjectConfig projectConfig,
- @Nullable Experiment experiment,
+ @Nullable ExperimentCore experiment,
@Nonnull String userId,
@Nonnull Map filteredAttributes,
@Nullable Variation variation,
@Nonnull String flagKey,
@Nonnull String ruleType,
- @Nonnull boolean enabled) {
+ @Nonnull boolean enabled,
+ @Nullable String cmabUuid) {
UserEvent userEvent = UserEventFactory.createImpressionEvent(
projectConfig,
@@ -297,22 +339,28 @@ private boolean sendImpression(@Nonnull ProjectConfig projectConfig,
filteredAttributes,
flagKey,
ruleType,
- enabled);
+ enabled,
+ cmabUuid);
if (userEvent == null) {
return false;
}
+ eventProcessor.getClass().getName();
eventProcessor.process(userEvent);
if (experiment != null) {
logger.info("Activating user \"{}\" in experiment \"{}\".", userId, experiment.getKey());
}
+
+ // Legacy API methods only apply to the Experiment type and not to Holdout.
+ boolean isExperimentType = experiment instanceof Experiment;
+
// Kept For backwards compatibility.
// This notification is deprecated and the new DecisionNotifications
// are sent via their respective method calls.
- if (notificationCenter.getNotificationManager(ActivateNotification.class).size() > 0) {
+ if (notificationCenter.getNotificationManager(ActivateNotification.class).size() > 0 && isExperimentType) {
LogEvent impressionEvent = EventFactory.createLogEvent(userEvent);
ActivateNotification activateNotification = new ActivateNotification(
- experiment, userId, filteredAttributes, variation, impressionEvent);
+ (Experiment) experiment, userId, filteredAttributes, variation, impressionEvent);
notificationCenter.send(activateNotification);
}
return true;
@@ -455,7 +503,7 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig,
if (featureDecision.decisionSource != null) {
decisionSource = featureDecision.decisionSource;
}
-
+ String cmabUuid = featureDecision.cmabUuid;
if (featureDecision.variation != null) {
// This information is only necessary for feature tests.
// For rollouts experiments and variations are an implementation detail only.
@@ -477,7 +525,8 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig,
featureDecision.variation,
featureKey,
decisionSource.toString(),
- featureEnabled);
+ featureEnabled,
+ cmabUuid);
DecisionNotification decisionNotification = DecisionNotification.newFeatureDecisionNotificationBuilder()
.withUserId(userId)
@@ -634,6 +683,53 @@ public Integer getFeatureVariableInteger(@Nonnull String featureKey,
return variableValue;
}
+ /**
+ * Get the Long value of the specified variable in the feature.
+ *
+ * @param featureKey The unique key of the feature.
+ * @param variableKey The unique key of the variable.
+ * @param userId The ID of the user.
+ * @return The Integer value of the integer single variable feature.
+ * Null if the feature or variable could not be found.
+ */
+ @Nullable
+ public Long getFeatureVariableLong(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId) {
+ return getFeatureVariableLong(featureKey, variableKey, userId, Collections.emptyMap());
+ }
+
+ /**
+ * Get the Integer value of the specified variable in the feature.
+ *
+ * @param featureKey The unique key of the feature.
+ * @param variableKey The unique key of the variable.
+ * @param userId The ID of the user.
+ * @param attributes The user's attributes.
+ * @return The Integer value of the integer single variable feature.
+ * Null if the feature or variable could not be found.
+ */
+ @Nullable
+ public Long getFeatureVariableLong(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId,
+ @Nonnull Map attributes) {
+ try {
+ return getFeatureVariableValueForType(
+ featureKey,
+ variableKey,
+ userId,
+ attributes,
+ FeatureVariable.INTEGER_TYPE
+ );
+
+ } catch (Exception exception) {
+ logger.error("NumberFormatException while trying to parse value as Long. {}", String.valueOf(exception));
+ }
+
+ return null;
+ }
+
/**
* Get the String value of the specified variable in the feature.
*
@@ -828,8 +924,13 @@ Object convertStringToType(String variableValue, String type) {
try {
return Integer.parseInt(variableValue);
} catch (NumberFormatException exception) {
- logger.error("NumberFormatException while trying to parse \"" + variableValue +
- "\" as Integer. " + exception.toString());
+ try {
+ return Long.parseLong(variableValue);
+ } catch (NumberFormatException longException) {
+ logger.error("NumberFormatException while trying to parse \"{}\" as Integer. {}",
+ variableValue,
+ exception.toString());
+ }
}
break;
case FeatureVariable.JSON_TYPE:
@@ -845,11 +946,10 @@ Object convertStringToType(String variableValue, String type) {
/**
* Get the values of all variables in the feature.
*
- * @param featureKey The unique key of the feature.
- * @param userId The ID of the user.
+ * @param featureKey The unique key of the feature.
+ * @param userId The ID of the user.
* @return An OptimizelyJSON instance for all variable values.
* Null if the feature could not be found.
- *
*/
@Nullable
public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey,
@@ -860,12 +960,11 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey,
/**
* Get the values of all variables in the feature.
*
- * @param featureKey The unique key of the feature.
- * @param userId The ID of the user.
- * @param attributes The user's attributes.
+ * @param featureKey The unique key of the feature.
+ * @param userId The ID of the user.
+ * @param attributes The user's attributes.
* @return An OptimizelyJSON instance for all variable values.
* Null if the feature could not be found.
- *
*/
@Nullable
public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey,
@@ -949,7 +1048,6 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey,
* @param attributes The user's attributes.
* @return List of the feature keys that are enabled for the user if the userId is empty it will
* return Empty List.
- *
*/
public List getEnabledFeatures(@Nonnull String userId, @Nonnull Map attributes) {
List enabledFeaturesList = new ArrayList();
@@ -1164,10 +1262,10 @@ public OptimizelyConfig getOptimizelyConfig() {
/**
* Create a context of the user for which decision APIs will be called.
- *
+ *
* A user context will be created successfully even when the SDK is not fully configured yet.
*
- * @param userId The user ID to be used for bucketing.
+ * @param userId The user ID to be used for bucketing.
* @param attributes: A map of attribute names to current user attribute values.
* @return An OptimizelyUserContext associated with this OptimizelyClient.
*/
@@ -1193,20 +1291,6 @@ private OptimizelyUserContext createUserContextCopy(@Nonnull String userId, @Non
return new OptimizelyUserContext(this, userId, attributes, Collections.EMPTY_MAP, null, false);
}
- OptimizelyDecision decide(@Nonnull OptimizelyUserContext user,
- @Nonnull String key,
- @Nonnull List options) {
- ProjectConfig projectConfig = getProjectConfig();
- if (projectConfig == null) {
- return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.SDK_NOT_READY.reason());
- }
-
- List allOptions = getAllOptions(options);
- allOptions.remove(OptimizelyDecideOption.ENABLED_FLAGS_ONLY);
-
- return decideForKeys(user, Arrays.asList(key), allOptions, true).get(key);
- }
-
private OptimizelyDecision createOptimizelyDecision(
OptimizelyUserContext user,
String flagKey,
@@ -1216,6 +1300,8 @@ private OptimizelyDecision createOptimizelyDecision(
ProjectConfig projectConfig
) {
String userId = user.getUserId();
+ String experimentId = null;
+ String variationId = null;
Boolean flagEnabled = false;
if (flagDecision.variation != null) {
@@ -1249,10 +1335,14 @@ private OptimizelyDecision createOptimizelyDecision(
Boolean decisionEventDispatched = false;
+ experimentId = flagDecision.experiment != null ? flagDecision.experiment.getId() : null;
+ variationId = flagDecision.variation != null ? flagDecision.variation.getId() : null;
Map attributes = user.getAttributes();
Map copiedAttributes = new HashMap<>(attributes);
+ String cmabUuid = flagDecision.cmabUuid;
+
if (!allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) {
decisionEventDispatched = sendImpression(
projectConfig,
@@ -1262,7 +1352,8 @@ private OptimizelyDecision createOptimizelyDecision(
flagDecision.variation,
flagKey,
decisionSource.toString(),
- flagEnabled);
+ flagEnabled,
+ cmabUuid);
}
DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder()
@@ -1275,6 +1366,8 @@ private OptimizelyDecision createOptimizelyDecision(
.withRuleKey(ruleKey)
.withReasons(reasonsToReport)
.withDecisionEventDispatched(decisionEventDispatched)
+ .withExperimentId(experimentId)
+ .withVariationId(variationId)
.build();
notificationCenter.send(decisionNotification);
@@ -1288,16 +1381,72 @@ private OptimizelyDecision createOptimizelyDecision(
reasonsToReport);
}
- Map decideForKeys(@Nonnull OptimizelyUserContext user,
- @Nonnull List keys,
- @Nonnull List options) {
- return decideForKeys(user, keys, options, false);
+ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user,
+ @Nonnull String key,
+ @Nonnull List options) {
+ return decideInternal(user, key, options, DecisionPath.WITH_CMAB);
}
- private Map decideForKeys(@Nonnull OptimizelyUserContext user,
+ Map decideForKeys(@Nonnull OptimizelyUserContext user,
@Nonnull List keys,
- @Nonnull List options,
- boolean ignoreDefaultOptions) {
+ @Nonnull List options) {
+ return decideForKeysInternal(user, keys, options, false, DecisionPath.WITH_CMAB);
+ }
+
+ Map decideAll(@Nonnull OptimizelyUserContext user,
+ @Nonnull List options) {
+ return decideAllInternal(user, options, DecisionPath.WITH_CMAB);
+ }
+
+ /**
+ * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context,
+ * skipping CMAB logic and using only traditional A/B testing.
+ * This will be called by mobile apps which will make synchronous decisions only (for backward compatibility with android-sdk)
+ *
+ * @param user An OptimizelyUserContext associated with this OptimizelyClient.
+ * @param key A flag key for which a decision will be made.
+ * @param options A list of options for decision-making.
+ * @return A decision result using traditional A/B testing logic only.
+ */
+ OptimizelyDecision decideSync(@Nonnull OptimizelyUserContext user,
+ @Nonnull String key,
+ @Nonnull List options) {
+ return decideInternal(user, key, options, DecisionPath.WITHOUT_CMAB);
+ }
+
+ /**
+ * Returns decision results for multiple flag keys, skipping CMAB logic and using only traditional A/B testing.
+ * This will be called by mobile apps which will make synchronous decisions only (for backward compatibility with android-sdk)
+ *
+ * @param user An OptimizelyUserContext associated with this OptimizelyClient.
+ * @param keys A list of flag keys for which decisions will be made.
+ * @param options A list of options for decision-making.
+ * @return All decision results mapped by flag keys, using traditional A/B testing logic only.
+ */
+ Map decideForKeysSync(@Nonnull OptimizelyUserContext user,
+ @Nonnull List keys,
+ @Nonnull List options) {
+ return decideForKeysInternal(user, keys, options, false, DecisionPath.WITHOUT_CMAB);
+ }
+
+ /**
+ * Returns decision results for all active flag keys, skipping CMAB logic and using only traditional A/B testing.
+ * This will be called by mobile apps which will make synchronous decisions only (for backward compatibility with android-sdk)
+ *
+ * @param user An OptimizelyUserContext associated with this OptimizelyClient.
+ * @param options A list of options for decision-making.
+ * @return All decision results mapped by flag keys, using traditional A/B testing logic only.
+ */
+ Map decideAllSync(@Nonnull OptimizelyUserContext user,
+ @Nonnull List options) {
+ return decideAllInternal(user, options, DecisionPath.WITHOUT_CMAB);
+ }
+
+ private Map decideForKeysInternal(@Nonnull OptimizelyUserContext user,
+ @Nonnull List keys,
+ @Nonnull List options,
+ boolean ignoreDefaultOptions,
+ DecisionPath decisionPath) {
Map decisionMap = new HashMap<>();
ProjectConfig projectConfig = getProjectConfig();
@@ -1308,7 +1457,7 @@ private Map decideForKeys(@Nonnull OptimizelyUserCon
if (keys.isEmpty()) return decisionMap;
- List allOptions = ignoreDefaultOptions ? options: getAllOptions(options);
+ List allOptions = ignoreDefaultOptions ? options : getAllOptions(options);
Map flagDecisions = new HashMap<>();
Map decisionReasonsMap = new HashMap<>();
@@ -1342,16 +1491,31 @@ private Map decideForKeys(@Nonnull OptimizelyUserCon
}
List> decisionList =
- decisionService.getVariationsForFeatureList(flagsWithoutForcedDecision, user, projectConfig, allOptions);
+ decisionService.getVariationsForFeatureList(flagsWithoutForcedDecision, user, projectConfig, allOptions, decisionPath);
for (int i = 0; i < flagsWithoutForcedDecision.size(); i++) {
DecisionResponse decision = decisionList.get(i);
+ boolean error = decision.isError();
+ List reasons = decision.getReasons().toReport();
+ String experimentKey = null;
+ if (decision.getResult() != null && decision.getResult().experiment != null) {
+ experimentKey = decision.getResult().experiment.getKey();
+ }
String flagKey = flagsWithoutForcedDecision.get(i).getKey();
+
+ if (error) {
+ OptimizelyDecision optimizelyDecision = OptimizelyDecision.newErrorDecision(flagKey, user, reasons);
+ decisionMap.put(flagKey, optimizelyDecision);
+ if (validKeys.contains(flagKey)) {
+ validKeys.remove(flagKey);
+ }
+ }
+
flagDecisions.put(flagKey, decision.getResult());
decisionReasonsMap.get(flagKey).merge(decision.getReasons());
}
- for (String key: validKeys) {
+ for (String key : validKeys) {
FeatureDecision flagDecision = flagDecisions.get(key);
DecisionReasons decisionReasons = decisionReasonsMap.get((key));
@@ -1367,13 +1531,14 @@ private Map decideForKeys(@Nonnull OptimizelyUserCon
return decisionMap;
}
- Map decideAll(@Nonnull OptimizelyUserContext user,
- @Nonnull List options) {
+ private Map decideAllInternal(@Nonnull OptimizelyUserContext user,
+ @Nonnull List options,
+ @Nonnull DecisionPath decisionPath) {
Map decisionMap = new HashMap<>();
ProjectConfig projectConfig = getProjectConfig();
if (projectConfig == null) {
- logger.error("Optimizely instance is not valid, failing isFeatureEnabled call.");
+ logger.error("Optimizely instance is not valid, failing decideAllSync call.");
return decisionMap;
}
@@ -1381,7 +1546,70 @@ Map decideAll(@Nonnull OptimizelyUserContext user,
List allFlagKeys = new ArrayList<>();
for (int i = 0; i < allFlags.size(); i++) allFlagKeys.add(allFlags.get(i).getKey());
- return decideForKeys(user, allFlagKeys, options);
+ return decideForKeysInternal(user, allFlagKeys, options, false, decisionPath);
+ }
+
+ private OptimizelyDecision decideInternal(@Nonnull OptimizelyUserContext user,
+ @Nonnull String key,
+ @Nonnull List options,
+ @Nonnull DecisionPath decisionPath) {
+ ProjectConfig projectConfig = getProjectConfig();
+ if (projectConfig == null) {
+ return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.SDK_NOT_READY.reason());
+ }
+
+ List allOptions = getAllOptions(options);
+ allOptions.remove(OptimizelyDecideOption.ENABLED_FLAGS_ONLY);
+
+ return decideForKeysInternal(user, Arrays.asList(key), allOptions, true, decisionPath).get(key);
+ }
+
+ //============ decide async ============//
+
+ /**
+ * Returns a decision result asynchronously for a given flag key and a user context.
+ *
+ * @param userContext The user context to make decisions for
+ * @param key A flag key for which a decision will be made
+ * @param callback A callback to invoke when the decision is available
+ * @param options A list of options for decision-making
+ */
+ void decideAsync(@Nonnull OptimizelyUserContext userContext,
+ @Nonnull String key,
+ @Nonnull List options,
+ @Nonnull OptimizelyDecisionCallback callback) {
+ AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(userContext, key, options, callback);
+ fetcher.start();
+ }
+
+ /**
+ * Returns decision results asynchronously for multiple flag keys.
+ *
+ * @param userContext The user context to make decisions for
+ * @param keys A list of flag keys for which decisions will be made
+ * @param callback A callback to invoke when decisions are available
+ * @param options A list of options for decision-making
+ */
+ void decideForKeysAsync(@Nonnull OptimizelyUserContext userContext,
+ @Nonnull List keys,
+ @Nonnull List options,
+ @Nonnull OptimizelyDecisionsCallback callback) {
+ AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(userContext, keys, options, callback);
+ fetcher.start();
+ }
+
+ /**
+ * Returns decision results asynchronously for all active flag keys.
+ *
+ * @param userContext The user context to make decisions for
+ * @param callback A callback to invoke when decisions are available
+ * @param options A list of options for decision-making
+ */
+ void decideAllAsync(@Nonnull OptimizelyUserContext userContext,
+ @Nonnull List options,
+ @Nonnull OptimizelyDecisionsCallback callback) {
+ AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(userContext, options, callback);
+ fetcher.start();
}
private List getAllOptions(List options) {
@@ -1484,9 +1712,9 @@ public int addLogEventNotificationHandler(NotificationHandler handler)
/**
* Convenience method for adding NotificationHandlers
*
- * @param clazz The class of NotificationHandler
+ * @param clazz The class of NotificationHandler
* @param handler NotificationHandler handler
- * @param This is the type parameter
+ * @param This is the type parameter
* @return A handler Id (greater than 0 if succeeded)
*/
public int addNotificationHandler(Class clazz, NotificationHandler handler) {
@@ -1535,10 +1763,10 @@ public ODPManager getODPManager() {
/**
* Send an event to the ODP server.
*
- * @param type the event type (default = "fullstack").
- * @param action the event action name.
+ * @param type the event type (default = "fullstack").
+ * @param action the event action name.
* @param identifiers a dictionary for identifiers. The caller must provide at least one key-value pair unless non-empty common identifiers have been set already with {@link ODPManager.Builder#withUserCommonIdentifiers(Map) }.
- * @param data a dictionary for associated data. The default event data will be added to this data before sending to the ODP server.
+ * @param data a dictionary for associated data. The default event data will be added to this data before sending to the ODP server.
*/
public void sendODPEvent(@Nullable String type, @Nonnull String action, @Nullable Map identifiers, @Nullable Map data) {
ProjectConfig projectConfig = getProjectConfig();
@@ -1567,7 +1795,13 @@ public void identifyUser(@Nonnull String userId) {
}
ODPManager odpManager = getODPManager();
if (odpManager != null) {
- odpManager.getEventManager().identifyUser(userId);
+ Map identifiers = new HashMap<>();
+ if (ODPManager.isVuid(userId)) {
+ identifiers.put(ODPUserKey.VUID.getKeyString(), userId);
+ } else {
+ identifiers.put(ODPUserKey.FS_USER_ID.getKeyString(), userId);
+ }
+ odpManager.getEventManager().identifyUser(identifiers);
}
}
@@ -1586,7 +1820,7 @@ private void updateODPSettings() {
* {@link Builder#withDatafile(java.lang.String)} and
* {@link Builder#withEventHandler(com.optimizely.ab.event.EventHandler)}
* respectively.
- *
+ *
* Example:
*
* Optimizely optimizely = Optimizely.builder()
@@ -1595,7 +1829,7 @@ private void updateODPSettings() {
* .build();
*
*
- * @param datafile A datafile
+ * @param datafile A datafile
* @param eventHandler An EventHandler
* @return An Optimizely builder
*/
@@ -1633,6 +1867,7 @@ public static class Builder {
private NotificationCenter notificationCenter;
private List defaultDecideOptions;
private ODPManager odpManager;
+ private CmabService cmabService;
// For backwards compatibility
private AtomicProjectConfigManager fallbackConfigManager = new AtomicProjectConfigManager();
@@ -1644,7 +1879,8 @@ public Builder(@Nonnull String datafile,
this.datafile = datafile;
}
- public Builder() { }
+ public Builder() {
+ }
public Builder withErrorHandler(ErrorHandler errorHandler) {
this.errorHandler = errorHandler;
@@ -1686,7 +1922,7 @@ public Builder withUserProfileService(UserProfileService userProfileService) {
* Override the SDK name and version (for client SDKs like android-sdk wrapping the core java-sdk) to be included in events.
*
* @param clientEngineName the client engine name ("java-sdk", "android-sdk", "flutter-sdk", etc.).
- * @param clientVersion the client SDK version.
+ * @param clientVersion the client SDK version.
* @return An Optimizely builder
*/
public Builder withClientInfo(String clientEngineName, String clientVersion) {
@@ -1743,6 +1979,11 @@ public Builder withODPManager(ODPManager odpManager) {
return this;
}
+ public Builder withCmabService(CmabService cmabService) {
+ this.cmabService = cmabService;
+ return this;
+ }
+
// Helper functions for making testing easier
protected Builder withBucketing(Bucketer bucketer) {
this.bucketer = bucketer;
@@ -1773,8 +2014,12 @@ public Optimizely build() {
bucketer = new Bucketer();
}
+ if (cmabService == null) {
+ logger.warn("CMAB service is not initiated. CMAB functionality will not be available.");
+ }
+
if (decisionService == null) {
- decisionService = new DecisionService(bucketer, errorHandler, userProfileService);
+ decisionService = new DecisionService(bucketer, errorHandler, userProfileService, cmabService);
}
if (projectConfig == null && datafile != null && !datafile.isEmpty()) {
@@ -1817,7 +2062,7 @@ public Optimizely build() {
defaultDecideOptions = Collections.emptyList();
}
- return new Optimizely(eventHandler, eventProcessor, errorHandler, decisionService, userProfileService, projectConfigManager, optimizelyConfigManager, notificationCenter, defaultDecideOptions, odpManager);
+ return new Optimizely(eventHandler, eventProcessor, errorHandler, decisionService, userProfileService, projectConfigManager, optimizelyConfigManager, notificationCenter, defaultDecideOptions, odpManager, cmabService);
}
}
}
diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java
index e2c03b147..19c8b999f 100644
--- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java
+++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java
@@ -16,18 +16,26 @@
*/
package com.optimizely.ab;
-import com.optimizely.ab.config.ProjectConfig;
-import com.optimizely.ab.odp.ODPManager;
-import com.optimizely.ab.odp.ODPSegmentCallback;
-import com.optimizely.ab.odp.ODPSegmentOption;
-import com.optimizely.ab.optimizelydecision.*;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
-import java.util.*;
-import java.util.concurrent.ConcurrentHashMap;
+
+import com.optimizely.ab.annotations.VisibleForTesting;
+import com.optimizely.ab.optimizelydecision.OptimizelyDecisionCallback;
+import com.optimizely.ab.optimizelydecision.OptimizelyDecisionsCallback;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.optimizely.ab.odp.ODPSegmentCallback;
+import com.optimizely.ab.odp.ODPSegmentOption;
+import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption;
+import com.optimizely.ab.optimizelydecision.OptimizelyDecision;
public class OptimizelyUserContext {
// OptimizelyForcedDecisionsKey mapped to variationKeys
@@ -42,7 +50,7 @@ public class OptimizelyUserContext {
private List qualifiedSegments;
@Nonnull
- private final Optimizely optimizely;
+ final Optimizely optimizely;
private static final Logger logger = LoggerFactory.getLogger(OptimizelyUserContext.class);
@@ -390,4 +398,44 @@ public String toString() {
", attributes='" + attributes + '\'' +
'}';
}
+
+ // sync decision support for android-sdk backward compatibility only
+
+ @VisibleForTesting // protected, open for testing only
+ public OptimizelyDecision decideSync(@Nonnull String key,
+ @Nonnull List options) {
+ return optimizely.decideSync(copy(), key, options);
+ }
+
+ @VisibleForTesting // protected, open for testing only
+ public Map decideForKeysSync(@Nonnull List keys,
+ @Nonnull List options) {
+ return optimizely.decideForKeysSync(copy(), keys, options);
+ }
+
+ @VisibleForTesting // protected, open for testing only
+ public Map decideAllSync(@Nonnull List options) {
+ return optimizely.decideAllSync(copy(), options);
+ }
+
+ @VisibleForTesting // protected, open for testing only
+ public void decideAsync(@Nonnull String key,
+ @Nonnull List options,
+ @Nonnull OptimizelyDecisionCallback callback) {
+ optimizely.decideAsync(copy(), key, options, callback);
+ }
+
+ @VisibleForTesting // protected, open for testing only
+ public void decideForKeysAsync(@Nonnull List keys,
+ @Nonnull List options,
+ @Nonnull OptimizelyDecisionsCallback callback) {
+ optimizely.decideForKeysAsync(copy(), keys, options, callback);
+ }
+
+ @VisibleForTesting // protected, open for testing only
+ public void decideAllAsync(@Nonnull List options,
+ @Nonnull OptimizelyDecisionsCallback callback) {
+ optimizely.decideAllAsync(copy(), options, callback);
+ }
+
}
diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java
index b92d2cf15..be37b4b7b 100644
--- a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java
+++ b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java
@@ -16,25 +16,33 @@
*/
package com.optimizely.ab.bucketing;
+import java.util.Collections;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.concurrent.Immutable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import com.optimizely.ab.annotations.VisibleForTesting;
import com.optimizely.ab.bucketing.internal.MurmurHash3;
-import com.optimizely.ab.config.*;
+import com.optimizely.ab.config.Experiment;
+import com.optimizely.ab.config.ExperimentCore;
+import com.optimizely.ab.config.Group;
+import com.optimizely.ab.config.ProjectConfig;
+import com.optimizely.ab.config.TrafficAllocation;
+import com.optimizely.ab.config.Variation;
import com.optimizely.ab.optimizelydecision.DecisionReasons;
import com.optimizely.ab.optimizelydecision.DecisionResponse;
import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import javax.annotation.Nonnull;
-import javax.annotation.concurrent.Immutable;
-import java.util.List;
/**
* Default Optimizely bucketing algorithm that evenly distributes users using the Murmur3 hash of some provided
* identifier.
*
* The user identifier must be provided in the first data argument passed to
- * {@link #bucket(Experiment, String, ProjectConfig)} and must be non-null and non-empty.
+ * {@link #bucket(ExperimentCore, String, ProjectConfig)} and must be non-null and non-empty.
*
* @see MurmurHash
*/
@@ -89,8 +97,9 @@ private Experiment bucketToExperiment(@Nonnull Group group,
}
@Nonnull
- private DecisionResponse bucketToVariation(@Nonnull Experiment experiment,
- @Nonnull String bucketingId) {
+ private DecisionResponse bucketToVariation(@Nonnull ExperimentCore experiment,
+ @Nonnull String bucketingId,
+ @Nonnull DecisionPath decisionPath) {
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
// "salt" the bucket id using the experiment id
@@ -104,8 +113,25 @@ private DecisionResponse bucketToVariation(@Nonnull Experiment experi
int bucketValue = generateBucketValue(hashCode);
logger.debug("Assigned bucket {} to user with bucketingId \"{}\" when bucketing to a variation.", bucketValue, bucketingId);
+ // Only apply CMAB traffic allocation logic if decision path is WITH_CMAB
+ if (decisionPath == DecisionPath.WITH_CMAB && experiment instanceof Experiment && ((Experiment) experiment).getCmab() != null) {
+ // For CMAB experiments, the original trafficAllocation is kept empty for backward compatibility.
+ // Use the traffic allocation defined in the CMAB block for bucketing instead.
+ String message = reasons.addInfo("Using CMAB traffic allocation for experiment \"%s\"", experimentKey);
+ logger.info(message);
+ trafficAllocations = Collections.singletonList(
+ new TrafficAllocation("$", ((Experiment) experiment).getCmab().getTrafficAllocation())
+ );
+ }
+
String bucketedVariationId = bucketToEntity(bucketValue, trafficAllocations);
- if (bucketedVariationId != null) {
+ if (decisionPath == DecisionPath.WITH_CMAB && "$".equals(bucketedVariationId)) {
+ // for cmab experiments
+ String message = reasons.addInfo("User with bucketingId \"%s\" is bucketed into CMAB for experiment \"%s\"", bucketingId, experimentKey);
+ logger.info(message);
+ return new DecisionResponse(new Variation("$", "$"), reasons);
+ }
+ else if (bucketedVariationId != null) {
Variation bucketedVariation = experiment.getVariationIdToVariationMap().get(bucketedVariationId);
String variationKey = bucketedVariation.getKey();
String message = reasons.addInfo("User with bucketingId \"%s\" is in variation \"%s\" of experiment \"%s\".", bucketingId, variationKey,
@@ -127,12 +153,14 @@ private DecisionResponse bucketToVariation(@Nonnull Experiment experi
* @param experiment The Experiment in which the user is to be bucketed.
* @param bucketingId string A customer-assigned value used to create the key for the murmur hash.
* @param projectConfig The current projectConfig
+ * @param decisionPath enum for decision making logic
* @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons
*/
@Nonnull
- public DecisionResponse bucket(@Nonnull Experiment experiment,
+ public DecisionResponse bucket(@Nonnull ExperimentCore experiment,
@Nonnull String bucketingId,
- @Nonnull ProjectConfig projectConfig) {
+ @Nonnull ProjectConfig projectConfig,
+ @Nonnull DecisionPath decisionPath) {
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
// ---------- Bucket User ----------
@@ -147,8 +175,6 @@ public DecisionResponse bucket(@Nonnull Experiment experiment,
String message = reasons.addInfo("User with bucketingId \"%s\" is not in any experiment of group %s.", bucketingId, experimentGroup.getId());
logger.info(message);
return new DecisionResponse(null, reasons);
- } else {
-
}
// if the experiment a user is bucketed in within a group isn't the same as the experiment provided,
// don't perform further bucketing within the experiment
@@ -165,11 +191,26 @@ public DecisionResponse bucket(@Nonnull Experiment experiment,
}
}
- DecisionResponse decisionResponse = bucketToVariation(experiment, bucketingId);
+ DecisionResponse decisionResponse = bucketToVariation(experiment, bucketingId, decisionPath);
reasons.merge(decisionResponse.getReasons());
return new DecisionResponse<>(decisionResponse.getResult(), reasons);
}
+ /**
+ * Assign a {@link Variation} of an {@link Experiment} to a user based on hashed value from murmurhash3.
+ *
+ * @param experiment The Experiment in which the user is to be bucketed.
+ * @param bucketingId string A customer-assigned value used to create the key for the murmur hash.
+ * @param projectConfig The current projectConfig
+ * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons
+ */
+ @Nonnull
+ public DecisionResponse bucket(@Nonnull ExperimentCore experiment,
+ @Nonnull String bucketingId,
+ @Nonnull ProjectConfig projectConfig) {
+ return bucket(experiment, bucketingId, projectConfig, DecisionPath.WITHOUT_CMAB);
+ }
+
//======== Helper methods ========//
/**
diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionPath.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionPath.java
new file mode 100644
index 000000000..42c80579d
--- /dev/null
+++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionPath.java
@@ -0,0 +1,21 @@
+/****************************************************************************
+ * Copyright 2025 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. *
+ ***************************************************************************/
+package com.optimizely.ab.bucketing;
+
+public enum DecisionPath {
+ WITH_CMAB, // Use CMAB logic
+ WITHOUT_CMAB // Skip CMAB logic (traditional A/B testing)
+}
diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java
index ff48ffb99..149fc1438 100644
--- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java
+++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java
@@ -15,27 +15,42 @@
***************************************************************************/
package com.optimizely.ab.bucketing;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.optimizely.ab.cmab.service.CmabDecision;
+import com.optimizely.ab.cmab.service.CmabService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import com.optimizely.ab.OptimizelyDecisionContext;
import com.optimizely.ab.OptimizelyForcedDecision;
import com.optimizely.ab.OptimizelyRuntimeException;
import com.optimizely.ab.OptimizelyUserContext;
-import com.optimizely.ab.config.*;
+import com.optimizely.ab.config.Experiment;
+import com.optimizely.ab.config.ExperimentCore;
+import com.optimizely.ab.config.FeatureFlag;
+import com.optimizely.ab.config.Holdout;
+import com.optimizely.ab.config.ProjectConfig;
+import com.optimizely.ab.config.Rollout;
+import com.optimizely.ab.config.Variation;
import com.optimizely.ab.error.ErrorHandler;
import com.optimizely.ab.internal.ControlAttribute;
import com.optimizely.ab.internal.ExperimentUtils;
+import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.EXPERIMENT;
+import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.RULE;
import com.optimizely.ab.optimizelydecision.DecisionReasons;
import com.optimizely.ab.optimizelydecision.DecisionResponse;
import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons;
import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-import java.util.*;
-import java.util.concurrent.ConcurrentHashMap;
-
-import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.EXPERIMENT;
-import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.RULE;
/**
* Optimizely's decision service that determines which variation of an experiment the user will be allocated to.
@@ -46,12 +61,14 @@
* 3. Checking sticky bucketing
* 4. Checking audience targeting
* 5. Using Murmurhash3 to bucket the user.
+ * 6. Handling CMAB (Contextual Multi-Armed Bandit) experiments for dynamic variation selection
*/
public class DecisionService {
private final Bucketer bucketer;
private final ErrorHandler errorHandler;
private final UserProfileService userProfileService;
+ private final CmabService cmabService;
private static final Logger logger = LoggerFactory.getLogger(DecisionService.class);
/**
@@ -62,7 +79,6 @@ public class DecisionService {
*/
private transient ConcurrentHashMap> forcedVariationMapping = new ConcurrentHashMap>();
-
/**
* Initialize a decision service for the Optimizely client.
*
@@ -73,9 +89,25 @@ public class DecisionService {
public DecisionService(@Nonnull Bucketer bucketer,
@Nonnull ErrorHandler errorHandler,
@Nullable UserProfileService userProfileService) {
+ this(bucketer, errorHandler, userProfileService, null);
+ }
+
+ /**
+ * Initialize a decision service for the Optimizely client.
+ *
+ * @param bucketer Base bucketer to allocate new users to an experiment.
+ * @param errorHandler The error handler of the Optimizely client.
+ * @param userProfileService UserProfileService implementation for storing user info.
+ * @param cmabService Cmab Service for decision making.
+ */
+ public DecisionService(@Nonnull Bucketer bucketer,
+ @Nonnull ErrorHandler errorHandler,
+ @Nullable UserProfileService userProfileService,
+ @Nullable CmabService cmabService) {
this.bucketer = bucketer;
this.errorHandler = errorHandler;
this.userProfileService = userProfileService;
+ this.cmabService = cmabService;
}
/**
@@ -87,6 +119,7 @@ public DecisionService(@Nonnull Bucketer bucketer,
* @param options An array of decision options
* @param userProfileTracker tracker for reading and updating user profile of the user
* @param reasons Decision reasons
+ * @param decisionPath An enum of paths for decision-making logic
* @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons
*/
@Nonnull
@@ -95,7 +128,8 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment,
@Nonnull ProjectConfig projectConfig,
@Nonnull List options,
@Nullable UserProfileTracker userProfileTracker,
- @Nullable DecisionReasons reasons) {
+ @Nullable DecisionReasons reasons,
+ @Nonnull DecisionPath decisionPath) {
if (reasons == null) {
reasons = DefaultDecisionReasons.newInstance();
}
@@ -131,25 +165,52 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment,
return new DecisionResponse(variation, reasons);
}
}
+ boolean ignoreUPS = false; // whether to ignore user profile service for cmab experiments
DecisionResponse decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, user, EXPERIMENT, experiment.getKey());
reasons.merge(decisionMeetAudience.getReasons());
if (decisionMeetAudience.getResult()) {
String bucketingId = getBucketingId(user.getUserId(), user.getAttributes());
-
- decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig);
+ String cmabUuid = null;
+ decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig, decisionPath);
reasons.merge(decisionVariation.getReasons());
- variation = decisionVariation.getResult();
+ if (decisionPath == DecisionPath.WITH_CMAB && isCmabExperiment(experiment) && decisionVariation.getResult() != null) {
+ // group-allocation and traffic-allocation checking passed for cmab
+ // we need server decision overruling local bucketing for cmab
+ DecisionResponse cmabDecision = getDecisionForCmabExperiment(projectConfig, experiment, user, bucketingId, options);
+ reasons.merge(cmabDecision.getReasons());
+
+ if (cmabDecision.isError()) {
+ return new DecisionResponse<>(null, reasons, true, null);
+ }
+
+ // Skip UPS for CMAB experiments as decisions are dynamic and not stored for sticky bucketing
+ ignoreUPS = true;
+ logger.debug(
+ "Skipping user profile service for CMAB experiment \"{}\". CMAB decisions are dynamic and not stored for sticky bucketing.",
+ experiment.getKey()
+ );
+
+ CmabDecision cmabResult = cmabDecision.getResult();
+ if (cmabResult != null) {
+ String variationId = cmabResult.getVariationId();
+ cmabUuid = cmabResult.getCmabUuid();
+ variation = experiment.getVariationIdToVariationMap().get(variationId);
+ }
+ } else {
+ // Standard bucketing for non-CMAB experiments
+ variation = decisionVariation.getResult();
+ }
if (variation != null) {
- if (userProfileTracker != null) {
+ if (userProfileTracker != null && !ignoreUPS) {
userProfileTracker.updateUserProfile(experiment, variation);
} else {
logger.debug("This decision will not be saved since the UserProfileService is null.");
}
}
- return new DecisionResponse(variation, reasons);
+ return new DecisionResponse<>(variation, reasons, false, cmabUuid);
}
String message = reasons.addInfo("User \"%s\" does not meet conditions to be in experiment \"%s\".", user.getUserId(), experiment.getKey());
@@ -164,13 +225,15 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment,
* @param user The current OptimizelyUserContext
* @param projectConfig The current projectConfig
* @param options An array of decision options
+ * @param decisionPath An enum of paths for decision-making logic
* @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons
*/
@Nonnull
public DecisionResponse getVariation(@Nonnull Experiment experiment,
@Nonnull OptimizelyUserContext user,
@Nonnull ProjectConfig projectConfig,
- @Nonnull List options) {
+ @Nonnull List options,
+ @Nonnull DecisionPath decisionPath) {
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
// fetch the user profile map from the user profile service
@@ -182,7 +245,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment,
userProfileTracker.loadUserProfile(reasons, errorHandler);
}
- DecisionResponse response = getVariation(experiment, user, projectConfig, options, userProfileTracker, reasons);
+ DecisionResponse response = getVariation(experiment, user, projectConfig, options, userProfileTracker, reasons, decisionPath);
if(userProfileService != null && !ignoreUPS) {
userProfileTracker.saveUserProfile(errorHandler);
@@ -194,7 +257,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment,
public DecisionResponse getVariation(@Nonnull Experiment experiment,
@Nonnull OptimizelyUserContext user,
@Nonnull ProjectConfig projectConfig) {
- return getVariation(experiment, user, projectConfig, Collections.emptyList());
+ return getVariation(experiment, user, projectConfig, Collections.emptyList(), DecisionPath.WITH_CMAB);
}
/**
@@ -228,6 +291,25 @@ public List> getVariationsForFeatureList(@Non
@Nonnull OptimizelyUserContext user,
@Nonnull ProjectConfig projectConfig,
@Nonnull List options) {
+ return getVariationsForFeatureList(featureFlags, user, projectConfig, options, DecisionPath.WITH_CMAB);
+ }
+
+ /**
+ * Get the variations the user is bucketed into for the list of feature flags
+ *
+ * @param featureFlags The feature flag list the user wants to access.
+ * @param user The current OptimizelyuserContext
+ * @param projectConfig The current projectConfig
+ * @param options An array of decision options
+ * @param decisionPath An enum of paths for decision-making logic
+ * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons
+ */
+ @Nonnull
+ public List> getVariationsForFeatureList(@Nonnull List featureFlags,
+ @Nonnull OptimizelyUserContext user,
+ @Nonnull ProjectConfig projectConfig,
+ @Nonnull List options,
+ @Nonnull DecisionPath decisionPath) {
DecisionReasons upsReasons = DefaultDecisionReasons.newInstance();
boolean ignoreUPS = options.contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE);
@@ -240,16 +322,31 @@ public List> getVariationsForFeatureList(@Non
List> decisions = new ArrayList<>();
- for (FeatureFlag featureFlag: featureFlags) {
+ flagLoop: for (FeatureFlag featureFlag: featureFlags) {
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
reasons.merge(upsReasons);
- DecisionResponse decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker);
+ // Evaluate global holdouts at flag level (before any rules are iterated)
+ List globalHoldouts = projectConfig.getGlobalHoldouts();
+ if (!globalHoldouts.isEmpty()) {
+ for (Holdout holdout : globalHoldouts) {
+ DecisionResponse holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
+ reasons.merge(holdoutDecision.getReasons());
+ if (holdoutDecision.getResult() != null) {
+ decisions.add(new DecisionResponse<>(new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT), reasons));
+ continue flagLoop;
+ }
+ }
+ }
+
+ DecisionResponse decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker, decisionPath);
reasons.merge(decisionVariationResponse.getReasons());
FeatureDecision decision = decisionVariationResponse.getResult();
+ boolean error = decisionVariationResponse.isError();
+
if (decision != null) {
- decisions.add(new DecisionResponse(decision, reasons));
+ decisions.add(new DecisionResponse(decision, reasons, error, decision.cmabUuid));
continue;
}
@@ -297,25 +394,25 @@ DecisionResponse getVariationFromExperiment(@Nonnull ProjectCon
@Nonnull FeatureFlag featureFlag,
@Nonnull OptimizelyUserContext user,
@Nonnull List options,
- @Nullable UserProfileTracker userProfileTracker) {
+ @Nullable UserProfileTracker userProfileTracker,
+ @Nonnull DecisionPath decisionPath) {
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
+ // Cache flagKey once to avoid multiple getKey() calls (important for mock-based tests)
+ String flagKey = featureFlag.getKey();
if (!featureFlag.getExperimentIds().isEmpty()) {
for (String experimentId : featureFlag.getExperimentIds()) {
Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId);
- DecisionResponse decisionVariation =
- getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options, userProfileTracker);
+ DecisionResponse decisionVariation =
+ getVariationFromExperimentRule(projectConfig, flagKey, experiment, user, options, userProfileTracker, decisionPath);
reasons.merge(decisionVariation.getReasons());
- Variation variation = decisionVariation.getResult();
-
- if (variation != null) {
- return new DecisionResponse(
- new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST),
- reasons);
+ FeatureDecision featureDecision = decisionVariation.getResult();
+ if (decisionVariation.isError() || (featureDecision != null && featureDecision.variation != null)) {
+ return new DecisionResponse(featureDecision, reasons, decisionVariation.isError(), decisionVariation.getCmabUuid());
}
}
} else {
- String message = reasons.addInfo("The feature flag \"%s\" is not used in any experiments.", featureFlag.getKey());
+ String message = reasons.addInfo("The feature flag \"%s\" is not used in any experiments.", flagKey);
logger.info(message);
}
@@ -362,6 +459,7 @@ DecisionResponse getVariationForFeatureInRollout(@Nonnull Featu
int index = 0;
while (index < rolloutRulesLength) {
+ Experiment rolloutRule = rollout.getExperiments().get(index);
DecisionResponse decisionVariationResponse = getVariationFromDeliveryRule(
projectConfig,
@@ -372,12 +470,10 @@ DecisionResponse getVariationForFeatureInRollout(@Nonnull Featu
);
reasons.merge(decisionVariationResponse.getReasons());
- AbstractMap.SimpleEntry response = decisionVariationResponse.getResult();
- Variation variation = response.getKey();
+ AbstractMap.SimpleEntry response = decisionVariationResponse.getResult();
+ FeatureDecision featureDecision = response.getKey();
Boolean skipToEveryoneElse = response.getValue();
- if (variation != null) {
- Experiment rule = rollout.getExperiments().get(index);
- FeatureDecision featureDecision = new FeatureDecision(rule, variation, FeatureDecision.DecisionSource.ROLLOUT);
+ if (featureDecision != null) {
return new DecisionResponse(featureDecision, reasons);
}
@@ -419,6 +515,54 @@ DecisionResponse getWhitelistedVariation(@Nonnull Experiment experime
return new DecisionResponse(null, reasons);
}
+ /**
+ * Determines the variation for a holdout rule.
+ *
+ * @param holdout The holdout rule to evaluate.
+ * @param user The user context.
+ * @param projectConfig The current project configuration.
+ * @return A {@link DecisionResponse} with the variation (if any) and reasons.
+ */
+ @Nonnull
+ DecisionResponse getVariationForHoldout(@Nonnull Holdout holdout,
+ @Nonnull OptimizelyUserContext user,
+ @Nonnull ProjectConfig projectConfig) {
+ DecisionReasons reasons = DefaultDecisionReasons.newInstance();
+
+ if (!holdout.isActive()) {
+ String message = reasons.addInfo("Holdout (%s) is not running.", holdout.getKey());
+ logger.info(message);
+ return new DecisionResponse<>(null, reasons);
+ }
+
+ DecisionResponse decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, holdout, user, EXPERIMENT, holdout.getKey());
+ reasons.merge(decisionMeetAudience.getReasons());
+
+ if (decisionMeetAudience.getResult()) {
+ // User meets audience conditions for holdout
+ String audienceMatchMessage = reasons.addInfo("User (%s) meets audience conditions for holdout (%s).", user.getUserId(), holdout.getKey());
+ logger.info(audienceMatchMessage);
+
+ String bucketingId = getBucketingId(user.getUserId(), user.getAttributes());
+ DecisionResponse decisionVariation = bucketer.bucket(holdout, bucketingId, projectConfig);
+ reasons.merge(decisionVariation.getReasons());
+ Variation variation = decisionVariation.getResult();
+
+ if (variation != null) {
+ String message = reasons.addInfo("User (%s) is in variation (%s) of holdout (%s).", user.getUserId(), variation.getKey(), holdout.getKey());
+ logger.info(message);
+ } else {
+ String message = reasons.addInfo("User (%s) is in no holdout variation.", user.getUserId());
+ logger.info(message);
+ }
+ return new DecisionResponse<>(variation, reasons);
+ }
+
+ String message = reasons.addInfo("User (%s) does not meet conditions for holdout (%s).", user.getUserId(), holdout.getKey());
+ logger.info(message);
+ return new DecisionResponse<>(null, reasons);
+ }
+
// TODO: Logically, it makes sense to move this method to UserProfileTracker. But some tests are also calling this
// method, requiring us to refactor those tests as well. We'll look to refactor this later.
@@ -560,6 +704,23 @@ public DecisionResponse validatedForcedDecision(@Nonnull OptimizelyDe
return new DecisionResponse<>(null, reasons);
}
+ DecisionResponse evaluateLocalHoldouts(@Nonnull ExperimentCore rule,
+ @Nonnull ProjectConfig projectConfig,
+ @Nonnull OptimizelyUserContext user) {
+ DecisionReasons reasons = DefaultDecisionReasons.newInstance();
+ List localHoldouts = projectConfig.getHoldoutsForRule(rule.getId());
+ for (Holdout holdout : localHoldouts) {
+ DecisionResponse holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
+ reasons.merge(holdoutDecision.getReasons());
+ if (holdoutDecision.getResult() != null) {
+ return new DecisionResponse<>(
+ new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT),
+ reasons);
+ }
+ }
+ return new DecisionResponse<>(null, reasons);
+ }
+
public ConcurrentHashMap> getForcedVariationMapping() {
return forcedVariationMapping;
}
@@ -672,16 +833,17 @@ public DecisionResponse getForcedVariation(@Nonnull Experiment experi
}
- private DecisionResponse getVariationFromExperimentRule(@Nonnull ProjectConfig projectConfig,
+ private DecisionResponse getVariationFromExperimentRule(@Nonnull ProjectConfig projectConfig,
@Nonnull String flagKey,
@Nonnull Experiment rule,
@Nonnull OptimizelyUserContext user,
@Nonnull List options,
- @Nullable UserProfileTracker userProfileTracker) {
+ @Nullable UserProfileTracker userProfileTracker,
+ @Nonnull DecisionPath decisionPath) {
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
String ruleKey = rule != null ? rule.getKey() : null;
- // Check Forced-Decision
+ // Step 1: Check Forced-Decision
OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey);
DecisionResponse forcedDecisionResponse = validatedForcedDecision(optimizelyDecisionContext, projectConfig, user);
@@ -689,15 +851,29 @@ private DecisionResponse getVariationFromExperimentRule(@Nonnull Proj
Variation variation = forcedDecisionResponse.getResult();
if (variation != null) {
- return new DecisionResponse(variation, reasons);
+ return new DecisionResponse(
+ new FeatureDecision(rule, variation, FeatureDecision.DecisionSource.FEATURE_TEST),
+ reasons);
}
- //regular decision
- DecisionResponse decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null);
+
+ // Step 2: Check local holdouts
+ if (rule != null) {
+ DecisionResponse holdoutResponse = evaluateLocalHoldouts(rule, projectConfig, user);
+ reasons.merge(holdoutResponse.getReasons());
+ if (holdoutResponse.getResult() != null) {
+ return new DecisionResponse<>(holdoutResponse.getResult(), reasons);
+ }
+ }
+
+ // Step 3: Regular rule decision
+ DecisionResponse decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null, decisionPath);
reasons.merge(decisionResponse.getReasons());
variation = decisionResponse.getResult();
- return new DecisionResponse(variation, reasons);
+ return new DecisionResponse<>(
+ new FeatureDecision(rule, variation, FeatureDecision.DecisionSource.FEATURE_TEST, decisionResponse.getCmabUuid()),
+ reasons, decisionResponse.isError(), decisionResponse.getCmabUuid());
}
/**
@@ -717,8 +893,8 @@ private boolean validateUserId(String userId) {
* @param rules The experiments belonging to a rollout
* @param ruleIndex The index of the rule
* @param user The OptimizelyUserContext
- * @return Returns a DecisionResponse Object containing a AbstractMap.SimpleEntry
- * where the Variation is the result and the Boolean is the skipToEveryoneElse.
+ * @return Returns a DecisionResponse Object containing a AbstractMap.SimpleEntry
+ * where the FeatureDecision is the result and the Boolean is the skipToEveryoneElse.
*/
DecisionResponse getVariationFromDeliveryRule(@Nonnull ProjectConfig projectConfig,
@Nonnull String flagKey,
@@ -728,20 +904,30 @@ DecisionResponse getVariationFromDeliveryRule(@Nonnull
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
Boolean skipToEveryoneElse = false;
- AbstractMap.SimpleEntry variationToSkipToEveryoneElsePair;
- // Check forced-decisions first
+ AbstractMap.SimpleEntry resultPair;
Experiment rule = rules.get(ruleIndex);
+
+ // Step 1: Check Forced-Decision
OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, rule.getKey());
DecisionResponse forcedDecisionResponse = validatedForcedDecision(optimizelyDecisionContext, projectConfig, user);
reasons.merge(forcedDecisionResponse.getReasons());
Variation variation = forcedDecisionResponse.getResult();
if (variation != null) {
- variationToSkipToEveryoneElsePair = new AbstractMap.SimpleEntry<>(variation, false);
- return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons);
+ resultPair = new AbstractMap.SimpleEntry<>(
+ new FeatureDecision(rule, variation, FeatureDecision.DecisionSource.ROLLOUT), false);
+ return new DecisionResponse(resultPair, reasons);
}
- // Handle a regular decision
+ // Step 2: Check local holdouts
+ DecisionResponse holdoutResponse = evaluateLocalHoldouts(rule, projectConfig, user);
+ reasons.merge(holdoutResponse.getReasons());
+ if (holdoutResponse.getResult() != null) {
+ resultPair = new AbstractMap.SimpleEntry<>(holdoutResponse.getResult(), false);
+ return new DecisionResponse(resultPair, reasons);
+ }
+
+ // Step 3: Regular rule decision
String bucketingId = getBucketingId(user.getUserId(), user.getAttributes());
Boolean everyoneElse = (ruleIndex == rules.size() - 1);
String loggingKey = everyoneElse ? "Everyone Else" : String.valueOf(ruleIndex + 1);
@@ -783,8 +969,59 @@ DecisionResponse getVariationFromDeliveryRule(@Nonnull
reasons.addInfo(message);
logger.debug(message);
}
- variationToSkipToEveryoneElsePair = new AbstractMap.SimpleEntry<>(bucketedVariation, skipToEveryoneElse);
- return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons);
+ FeatureDecision featureDecision = bucketedVariation != null
+ ? new FeatureDecision(rule, bucketedVariation, FeatureDecision.DecisionSource.ROLLOUT)
+ : null;
+ resultPair = new AbstractMap.SimpleEntry<>(featureDecision, skipToEveryoneElse);
+ return new DecisionResponse(resultPair, reasons);
}
+ /**
+ * Retrieves a decision for a contextual multi-armed bandit (CMAB)
+ * experiment.
+ *
+ * @param projectConfig Instance of ProjectConfig.
+ * @param experiment The experiment object for which the decision is to be
+ * made.
+ * @param userContext The user context containing user id and attributes.
+ * @param bucketingId The bucketing ID to use for traffic allocation.
+ * @param options Optional list of decide options.
+ * @return A CmabDecisionResult containing error status, result, and
+ * reasons.
+ */
+ private DecisionResponse getDecisionForCmabExperiment(@Nonnull ProjectConfig projectConfig,
+ @Nonnull Experiment experiment,
+ @Nonnull OptimizelyUserContext userContext,
+ @Nonnull String bucketingId,
+ @Nonnull List options) {
+ DecisionReasons reasons = DefaultDecisionReasons.newInstance();
+
+ // User is in CMAB allocation, proceed to CMAB decision
+ try {
+ CmabDecision cmabDecision = cmabService.getDecision(projectConfig, userContext, experiment.getId(), options);
+ String message = String.format("Successfully fetched CMAB decision %s for experiment %s.", cmabDecision.toString(), experiment.getKey());
+ reasons.addInfo(message);
+ return new DecisionResponse<>(cmabDecision, reasons);
+ } catch (Exception e) {
+ String errorMessage = String.format("Failed to fetch CMAB data for experiment %s.", experiment.getKey());
+ reasons.addError(errorMessage);
+ logger.error("{} {}", errorMessage, e.getMessage());
+
+ return new DecisionResponse<>(null, reasons, true, null);
+ }
+ }
+
+ /**
+ * Checks whether an experiment is a contextual multi-armed bandit (CMAB)
+ * experiment.
+ *
+ * @param experiment The experiment to check
+ * @return true if the experiment is a CMAB experiment, false otherwise
+ */
+ private boolean isCmabExperiment(@Nonnull Experiment experiment) {
+ if (cmabService == null){
+ return false;
+ }
+ return experiment.getCmab() != null;
+ }
}
diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java b/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java
index b0f0a11ed..ed369ee69 100644
--- a/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java
+++ b/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java
@@ -15,17 +15,17 @@
***************************************************************************/
package com.optimizely.ab.bucketing;
-import com.optimizely.ab.config.Experiment;
-import com.optimizely.ab.config.Variation;
-
import javax.annotation.Nullable;
+import com.optimizely.ab.config.ExperimentCore;
+import com.optimizely.ab.config.Variation;
+
public class FeatureDecision {
/**
- * The {@link Experiment} the Feature is associated with.
+ * The {@link ExperimentCore} the Feature is associated with.
*/
@Nullable
- public Experiment experiment;
+ public ExperimentCore experiment;
/**
* The {@link Variation} the user was bucketed into.
@@ -39,9 +39,16 @@ public class FeatureDecision {
@Nullable
public DecisionSource decisionSource;
+ /**
+ * The CMAB UUID for Contextual Multi-Armed Bandit experiments.
+ */
+ @Nullable
+ public String cmabUuid;
+
public enum DecisionSource {
FEATURE_TEST("feature-test"),
- ROLLOUT("rollout");
+ ROLLOUT("rollout"),
+ HOLDOUT("holdout");
private final String key;
@@ -58,15 +65,32 @@ public String toString() {
/**
* Initialize a FeatureDecision object.
*
- * @param experiment The {@link Experiment} the Feature is associated with.
+ * @param experiment The {@link ExperimentCore} the Feature is associated with.
* @param variation The {@link Variation} the user was bucketed into.
* @param decisionSource The source of the variation.
*/
- public FeatureDecision(@Nullable Experiment experiment, @Nullable Variation variation,
+ public FeatureDecision(@Nullable ExperimentCore experiment, @Nullable Variation variation,
@Nullable DecisionSource decisionSource) {
this.experiment = experiment;
this.variation = variation;
this.decisionSource = decisionSource;
+ this.cmabUuid = null;
+ }
+
+ /**
+ * Initialize a FeatureDecision object with CMAB UUID.
+ *
+ * @param experiment The {@link ExperimentCore} the Feature is associated with.
+ * @param variation The {@link Variation} the user was bucketed into.
+ * @param decisionSource The source of the variation.
+ * @param cmabUuid The CMAB UUID for Contextual Multi-Armed Bandit experiments.
+ */
+ public FeatureDecision(@Nullable ExperimentCore experiment, @Nullable Variation variation,
+ @Nullable DecisionSource decisionSource, @Nullable String cmabUuid) {
+ this.experiment = experiment;
+ this.variation = variation;
+ this.decisionSource = decisionSource;
+ this.cmabUuid = cmabUuid;
}
@Override
@@ -78,13 +102,15 @@ public boolean equals(Object o) {
if (variation != null ? !variation.equals(that.variation) : that.variation != null)
return false;
- return decisionSource == that.decisionSource;
+ if (decisionSource != that.decisionSource) return false;
+ return cmabUuid != null ? cmabUuid.equals(that.cmabUuid) : that.cmabUuid == null;
}
@Override
public int hashCode() {
int result = variation != null ? variation.hashCode() : 0;
result = 31 * result + (decisionSource != null ? decisionSource.hashCode() : 0);
+ result = 31 * result + (cmabUuid != null ? cmabUuid.hashCode() : 0);
return result;
}
}
diff --git a/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClient.java b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClient.java
new file mode 100644
index 000000000..9e7272be4
--- /dev/null
+++ b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClient.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright 2025, Optimizely
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.
+ */
+package com.optimizely.ab.cmab.client;
+
+import java.util.Map;
+
+public interface CmabClient {
+ /**
+ * Fetches a decision from the CMAB prediction service.
+ *
+ * @param ruleId The rule/experiment ID
+ * @param userId The user ID
+ * @param attributes User attributes
+ * @param cmabUuid The CMAB UUID
+ * @return CompletableFuture containing the variation ID as a String
+ */
+ String fetchDecision(String ruleId, String userId, Map attributes, String cmabUuid);
+}
diff --git a/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientConfig.java b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientConfig.java
new file mode 100644
index 000000000..f8f9d1630
--- /dev/null
+++ b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientConfig.java
@@ -0,0 +1,77 @@
+/**
+ * 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.
+ */
+package com.optimizely.ab.cmab.client;
+
+import javax.annotation.Nullable;
+
+/**
+ * Configuration for CMAB client operations.
+ * Contains only retry configuration since HTTP client is handled separately.
+ */
+public class CmabClientConfig {
+ private final RetryConfig retryConfig;
+ private String cmabEndpoint = null;
+
+ public CmabClientConfig(@Nullable RetryConfig retryConfig) {
+ this.retryConfig = retryConfig;
+ }
+
+ public CmabClientConfig(@Nullable RetryConfig retryConfig, @Nullable String cmabEndpoint) {
+ this.retryConfig = retryConfig;
+ this.cmabEndpoint = cmabEndpoint;
+ }
+
+ @Nullable
+ public RetryConfig getRetryConfig() {
+ return retryConfig;
+ }
+
+ @Nullable
+ public String getCmabEndpoint() {
+ return cmabEndpoint;
+ }
+
+ public void setCmabEndpoint(@Nullable String cmabEndpoint){
+ this.cmabEndpoint = cmabEndpoint;
+ }
+
+ /**
+ * Creates a config with default retry settings.
+ *
+ * @return A default cmab client config
+ */
+ public static CmabClientConfig withDefaultRetry() {
+ return new CmabClientConfig(RetryConfig.defaultConfig());
+ }
+
+ /**
+ * Creates a config with no retry.
+ *
+ * @return A cmab client config with no retry
+ */
+ public static CmabClientConfig withNoRetry() {
+ return new CmabClientConfig(null);
+ }
+
+ /**
+ * Creates a config with custom cmab endpoint.
+ *
+ * @return A cmab client config with custom cmab endpoint
+ */
+ public static CmabClientConfig withCmabEndpoint(@Nullable String cmabEndpoint) {
+ return new CmabClientConfig(RetryConfig.defaultConfig(), cmabEndpoint);
+ }
+}
diff --git a/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientHelper.java b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientHelper.java
new file mode 100644
index 000000000..f534abb90
--- /dev/null
+++ b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientHelper.java
@@ -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.
+ */
+package com.optimizely.ab.cmab.client;
+
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class CmabClientHelper {
+ public static final String CMAB_PREDICTION_ENDPOINT = "https://prediction.cmab.optimizely.com/predict/%s";
+ public static final String CMAB_FETCH_FAILED = "CMAB decision fetch failed with status: %s";
+ public static final String INVALID_CMAB_FETCH_RESPONSE = "Invalid CMAB fetch response";
+ private static final Pattern VARIATION_ID_PATTERN = Pattern.compile("\"variation_id\"\\s*:\\s*\"?([^\"\\s,}]+)\"?");
+
+ public static String buildRequestJson(String userId, String ruleId, Map attributes, String cmabUuid) {
+ StringBuilder json = new StringBuilder();
+ json.append("{\"instances\":[{");
+ json.append("\"visitorId\":\"").append(escapeJson(userId)).append("\",");
+ json.append("\"experimentId\":\"").append(escapeJson(ruleId)).append("\",");
+ json.append("\"cmabUUID\":\"").append(escapeJson(cmabUuid)).append("\",");
+ json.append("\"attributes\":[");
+
+ boolean first = true;
+ for (Map.Entry entry : attributes.entrySet()) {
+ if (!first) {
+ json.append(",");
+ }
+ json.append("{\"id\":\"").append(escapeJson(entry.getKey())).append("\",");
+ json.append("\"value\":").append(formatJsonValue(entry.getValue())).append(",");
+ json.append("\"type\":\"custom_attribute\"}");
+ first = false;
+ }
+
+ json.append("]}]}");
+ return json.toString();
+ }
+
+ private static String escapeJson(String value) {
+ if (value == null) {
+ return "";
+ }
+ return value.replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t");
+ }
+
+ private static String formatJsonValue(Object value) {
+ if (value == null) {
+ return "null";
+ } else if (value instanceof String) {
+ return "\"" + escapeJson((String) value) + "\"";
+ } else if (value instanceof Number || value instanceof Boolean) {
+ return value.toString();
+ } else {
+ return "\"" + escapeJson(value.toString()) + "\"";
+ }
+ }
+
+ public static String parseVariationId(String jsonResponse) {
+ // Simple regex to extract variation_id from predictions[0].variation_id
+ Pattern pattern = Pattern.compile("\"predictions\"\\s*:\\s*\\[\\s*\\{[^}]*\"variation_id\"\\s*:\\s*\"?([^\"\\s,}]+)\"?");
+ Matcher matcher = pattern.matcher(jsonResponse);
+ if (matcher.find()) {
+ return matcher.group(1);
+ }
+ throw new CmabInvalidResponseException(INVALID_CMAB_FETCH_RESPONSE);
+ }
+
+ private static String parseVariationIdForValidation(String jsonResponse) {
+ Matcher matcher = VARIATION_ID_PATTERN.matcher(jsonResponse);
+ if (matcher.find()) {
+ return matcher.group(1);
+ }
+ return null;
+ }
+
+ public static boolean validateResponse(String responseBody) {
+ try {
+ return responseBody.contains("predictions") &&
+ responseBody.contains("variation_id") &&
+ parseVariationIdForValidation(responseBody) != null;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public static boolean isSuccessStatusCode(int statusCode) {
+ return statusCode >= 200 && statusCode < 300;
+ }
+}
diff --git a/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabFetchException.java b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabFetchException.java
new file mode 100644
index 000000000..d76576ea2
--- /dev/null
+++ b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabFetchException.java
@@ -0,0 +1,28 @@
+/**
+ * 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.
+ */
+package com.optimizely.ab.cmab.client;
+
+import com.optimizely.ab.OptimizelyRuntimeException;
+
+public class CmabFetchException extends OptimizelyRuntimeException {
+ public CmabFetchException(String message) {
+ super(message);
+ }
+
+ public CmabFetchException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabInvalidResponseException.java b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabInvalidResponseException.java
new file mode 100644
index 000000000..de5550995
--- /dev/null
+++ b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabInvalidResponseException.java
@@ -0,0 +1,27 @@
+/**
+ * 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.
+ */
+package com.optimizely.ab.cmab.client;
+
+import com.optimizely.ab.OptimizelyRuntimeException;
+
+public class CmabInvalidResponseException extends OptimizelyRuntimeException{
+ public CmabInvalidResponseException(String message) {
+ super(message);
+ }
+ public CmabInvalidResponseException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/core-api/src/main/java/com/optimizely/ab/cmab/client/RetryConfig.java b/core-api/src/main/java/com/optimizely/ab/cmab/client/RetryConfig.java
new file mode 100644
index 000000000..68ddbd151
--- /dev/null
+++ b/core-api/src/main/java/com/optimizely/ab/cmab/client/RetryConfig.java
@@ -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.
+ */
+package com.optimizely.ab.cmab.client;
+/**
+ * Configuration for retry behavior in CMAB client operations.
+ */
+public class RetryConfig {
+ private final int maxRetries;
+ private final long backoffBaseMs;
+ private final double backoffMultiplier;
+ private final int maxTimeoutMs;
+
+ /**
+ * Creates a RetryConfig with custom retry and backoff settings.
+ *
+ * @param maxRetries Maximum number of retry attempts
+ * @param backoffBaseMs Base delay in milliseconds for the first retry
+ * @param backoffMultiplier Multiplier for exponential backoff (e.g., 2.0 for doubling)
+ * @param maxTimeoutMs Maximum total timeout in milliseconds for all retry attempts
+ */
+ public RetryConfig(int maxRetries, long backoffBaseMs, double backoffMultiplier, int maxTimeoutMs) {
+ if (maxRetries < 0) {
+ throw new IllegalArgumentException("maxRetries cannot be negative");
+ }
+ if (backoffBaseMs < 0) {
+ throw new IllegalArgumentException("backoffBaseMs cannot be negative");
+ }
+ if (backoffMultiplier < 1.0) {
+ throw new IllegalArgumentException("backoffMultiplier must be >= 1.0");
+ }
+ if (maxTimeoutMs < 0) {
+ throw new IllegalArgumentException("maxTimeoutMs cannot be negative");
+ }
+
+ this.maxRetries = maxRetries;
+ this.backoffBaseMs = backoffBaseMs;
+ this.backoffMultiplier = backoffMultiplier;
+ this.maxTimeoutMs = maxTimeoutMs;
+ }
+
+ /**
+ * Creates a RetryConfig with default backoff settings and timeout (100 millisecond base, 2x multiplier, 10 second timeout).
+ *
+ * @param maxRetries Maximum number of retry attempts
+ */
+ public RetryConfig(int maxRetries) {
+ this(maxRetries, 100, 2.0, 10000);
+ }
+
+ /**
+ * Creates a default RetryConfig with 1 retry and exponential backoff.
+ *
+ * @return Retry config with default settings
+ */
+ public static RetryConfig defaultConfig() {
+ return new RetryConfig(1);
+ }
+
+ /**
+ * Creates a RetryConfig with no retries (single attempt only).
+ *
+ * @return Retry config with no retries
+ */
+ public static RetryConfig noRetry() {
+ return new RetryConfig(0, 0, 1.0, 0);
+ }
+
+ public int getMaxRetries() {
+ return maxRetries;
+ }
+
+ public long getBackoffBaseMs() {
+ return backoffBaseMs;
+ }
+
+ public double getBackoffMultiplier() {
+ return backoffMultiplier;
+ }
+
+ public int getMaxTimeoutMs() {
+ return maxTimeoutMs;
+ }
+
+ /**
+ * Calculates the delay for a specific retry attempt.
+ *
+ * @param attemptNumber The attempt number (0-based, so 0 = first retry)
+ * @return Delay in milliseconds
+ */
+ public long calculateDelay(int attemptNumber) {
+ if (attemptNumber < 0) {
+ return 0;
+ }
+ return (long) (backoffBaseMs * Math.pow(backoffMultiplier, attemptNumber));
+ }
+
+ @Override
+ public String toString() {
+ return String.format("RetryConfig{maxRetries=%d, backoffBaseMs=%d, backoffMultiplier=%.1f, maxTimeoutMs=%d}",
+ maxRetries, backoffBaseMs, backoffMultiplier, maxTimeoutMs);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (obj == null || getClass() != obj.getClass()) return false;
+
+ RetryConfig that = (RetryConfig) obj;
+ return maxRetries == that.maxRetries &&
+ backoffBaseMs == that.backoffBaseMs &&
+ maxTimeoutMs == that.maxTimeoutMs &&
+ Double.compare(that.backoffMultiplier, backoffMultiplier) == 0;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = maxRetries;
+ result = 31 * result + Long.hashCode(backoffBaseMs);
+ result = 31 * result + Double.hashCode(backoffMultiplier);
+ result = 31 * result + Integer.hashCode(maxTimeoutMs);
+ return result;
+ }
+}
diff --git a/core-api/src/main/java/com/optimizely/ab/cmab/service/CmabCacheValue.java b/core-api/src/main/java/com/optimizely/ab/cmab/service/CmabCacheValue.java
new file mode 100644
index 000000000..361118ab5
--- /dev/null
+++ b/core-api/src/main/java/com/optimizely/ab/cmab/service/CmabCacheValue.java
@@ -0,0 +1,66 @@
+/**
+ * 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.
+ */
+package com.optimizely.ab.cmab.service;
+
+import java.util.Objects;
+
+public class CmabCacheValue {
+ private final String attributesHash;
+ private final String variationId;
+ private final String cmabUuid;
+
+ public CmabCacheValue(String attributesHash, String variationId, String cmabUuid) {
+ this.attributesHash = attributesHash;
+ this.variationId = variationId;
+ this.cmabUuid = cmabUuid;
+ }
+
+ public String getAttributesHash() {
+ return attributesHash;
+ }
+
+ public String getVariationId() {
+ return variationId;
+ }
+
+ public String getCmabUuid() {
+ return cmabUuid;
+ }
+
+ @Override
+ public String toString() {
+ return "CmabCacheValue{" +
+ "attributesHash='" + attributesHash + '\'' +
+ ", variationId='" + variationId + '\'' +
+ ", cmabUuid='" + cmabUuid + '\'' +
+ '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ CmabCacheValue that = (CmabCacheValue) o;
+ return Objects.equals(attributesHash, that.attributesHash) &&
+ Objects.equals(variationId, that.variationId) &&
+ Objects.equals(cmabUuid, that.cmabUuid);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(attributesHash, variationId, cmabUuid);
+ }
+}
diff --git a/core-api/src/main/java/com/optimizely/ab/cmab/service/CmabDecision.java b/core-api/src/main/java/com/optimizely/ab/cmab/service/CmabDecision.java
new file mode 100644
index 000000000..1dbb44ed7
--- /dev/null
+++ b/core-api/src/main/java/com/optimizely/ab/cmab/service/CmabDecision.java
@@ -0,0 +1,58 @@
+/**
+ * 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.
+ */
+package com.optimizely.ab.cmab.service;
+
+import java.util.Objects;
+
+public class CmabDecision {
+ private final String variationId;
+ private final String cmabUuid;
+
+ public CmabDecision(String variationId, String cmabUuid) {
+ this.variationId = variationId;
+ this.cmabUuid = cmabUuid;
+ }
+
+ public String getVariationId() {
+ return variationId;
+ }
+
+ public String getCmabUuid() {
+ return cmabUuid;
+ }
+
+ @Override
+ public String toString() {
+ return "CmabDecision{" +
+ "variationId='" + variationId + '\'' +
+ ", cmabUuid='" + cmabUuid + '\'' +
+ '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ CmabDecision that = (CmabDecision) o;
+ return Objects.equals(variationId, that.variationId) &&
+ Objects.equals(cmabUuid, that.cmabUuid);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(variationId, cmabUuid);
+ }
+}
diff --git a/core-api/src/main/java/com/optimizely/ab/cmab/service/CmabService.java b/core-api/src/main/java/com/optimizely/ab/cmab/service/CmabService.java
new file mode 100644
index 000000000..7d4412f79
--- /dev/null
+++ b/core-api/src/main/java/com/optimizely/ab/cmab/service/CmabService.java
@@ -0,0 +1,39 @@
+/**
+ * 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.
+ */
+package com.optimizely.ab.cmab.service;
+
+import java.util.List;
+
+import com.optimizely.ab.OptimizelyUserContext;
+import com.optimizely.ab.config.ProjectConfig;
+import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption;
+
+public interface CmabService {
+ /**
+ * Get variation id for the user
+ * @param projectConfig the project configuration
+ * @param userContext the user context
+ * @param ruleId the rule identifier
+ * @param options list of decide options
+ * @return CompletableFuture containing the CMAB decision
+ */
+ CmabDecision getDecision(
+ ProjectConfig projectConfig,
+ OptimizelyUserContext userContext,
+ String ruleId,
+ List options
+ );
+}
diff --git a/core-api/src/main/java/com/optimizely/ab/cmab/service/DefaultCmabService.java b/core-api/src/main/java/com/optimizely/ab/cmab/service/DefaultCmabService.java
new file mode 100644
index 000000000..75835a9f8
--- /dev/null
+++ b/core-api/src/main/java/com/optimizely/ab/cmab/service/DefaultCmabService.java
@@ -0,0 +1,292 @@
+/**
+ * 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.
+ */
+package com.optimizely.ab.cmab.service;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.optimizely.ab.OptimizelyUserContext;
+import com.optimizely.ab.bucketing.internal.MurmurHash3;
+import com.optimizely.ab.cmab.client.CmabClient;
+import com.optimizely.ab.config.Attribute;
+import com.optimizely.ab.config.Experiment;
+import com.optimizely.ab.config.ProjectConfig;
+import com.optimizely.ab.event.internal.ClientEngineInfo;
+import com.optimizely.ab.internal.Cache;
+import com.optimizely.ab.internal.DefaultLRUCache;
+import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption;
+
+public class DefaultCmabService implements CmabService {
+ public static final int DEFAULT_CMAB_CACHE_SIZE = 10000;
+ public static final int DEFAULT_CMAB_CACHE_TIMEOUT_SECS = 30*60; // 30 minutes
+ private static final boolean IS_ANDROID = ClientEngineInfo.getClientEngineName().toLowerCase().contains("android");
+ private static final int NUM_LOCK_STRIPES = IS_ANDROID ? 100 : 1000;
+
+ private final Cache cmabCache;
+ private final CmabClient cmabClient;
+ private final Logger logger;
+ private final ReentrantLock[] locks;
+
+ public DefaultCmabService(CmabClient cmabClient, Cache cmabCache) {
+ this(cmabClient, cmabCache, null);
+ }
+
+ public DefaultCmabService(CmabClient cmabClient, Cache cmabCache, Logger logger) {
+ this.cmabCache = cmabCache;
+ this.cmabClient = cmabClient;
+ this.logger = logger != null ? logger : LoggerFactory.getLogger(DefaultCmabService.class);
+ this.locks = new ReentrantLock[NUM_LOCK_STRIPES];
+ for (int i = 0; i < NUM_LOCK_STRIPES; i++) {
+ this.locks[i] = new ReentrantLock();
+ }
+ }
+
+ @Override
+ public CmabDecision getDecision(ProjectConfig projectConfig, OptimizelyUserContext userContext, String ruleId, List options) {
+ options = options == null ? Collections.emptyList() : options;
+ String userId = userContext.getUserId();
+
+ int lockIndex = getLockIndex(userId, ruleId);
+ ReentrantLock lock = locks[lockIndex];
+ lock.lock();
+ try {
+ Map filteredAttributes = filterAttributes(projectConfig, userContext, ruleId);
+
+ if (options.contains(OptimizelyDecideOption.IGNORE_CMAB_CACHE)) {
+ logger.debug("Ignoring CMAB cache for user '{}' and rule '{}'", userId, ruleId);
+ return fetchDecision(ruleId, userId, filteredAttributes);
+ }
+
+ if (options.contains(OptimizelyDecideOption.RESET_CMAB_CACHE)) {
+ logger.debug("Resetting CMAB cache for user '{}' and rule '{}'", userId, ruleId);
+ cmabCache.reset();
+ }
+
+ String cacheKey = getCacheKey(userContext.getUserId(), ruleId);
+ if (options.contains(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE)) {
+ logger.debug("Invalidating CMAB cache for user '{}' and rule '{}'", userId, ruleId);
+ cmabCache.remove(cacheKey);
+ }
+
+ CmabCacheValue cachedValue = cmabCache.lookup(cacheKey);
+
+ String attributesHash = hashAttributes(filteredAttributes);
+
+ if (cachedValue != null) {
+ if (cachedValue.getAttributesHash().equals(attributesHash)) {
+ logger.debug("CMAB cache hit for user '{}' and rule '{}'", userId, ruleId);
+ return new CmabDecision(cachedValue.getVariationId(), cachedValue.getCmabUuid());
+ } else {
+ logger.debug("CMAB cache attributes mismatch for user '{}' and rule '{}', fetching new decision", userId, ruleId);
+ cmabCache.remove(cacheKey);
+ }
+ } else {
+ logger.debug("CMAB cache miss for user '{}' and rule '{}'", userId, ruleId);
+ }
+
+ CmabDecision cmabDecision = fetchDecision(ruleId, userId, filteredAttributes);
+ logger.debug("CMAB decision is {}", cmabDecision);
+
+ cmabCache.save(cacheKey, new CmabCacheValue(attributesHash, cmabDecision.getVariationId(), cmabDecision.getCmabUuid()));
+
+ return cmabDecision;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private CmabDecision fetchDecision(String ruleId, String userId, Map attributes) {
+ String cmabUuid = java.util.UUID.randomUUID().toString();
+ String variationId = cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid);
+ return new CmabDecision(variationId, cmabUuid);
+ }
+
+ private Map filterAttributes(ProjectConfig projectConfig, OptimizelyUserContext userContext, String ruleId) {
+ Map userAttributes = userContext.getAttributes();
+ Map filteredAttributes = new HashMap<>();
+
+ // Get experiment by rule ID
+ Experiment experiment = projectConfig.getExperimentIdMapping().get(ruleId);
+ if (experiment == null) {
+ logger.debug("Experiment not found for rule ID: {}", ruleId);
+ return filteredAttributes;
+ }
+
+ // Check if experiment has CMAB configuration
+ if (experiment.getCmab() == null) {
+ logger.debug("No CMAB configuration found for experiment: {}", ruleId);
+ return filteredAttributes;
+ }
+
+ List cmabAttributeIds = experiment.getCmab().getAttributeIds();
+ if (cmabAttributeIds == null || cmabAttributeIds.isEmpty()) {
+ return filteredAttributes;
+ }
+
+ Map attributeIdMapping = projectConfig.getAttributeIdMapping();
+ if (attributeIdMapping == null) {
+ logger.debug("No attribute mapping found in project config for rule ID: {}", ruleId);
+ return filteredAttributes;
+ }
+
+ // Filter attributes based on CMAB configuration
+ for (String attributeId : cmabAttributeIds) {
+ Attribute attribute = attributeIdMapping.get(attributeId);
+ if (attribute != null) {
+ if (userAttributes.containsKey(attribute.getKey())) {
+ filteredAttributes.put(attribute.getKey(), userAttributes.get(attribute.getKey()));
+ } else {
+ logger.debug("User attribute '{}' not found for attribute ID '{}'", attribute.getKey(), attributeId);
+ }
+ } else {
+ logger.debug("Attribute configuration not found for ID: {}", attributeId);
+ }
+ }
+
+ return filteredAttributes;
+ }
+
+ private String getCacheKey(String userId, String ruleId) {
+ return userId.length() + "-" + userId + "-" + ruleId;
+ }
+
+ private String hashAttributes(Map attributes) {
+ if (attributes == null || attributes.isEmpty()) {
+ return "empty";
+ }
+
+ // Sort attributes to ensure consistent hashing
+ TreeMap sortedAttributes = new TreeMap<>(attributes);
+
+ // Create a simple string representation
+ StringBuilder sb = new StringBuilder();
+ sb.append("{");
+ boolean first = true;
+ for (Map.Entry entry : sortedAttributes.entrySet()) {
+ if (entry.getKey() == null) continue; // Skip null keys
+
+ if (!first) {
+ sb.append(",");
+ }
+ sb.append("\"").append(entry.getKey()).append("\":");
+
+ Object value = entry.getValue();
+ if (value == null) {
+ sb.append("null");
+ } else if (value instanceof String) {
+ sb.append("\"").append(value).append("\"");
+ } else {
+ sb.append(value.toString());
+ }
+ first = false;
+ }
+ sb.append("}");
+
+ String attributesString = sb.toString();
+ int hash = MurmurHash3.murmurhash3_x86_32(attributesString, 0, attributesString.length(), 0);
+
+ // Convert to hex string to match your existing pattern
+ return Integer.toHexString(hash);
+ }
+
+ private int getLockIndex(String userId, String ruleId) {
+ // Create a hash of userId + ruleId for consistent lock selection
+ String combined = userId + ruleId;
+ int hash = MurmurHash3.murmurhash3_x86_32(combined, 0, combined.length(), 0);
+ return Math.abs(hash) % NUM_LOCK_STRIPES;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private int cmabCacheSize = DEFAULT_CMAB_CACHE_SIZE;
+ private int cmabCacheTimeoutInSecs = DEFAULT_CMAB_CACHE_TIMEOUT_SECS;
+ private Cache customCache;
+ private CmabClient client;
+
+ /**
+ * Set the maximum size of the CMAB cache.
+ *
+ * Default value is 10000 entries.
+ *
+ * @param cacheSize The maximum number of entries to store in the cache
+ * @return Builder instance
+ */
+ public Builder withCmabCacheSize(int cacheSize) {
+ this.cmabCacheSize = cacheSize;
+ return this;
+ }
+
+ /**
+ * Set the timeout duration for cached CMAB decisions.
+ *
+ * Default value is 30 * 60 seconds (30 minutes).
+ *
+ * @param timeoutInSecs The timeout in seconds before cached entries expire
+ * @return Builder instance
+ */
+ public Builder withCmabCacheTimeoutInSecs(int timeoutInSecs) {
+ this.cmabCacheTimeoutInSecs = timeoutInSecs;
+ return this;
+ }
+
+ /**
+ * Provide a custom {@link CmabClient} instance which makes HTTP calls to fetch CMAB decisions.
+ *
+ * A Default CmabClient implementation is required for CMAB functionality.
+ *
+ * @param client The implementation of {@link CmabClient}
+ * @return Builder instance
+ */
+ public Builder withClient(CmabClient client) {
+ this.client = client;
+ return this;
+ }
+
+ /**
+ * Provide a custom {@link Cache} instance for caching CMAB decisions.
+ *
+ * If provided, this will override the cache size and timeout settings.
+ *
+ * @param cache The custom cache instance implementing {@link Cache}
+ * @return Builder instance
+ */
+ public Builder withCustomCache(Cache cache) {
+ this.customCache = cache;
+ return this;
+ }
+
+ public DefaultCmabService build() {
+ if (client == null) {
+ throw new IllegalStateException("CmabClient is required");
+ }
+
+ Cache cache = customCache != null ? customCache :
+ new DefaultLRUCache<>(cmabCacheSize, cmabCacheTimeoutInSecs);
+
+ return new DefaultCmabService(client, cache);
+ }
+ }
+}
diff --git a/core-api/src/main/java/com/optimizely/ab/config/Cmab.java b/core-api/src/main/java/com/optimizely/ab/config/Cmab.java
new file mode 100644
index 000000000..738864e58
--- /dev/null
+++ b/core-api/src/main/java/com/optimizely/ab/config/Cmab.java
@@ -0,0 +1,72 @@
+/**
+ *
+ * Copyright 2025 Optimizely and contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.
+ */
+package com.optimizely.ab.config;
+
+import java.util.List;
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+/**
+ * Represents the Optimizely Traffic Allocation configuration.
+ *
+ * @see Project JSON
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class Cmab {
+
+ private final List attributeIds;
+ private final int trafficAllocation;
+
+ @JsonCreator
+ public Cmab(@JsonProperty("attributeIds") List attributeIds,
+ @JsonProperty("trafficAllocation") int trafficAllocation) {
+ this.attributeIds = attributeIds;
+ this.trafficAllocation = trafficAllocation;
+ }
+
+ public List getAttributeIds() {
+ return attributeIds;
+ }
+
+ public int getTrafficAllocation() {
+ return trafficAllocation;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (obj == null || getClass() != obj.getClass()) return false;
+ Cmab cmab = (Cmab) obj;
+ return trafficAllocation == cmab.trafficAllocation &&
+ Objects.equals(attributeIds, cmab.attributeIds);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(attributeIds, trafficAllocation);
+ }
+
+ @Override
+ public String toString() {
+ return "Cmab{" +
+ "attributeIds=" + attributeIds +
+ ", trafficAllocation=" + trafficAllocation +
+ '}';
+ }
+}
\ No newline at end of file
diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java
index 28ad519a5..28ac62789 100644
--- a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java
+++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java
@@ -63,6 +63,7 @@ public class DatafileProjectConfig implements ProjectConfig {
private final boolean anonymizeIP;
private final boolean sendFlagDecisions;
private final Boolean botFiltering;
+ private final String region;
private final String hostForODP;
private final String publicKeyForODP;
private final List attributes;
@@ -91,10 +92,13 @@ public class DatafileProjectConfig implements ProjectConfig {
private final Map groupIdMapping;
private final Map rolloutIdMapping;
private final Map> experimentFeatureKeyMapping;
+ private final Map attributeIdMapping;
// other mappings
private final Map variationIdToExperimentMapping;
+ private final HoldoutConfig holdoutConfig;
+
private String datafile;
// v2 constructor
@@ -113,6 +117,7 @@ public DatafileProjectConfig(String accountId, String projectId, String version,
anonymizeIP,
false,
null,
+ null,
projectId,
revision,
null,
@@ -124,6 +129,7 @@ public DatafileProjectConfig(String accountId, String projectId, String version,
eventType,
experiments,
null,
+ null,
groups,
null,
null
@@ -135,6 +141,7 @@ public DatafileProjectConfig(String accountId,
boolean anonymizeIP,
boolean sendFlagDecisions,
Boolean botFiltering,
+ String region,
String projectId,
String revision,
String sdkKey,
@@ -145,6 +152,7 @@ public DatafileProjectConfig(String accountId,
List typedAudiences,
List events,
List experiments,
+ List holdouts,
List featureFlags,
List groups,
List rollouts,
@@ -158,6 +166,7 @@ public DatafileProjectConfig(String accountId,
this.anonymizeIP = anonymizeIP;
this.sendFlagDecisions = sendFlagDecisions;
this.botFiltering = botFiltering;
+ this.region = region != null ? region : "US";
this.attributes = Collections.unmodifiableList(attributes);
this.audiences = Collections.unmodifiableList(audiences);
@@ -185,8 +194,18 @@ public DatafileProjectConfig(String accountId,
List allExperiments = new ArrayList();
allExperiments.addAll(experiments);
allExperiments.addAll(aggregateGroupExperiments(groups));
+
+ // Inject "everyone else" variation into feature_rollout experiments
+ allExperiments = injectFeatureRolloutVariations(allExperiments, this.featureFlags, this.rollouts);
+
this.experiments = Collections.unmodifiableList(allExperiments);
+ if (holdouts == null) {
+ this.holdoutConfig = new HoldoutConfig();
+ } else {
+ this.holdoutConfig = new HoldoutConfig(holdouts);
+ }
+
String publicKeyForODP = "";
String hostForODP = "";
if (integrations == null) {
@@ -239,6 +258,7 @@ public DatafileProjectConfig(String accountId,
this.experimentIdMapping = ProjectConfigUtils.generateIdMapping(this.experiments);
this.groupIdMapping = ProjectConfigUtils.generateIdMapping(groups);
this.rolloutIdMapping = ProjectConfigUtils.generateIdMapping(this.rollouts);
+ this.attributeIdMapping = ProjectConfigUtils.generateIdMapping(this.attributes);
// Generate experiment to featureFlag list mapping to identify if experiment is AB-Test experiment or Feature-Test Experiment.
this.experimentFeatureKeyMapping = ProjectConfigUtils.generateExperimentFeatureMapping(this.featureFlags);
@@ -341,6 +361,112 @@ public Experiment getExperimentForVariationId(String variationId) {
return this.variationIdToExperimentMapping.get(variationId);
}
+ /**
+ * Injects the "everyone else" variation from the flag's rollout into any experiment
+ * with type "feature_rollout". This enables Feature Rollout experiments to fall back
+ * to the everyone else variation when users are outside the rollout percentage.
+ */
+ private List injectFeatureRolloutVariations(
+ List allExperiments,
+ List featureFlags,
+ List rollouts
+ ) {
+ if (featureFlags == null || featureFlags.isEmpty()) {
+ return allExperiments;
+ }
+
+ // Build rollout ID to Rollout mapping.
+ // [NOTE] we cannot use the rolloutIdMapping here because it is built after we
+ // inject the variations, which causes a circular dependency.
+ Map rolloutMap = new HashMap<>();
+ if (rollouts != null) {
+ for (Rollout rollout : rollouts) {
+ rolloutMap.put(rollout.getId(), rollout);
+ }
+ }
+
+ // Build experiment ID to index mapping for quick lookup
+ Map experimentIndexMap = new HashMap<>();
+ for (int i = 0; i < allExperiments.size(); i++) {
+ experimentIndexMap.put(allExperiments.get(i).getId(), i);
+ }
+
+ List updatedExperiments = new ArrayList<>(allExperiments);
+
+ for (FeatureFlag flag : featureFlags) {
+ Variation everyoneElseVariation = getEveryoneElseVariation(flag, rolloutMap);
+ if (everyoneElseVariation == null) {
+ continue;
+ }
+
+ for (String experimentId : flag.getExperimentIds()) {
+ Integer index = experimentIndexMap.get(experimentId);
+ if (index == null) {
+ continue;
+ }
+ Experiment experiment = updatedExperiments.get(index);
+ if (!Experiment.TYPE_FR.equals(experiment.getType())) {
+ continue;
+ }
+
+ // Create new experiment with injected variation and traffic allocation
+ List newVariations = new ArrayList<>(experiment.getVariations());
+ newVariations.add(everyoneElseVariation);
+
+ List newTrafficAllocation = new ArrayList<>(experiment.getTrafficAllocation());
+ newTrafficAllocation.add(new TrafficAllocation(everyoneElseVariation.getId(), 10000));
+
+ Experiment updatedExperiment = new Experiment(
+ experiment.getId(),
+ experiment.getKey(),
+ experiment.getStatus(),
+ experiment.getLayerId(),
+ experiment.getAudienceIds(),
+ experiment.getAudienceConditions(),
+ newVariations,
+ experiment.getUserIdToVariationKeyMap(),
+ newTrafficAllocation,
+ experiment.getGroupId(),
+ experiment.getCmab(),
+ experiment.getType()
+ );
+
+ updatedExperiments.set(index, updatedExperiment);
+ }
+ }
+
+ return updatedExperiments;
+ }
+
+ /**
+ * Gets the "everyone else" variation from the flag's rollout.
+ * The everyone else rule is the last experiment in the rollout,
+ * and the variation is the first variation of that rule.
+ *
+ * @return the everyone else variation, or null if it cannot be resolved
+ */
+ @Nullable
+ private Variation getEveryoneElseVariation(FeatureFlag flag, Map rolloutMap) {
+ String rolloutId = flag.getRolloutId();
+ if (rolloutId == null || rolloutId.isEmpty()) {
+ return null;
+ }
+ Rollout rollout = rolloutMap.get(rolloutId);
+ if (rollout == null) {
+ return null;
+ }
+ List rolloutExperiments = rollout.getExperiments();
+ if (rolloutExperiments == null || rolloutExperiments.isEmpty()) {
+ return null;
+ }
+ Experiment everyoneElseRule = rolloutExperiments.get(rolloutExperiments.size() - 1);
+ List variations = everyoneElseRule.getVariations();
+ if (variations == null || variations.isEmpty()) {
+ return null;
+ }
+ return variations.get(0);
+ }
+
private List aggregateGroupExperiments(List groups) {
List groupExperiments = new ArrayList();
for (Group group : groups) {
@@ -424,6 +550,11 @@ public Boolean getBotFiltering() {
return botFiltering;
}
+ @Override
+ public String getRegion() {
+ return region;
+ }
+
@Override
public List getGroups() {
return groups;
@@ -434,6 +565,31 @@ public List getExperiments() {
return experiments;
}
+ @Override
+ public List getHoldouts() {
+ return holdoutConfig.getAllHoldouts();
+ }
+
+ @Override
+ public List getHoldoutForFlag(@Nonnull String id) {
+ return holdoutConfig.getHoldoutForFlag(id);
+ }
+
+ @Override
+ public List getGlobalHoldouts() {
+ return holdoutConfig.getGlobalHoldouts();
+ }
+
+ @Override
+ public List getHoldoutsForRule(@Nonnull String ruleId) {
+ return holdoutConfig.getHoldoutsForRule(ruleId);
+ }
+
+ @Override
+ public Holdout getHoldout(@Nonnull String id) {
+ return holdoutConfig.getHoldout(id);
+ }
+
@Override
public Set getAllSegments() {
return this.allSegments;
@@ -505,6 +661,11 @@ public Map getAttributeKeyMapping() {
return attributeKeyMapping;
}
+ @Override
+ public Map getAttributeIdMapping() {
+ return this.attributeIdMapping;
+ }
+
@Override
public Map getEventNameMapping() {
return eventNameMapping;
@@ -587,6 +748,7 @@ public String toString() {
", version='" + version + '\'' +
", anonymizeIP=" + anonymizeIP +
", botFiltering=" + botFiltering +
+ ", region=" + region +
", attributes=" + attributes +
", audiences=" + audiences +
", typedAudiences=" + typedAudiences +
diff --git a/core-api/src/main/java/com/optimizely/ab/config/Experiment.java b/core-api/src/main/java/com/optimizely/ab/config/Experiment.java
index 11530735c..51d4fbb18 100644
--- a/core-api/src/main/java/com/optimizely/ab/config/Experiment.java
+++ b/core-api/src/main/java/com/optimizely/ab/config/Experiment.java
@@ -34,17 +34,15 @@
*/
@Immutable
@JsonIgnoreProperties(ignoreUnknown = true)
-public class Experiment implements IdKeyMapped {
+public class Experiment implements ExperimentCore {
private final String id;
private final String key;
private final String status;
private final String layerId;
private final String groupId;
-
- private final String AND = "AND";
- private final String OR = "OR";
- private final String NOT = "NOT";
+ private final String type;
+ private final Cmab cmab;
private final List audienceIds;
private final Condition audienceConditions;
@@ -55,6 +53,12 @@ public class Experiment implements IdKeyMapped {
private final Map variationIdToVariationMap;
private final Map userIdToVariationKeyMap;
+ public static final String TYPE_AB = "ab";
+ public static final String TYPE_MAB = "mab";
+ public static final String TYPE_CMAB = "cmab";
+ public static final String TYPE_TD = "td";
+ public static final String TYPE_FR = "fr";
+
public enum ExperimentStatus {
RUNNING("Running"),
LAUNCHED("Launched"),
@@ -75,7 +79,45 @@ public String toString() {
@VisibleForTesting
public Experiment(String id, String key, String layerId) {
- this(id, key, null, layerId, Collections.emptyList(), null, Collections.emptyList(), Collections.emptyMap(), Collections.emptyList(), "");
+ this(id, key, null, layerId, Collections.emptyList(), null, Collections.emptyList(), Collections.emptyMap(), Collections.emptyList(), "", null, null);
+ }
+
+ @VisibleForTesting
+ public Experiment(String id, String key, String status, String layerId,
+ List audienceIds, Condition audienceConditions,
+ List variations, Map userIdToVariationKeyMap,
+ List trafficAllocation, String groupId) {
+ this(id, key, status, layerId, audienceIds, audienceConditions, variations,
+ userIdToVariationKeyMap, trafficAllocation, groupId, null, null); // Default cmab=null, type=null
+ }
+
+ @VisibleForTesting
+ public Experiment(String id, String key, String status, String layerId,
+ List audienceIds, Condition audienceConditions,
+ List variations, Map userIdToVariationKeyMap,
+ List trafficAllocation) {
+ this(id, key, status, layerId, audienceIds, audienceConditions, variations,
+ userIdToVariationKeyMap, trafficAllocation, "", null, null); // Default groupId="", cmab=null, type=null
+ }
+
+ @VisibleForTesting
+ public Experiment(String id, String key, String status, String layerId,
+ List audienceIds, Condition audienceConditions,
+ List variations, Map userIdToVariationKeyMap,
+ List trafficAllocation,
+ Cmab cmab) {
+ this(id, key, status, layerId, audienceIds, audienceConditions, variations,
+ userIdToVariationKeyMap, trafficAllocation, "", cmab, null); // Default groupId="" and type=null
+ }
+
+ @VisibleForTesting
+ public Experiment(String id, String key, String status, String layerId,
+ List audienceIds, Condition audienceConditions,
+ List variations, Map userIdToVariationKeyMap,
+ List trafficAllocation, String groupId,
+ Cmab cmab) {
+ this(id, key, status, layerId, audienceIds, audienceConditions, variations,
+ userIdToVariationKeyMap, trafficAllocation, groupId, cmab, null); // Default type=null
}
@JsonCreator
@@ -87,8 +129,10 @@ public Experiment(@JsonProperty("id") String id,
@JsonProperty("audienceConditions") Condition audienceConditions,
@JsonProperty("variations") List variations,
@JsonProperty("forcedVariations") Map userIdToVariationKeyMap,
- @JsonProperty("trafficAllocation") List trafficAllocation) {
- this(id, key, status, layerId, audienceIds, audienceConditions, variations, userIdToVariationKeyMap, trafficAllocation, "");
+ @JsonProperty("trafficAllocation") List trafficAllocation,
+ @JsonProperty("cmab") Cmab cmab,
+ @JsonProperty("type") String type) {
+ this(id, key, status, layerId, audienceIds, audienceConditions, variations, userIdToVariationKeyMap, trafficAllocation, "", cmab, type);
}
public Experiment(@Nonnull String id,
@@ -100,7 +144,9 @@ public Experiment(@Nonnull String id,
@Nonnull List variations,
@Nonnull Map userIdToVariationKeyMap,
@Nonnull List trafficAllocation,
- @Nonnull String groupId) {
+ @Nonnull String groupId,
+ @Nullable Cmab cmab,
+ @Nullable String type) {
this.id = id;
this.key = key;
this.status = status == null ? ExperimentStatus.NOT_STARTED.toString() : status;
@@ -113,6 +159,8 @@ public Experiment(@Nonnull String id,
this.userIdToVariationKeyMap = userIdToVariationKeyMap;
this.variationKeyToVariationMap = ProjectConfigUtils.generateNameMapping(variations);
this.variationIdToVariationMap = ProjectConfigUtils.generateIdMapping(variations);
+ this.cmab = cmab;
+ this.type = type;
}
public String getId() {
@@ -163,6 +211,15 @@ public String getGroupId() {
return groupId;
}
+ @Nullable
+ public String getType() {
+ return type;
+ }
+
+ public Cmab getCmab() {
+ return cmab;
+ }
+
public boolean isActive() {
return status.equals(ExperimentStatus.RUNNING.toString()) ||
status.equals(ExperimentStatus.LAUNCHED.toString());
@@ -176,98 +233,6 @@ public boolean isLaunched() {
return status.equals(ExperimentStatus.LAUNCHED.toString());
}
- public String serializeConditions(Map audiencesMap) {
- Condition condition = this.audienceConditions;
- return condition instanceof EmptyCondition ? "" : this.serialize(condition, audiencesMap);
- }
-
- private String getNameFromAudienceId(String audienceId, Map audiencesMap) {
- StringBuilder audienceName = new StringBuilder();
- if (audiencesMap != null && audiencesMap.get(audienceId) != null) {
- audienceName.append("\"" + audiencesMap.get(audienceId) + "\"");
- } else {
- audienceName.append("\"" + audienceId + "\"");
- }
- return audienceName.toString();
- }
-
- private String getOperandOrAudienceId(Condition condition, Map audiencesMap) {
- if (condition != null) {
- if (condition instanceof AudienceIdCondition) {
- return this.getNameFromAudienceId(condition.getOperandOrId(), audiencesMap);
- } else {
- return condition.getOperandOrId();
- }
- } else {
- return "";
- }
- }
-
- public String serialize(Condition condition, Map audiencesMap) {
- StringBuilder stringBuilder = new StringBuilder();
- List conditions;
-
- String operand = this.getOperandOrAudienceId(condition, audiencesMap);
- switch (operand){
- case (AND):
- conditions = ((AndCondition>) condition).getConditions();
- stringBuilder.append(this.getNameOrNextCondition(operand, conditions, audiencesMap));
- break;
- case (OR):
- conditions = ((OrCondition>) condition).getConditions();
- stringBuilder.append(this.getNameOrNextCondition(operand, conditions, audiencesMap));
- break;
- case (NOT):
- stringBuilder.append(operand + " ");
- Condition notCondition = ((NotCondition>) condition).getCondition();
- if (notCondition instanceof AudienceIdCondition) {
- stringBuilder.append(serialize(notCondition, audiencesMap));
- } else {
- stringBuilder.append("(" + serialize(notCondition, audiencesMap) + ")");
- }
- break;
- default:
- stringBuilder.append(operand);
- break;
- }
-
- return stringBuilder.toString();
- }
-
- public String getNameOrNextCondition(String operand, List conditions, Map