diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml
new file mode 100644
index 000000000..9688c6465
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml
@@ -0,0 +1,88 @@
+name: 🐞 Bug
+description: File a bug/issue
+title: "[BUG]
"
+labels: ["bug", "needs-triage"]
+body:
+- type: checkboxes
+ attributes:
+ label: Is there an existing issue for this?
+ description: Please search to see if an issue already exists for the bug you encountered.
+ options:
+ - label: I have searched the existing issues
+ required: true
+- type: textarea
+ attributes:
+ label: SDK Version
+ description: Version of the SDK in use?
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: Current Behavior
+ description: A concise description of what you're experiencing.
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: Expected Behavior
+ description: A concise description of what you expected to happen.
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: Steps To Reproduce
+ description: Steps to reproduce the behavior.
+ placeholder: |
+ 1. In this environment...
+ 1. With this config...
+ 1. Run '...'
+ 1. See error...
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: Go Version
+ description: What version of Go are you using?
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: Link
+ description: Link to code demonstrating the problem.
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: Logs
+ description: Logs/stack traces related to the problem (⚠️do not include sensitive information).
+ validations:
+ required: false
+- type: dropdown
+ attributes:
+ label: Severity
+ description: What is the severity of the problem?
+ multiple: true
+ options:
+ - Blocking development
+ - Affecting users
+ - Minor issue
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: Workaround/Solution
+ description: Do you have any workaround or solution in mind for the problem?
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: "Recent Change"
+ description: Has this issue started happening after an update or experiment change?
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: Conflicts
+ description: Are there other libraries/dependencies potentially in conflict?
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml
new file mode 100644
index 000000000..5f7a3d714
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml
@@ -0,0 +1,45 @@
+name: ✨Enhancement
+description: Create a new ticket for a Enhancement/Tech-initiative for the benefit of the SDK which would be considered for a minor version update.
+title: "[ENHANCEMENT] "
+labels: ["enhancement"]
+body:
+ - type: textarea
+ id: description
+ attributes:
+ label: "Description"
+ description: Briefly describe the enhancement in a few sentences.
+ placeholder: Short description...
+ validations:
+ required: true
+ - type: textarea
+ id: benefits
+ attributes:
+ label: "Benefits"
+ description: How would the enhancement benefit to your product or usage?
+ placeholder: Benefits...
+ validations:
+ required: true
+ - type: textarea
+ id: detail
+ attributes:
+ label: "Detail"
+ description: How would you like the enhancement to work? Please provide as much detail as possible
+ placeholder: Detailed description...
+ validations:
+ required: false
+ - type: textarea
+ id: examples
+ attributes:
+ label: "Examples"
+ description: Are there any examples of this enhancement in other products/services? If so, please provide links or references.
+ placeholder: Links/References...
+ validations:
+ required: false
+ - type: textarea
+ id: risks
+ attributes:
+ label: "Risks/Downsides"
+ description: Do you think this enhancement could have any potential downsides or risks?
+ placeholder: Risks/Downsides...
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md
new file mode 100644
index 000000000..a061f3356
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md
@@ -0,0 +1,4 @@
+
+## Feedback requesting a new feature can be shared [here.](https://feedback.optimizely.com/)
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..17de7159f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: 💡Feature Requests
+ url: https://feedback.optimizely.com/
+ about: Feedback requesting a new feature can be shared here.
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 7fcde151c..bfcc8033e 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -19,7 +19,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
- go-version: '1.19'
+ go-version: '1.21.0'
- run: make install lint
unit_test_latest:
@@ -28,7 +28,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
- go-version: '>=1.20'
+ go-version: '>=1.21.0'
- run: make cover
unit_test_legacy:
@@ -37,7 +37,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
- go-version: '1.19'
+ go-version: '1.21.0'
check-latest: true
- run: make cover
@@ -49,7 +49,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
- go-version: '1.19.7'
+ go-version: '1.21.0'
- run: |
go test -race -covermode atomic -coverprofile=covprofile ./...
go install github.com/mattn/goveralls@latest
@@ -61,7 +61,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
- go-version: '>=1.20'
+ go-version: '>=1.21.0'
- run: make benchmark
integration_tests:
diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml
index a7dc068b2..6758fd2ab 100644
--- a/.github/workflows/integration_test.yml
+++ b/.github/workflows/integration_test.yml
@@ -24,18 +24,19 @@ jobs:
ref: 'master'
- name: set SDK Branch if PR
if: ${{ github.event_name == 'pull_request' }}
+ env:
+ HEAD_REF: ${{ github.head_ref }}
+ BASE_REF: ${{ github.base_ref }}
run: |
- echo "SDK_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV
- echo "TRAVIS_BRANCH=${{ github.base_ref }}" >> $GITHUB_ENV
- ## Need to remove
- echo ${{ github.ref_name }}
+ echo "SDK_BRANCH=$HEAD_REF" >> $GITHUB_ENV
+ echo "TRAVIS_BRANCH=$BASE_REF" >> $GITHUB_ENV
- name: set SDK Branch if not pull request
if: ${{ github.event_name != 'pull_request' }}
+ env:
+ REF_NAME: ${{ github.ref_name }}
run: |
- echo "SDK_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- echo "TRAVIS_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- ## Need to remove
- echo ${{ github.ref_name }}
+ echo "SDK_BRANCH=$REF_NAME" >> $GITHUB_ENV
+ echo "TRAVIS_BRANCH=$REF_NAME" >> $GITHUB_ENV
- name: Trigger build
env:
SDK: go
diff --git a/.github/workflows/source_clear_cron.yml b/.github/workflows/source_clear_cron.yml
index 4a9b2dcf4..26c814f28 100644
--- a/.github/workflows/source_clear_cron.yml
+++ b/.github/workflows/source_clear_cron.yml
@@ -3,6 +3,8 @@ name: Source clear
on:
push:
branches: [ master ]
+ pull_request:
+ branches: [ master ]
schedule:
# Runs "weekly"
- cron: '0 0 * * 0'
@@ -11,8 +13,16 @@ jobs:
source_clear:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - name: Checkout Code
+ uses: actions/checkout@v3
+ - name: Setup Go
+ uses: actions/setup-go@v3
+ with:
+ go-version: '1.21.0'
+ check-latest: true
- name: Source clear scan
env:
SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }}
- run: curl -sSL https://download.sourceclear.com/ci.sh | bash -s - scan
+ run: |
+ go mod tidy
+ curl -sSL https://download.sourceclear.com/ci.sh | bash -s - scan
diff --git a/CHANGELOG.MD b/CHANGELOG.MD
index b013d24fb..b6ed5f2b2 100644
--- a/CHANGELOG.MD
+++ b/CHANGELOG.MD
@@ -4,8 +4,71 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
-## [Unreleased]
-Changes that have landed but are not yet released.
+## [2.1.0] - November 8, 2024
+
+### Enhancements
+- **Batch Processing for User Profile Service (UPS)**: Added support for batch processing in `decideAll` and `decideForKeys`, enabling more efficient handling of multiple decisions in the User Profile Service. ([#394](https://github.com/optimizely/go-sdk/pull/394))
+
+- **Optimized Logging**: Reduced log verbosity in the Decision flow by shifting info-level logs to debug. This change will significantly reduce the volume of logs at the info level, improving readability and performance for applications with high decision-making activity. ([#390](https://github.com/optimizely/go-sdk/pull/390))
+
+## [2.0.0] - January 22th, 2024
+
+### New Features
+
+The 2.0.0 release introduces a new primary feature, [Advanced Audience Targeting]( https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) enabled through integration with [Optimizely Data Platform (ODP)](https://docs.developers.optimizely.com/optimizely-data-platform/docs) ([#350](https://github.com/optimizely/go-sdk/pull/350), [#353](https://github.com/optimizely/go-sdk/pull/353), [#354](https://github.com/optimizely/go-sdk/pull/354), [#355](https://github.com/optimizely/go-sdk/pull/355)).
+
+You can use ODP, a high-performance [Customer Data Platform (CDP)]( https://www.optimizely.com/optimization-glossary/customer-data-platform/), to easily create complex real-time segments (RTS) using first-party and 50+ third-party data sources out of the box. You can create custom schemas that support the user attributes important for your business, and stitch together user behavior done on different devices to better understand and target your customers for personalized user experiences. ODP can be used as a single source of truth for these segments in any Optimizely or 3rd party tool.
+
+With ODP accounts integrated into Optimizely projects, you can build audiences using segments pre-defined in ODP. The SDK will fetch the segments for given users and make decisions using the segments. For access to ODP audience targeting in your Feature Experimentation account, please contact your Optimizely Customer Success Manager.
+
+This version includes the following changes:
+
+* New API added to `OptimizelyUserContext`:
+
+ - `FetchQualifiedSegments()`: this API will retrieve user segments from the ODP server. The fetched segments will be used for audience evaluation. The fetched data will be stored in the local cache to avoid repeated network delays.
+
+ - When an `OptimizelyUserContext` is created, the SDK will automatically send an identify request to the ODP server to facilitate observing user activities.
+
+* New APIs added to `OptimizelyClient`:
+
+ - `SendOdpEvent()`: customers can build/send arbitrary ODP events that will bind user identifiers and data to user profiles in ODP.
+
+For details, refer to our documentation pages:
+
+* [Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting)
+
+* [Server SDK Support](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/advanced-audience-targeting-for-server-side-sdks)
+
+* [Initialize Go SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-go)
+
+* [OptimizelyUserContext Go SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizelyusercontext-go)
+
+* [Advanced Audience Targeting segment qualification methods](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/advanced-audience-targeting-segment-qualification-methods-go)
+
+* [Send Optimizely Data Platform data using Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/send-odp-data-using-advanced-audience-targeting-go)
+
+### Breaking Changes
+
+* ODPManager in the SDK is enabled by default. Unless an ODP account is integrated into the Optimizely projects, most ODPManager functions will be ignored. If needed, ODPManager can be disabled when `OptimizelyClient` is instantiated.
+* Minimum golang version changed from 1.13 to 1.21.0. ([#369](https://github.com/optimizely/go-sdk/pull/369), [#380](https://github.com/optimizely/go-sdk/pull/380))
+* Module name has been changed to `github.com/optimizely/go-sdk/v2`.
+
+### Bug Fixes
+* Logged correct error message in HTTP requests. ([#374](https://github.com/optimizely/go-sdk/pull/374))
+* Stopped all tickers to prevent goroutine leak. ([#375](https://github.com/optimizely/go-sdk/pull/375)).
+* Client is closed gracefully to dispatch queued events. ([#376](https://github.com/optimizely/go-sdk/pull/376))
+* Convert warning into error log for SyncConfig failure. ([#383](https://github.com/optimizely/go-sdk/pull/383))
+
+### Functionality Enhancement
+* Github issue template is updated. ([#379](https://github.com/optimizely/go-sdk/pull/379))
+* Helper function for passing notification center is added. ([#381](https://github.com/optimizely/go-sdk/pull/381))
+* OpenTelemetry tracing support is added. ([#385](https://github.com/optimizely/go-sdk/pull/385))
+ - New ClientOptionFunc `WithTracer()` is added to initiate `OptimizelyClient` with OpenTelemetry tracer.
+ - New method `WithTraceContext()` is added to `OptimizelyClient` to set the trace context before calling APIs.
+
+## [1.8.5] - October 5th, 2023
+
+* Fixed a bug in the HTTP Requester logging. Now correct error message is logged. ([#374](https://github.com/optimizely/go-sdk/pull/374))
## [2.0.0-beta] - April 27th, 2023
diff --git a/LICENSE b/LICENSE
index b9f80c5bd..f2f83230c 100644
--- a/LICENSE
+++ b/LICENSE
@@ -187,7 +187,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
- Copyright 2016-2017, Optimizely, Inc. and contributors
+ Copyright 2016 Optimizely, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/Makefile b/Makefile
index 12cde2f07..b3f33a48c 100644
--- a/Makefile
+++ b/Makefile
@@ -23,7 +23,7 @@ cover: ## run unit tests with coverage
GO111MODULE=$(GO111MODULE) $(GOTEST) -race ./pkg/... -coverprofile=profile.cov
install: ## installs dev and ci dependencies
- curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $(GOPATH)/bin v1.52.2
+ curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $(GOPATH)/bin v1.54.2
lint: ## runs `golangci-lint` linters defined in `.golangci.yml` file
$(GOLINT) run --out-format=tab --tests=false pkg/...
diff --git a/README.md b/README.md
index c696d62c7..e4d00c32c 100644
--- a/README.md
+++ b/README.md
@@ -16,20 +16,26 @@ Refer to the [Go SDK's developer documentation](https://docs.developers.optimize
### Requirements
-Requires Golang version 1.19 or higher.
+Requires Golang version:
+
+| SDK Version | Required Golang Version |
+|--------------|--------------------------|
+| v2.0.0 | 1.21.0 or higher |
+| < v2.0.0 | 1.13 or higher |
+
### Install the SDK
#### Install from github:
```$sh
-go get github.com/optimizely/go-sdk
+go get github.com/optimizely/go-sdk/v2
```
#### Install from source:
```$sh
-go get github.com/optimizely/go-sdk
-cd $GOPATH/src/github.com/optimizely/go-sdk
+go get github.com/optimizely/go-sdk/v2
+cd $GOPATH/src/github.com/optimizely/go-sdk/v2
go install
```
@@ -41,26 +47,26 @@ We practice trunk-based development, and as such our default branch, `master` mi
```
module mymodule
-go 1.19
+go 1.21.0
require (
- github.com/optimizely/go-sdk v2.0.0-beta
+ github.com/optimizely/go-sdk/v2 v2.0.0
)
```
If you are already using `go.mod` in your application you can run the following:
```
-go mod edit -require github.com/optimizely/go-sdk@v2.0.0-beta
+go mod edit -require github.com/optimizely/go-sdk/v2@v2.0.0
```
NOTE:
```$sh
-go get github.com/optimizely/go-sdk/...
+go get github.com/optimizely/go-sdk/v2/...
```
or
```$sh
-go get github.com/optimizely/go-sdk/pkg
+go get github.com/optimizely/go-sdk/v2/pkg
```
will install it as a package to pkg directory, rather than src directory. It could be useful for future development and vendoring.
@@ -72,8 +78,8 @@ See the example file in examples/main.go.
### Initialization
```
-import optly "github.com/optimizely/go-sdk"
-import "github.com/optimizely/go-sdk/client"
+import optly "github.com/optimizely/go-sdk/v2"
+import "github.com/optimizely/go-sdk/v2/client"
// Simple one-line initialization with the SDK key
optlyClient, err := optly.Client("SDK_KEY")
@@ -87,7 +93,7 @@ optlyClient, err = optimizelyFactory.Client()
### Make Decisions
```
import (
- optly "github.com/optimizely/go-sdk"
+ optly "github.com/optimizely/go-sdk/v2"
)
// instantiate a client
@@ -161,6 +167,10 @@ sync
Copyright (c) 2009 The Go Authors. All rights reserved.
https://github.com/golang/sync/blob/master/LICENSE
+opentelemetry-go
+Copyright The OpenTelemetry Authors
+License (Apache-2.0): https://github.com/open-telemetry/opentelemetry-go/blob/main/LICENSE
+
### Other Optimizely SDKs
- Agent - https://github.com/optimizely/agent
diff --git a/examples/benchmark/main.go b/examples/benchmark/main.go
index c27f1fa88..41785ef90 100644
--- a/examples/benchmark/main.go
+++ b/examples/benchmark/main.go
@@ -3,16 +3,15 @@
package main
import (
- "io/ioutil"
"log"
"os"
"path"
- "github.com/optimizely/go-sdk/pkg/client"
- "github.com/optimizely/go-sdk/pkg/decision"
- "github.com/optimizely/go-sdk/pkg/entities"
-
"github.com/pkg/profile"
+
+ "github.com/optimizely/go-sdk/v2/pkg/client"
+ "github.com/optimizely/go-sdk/v2/pkg/decision"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
func stressTest() {
@@ -23,7 +22,7 @@ func stressTest() {
var datafileDir = path.Join(os.Getenv("DATAFILES_DIR"), "100_entities.json")
- datafile, err := ioutil.ReadFile(datafileDir)
+ datafile, err := os.ReadFile(datafileDir)
if err != nil {
log.Print(err)
}
diff --git a/examples/main.go b/examples/main.go
index bf04724d6..516230b80 100644
--- a/examples/main.go
+++ b/examples/main.go
@@ -7,55 +7,59 @@ import (
"fmt"
"time"
- optimizely "github.com/optimizely/go-sdk"
- "github.com/optimizely/go-sdk/pkg/client"
- "github.com/optimizely/go-sdk/pkg/config"
- "github.com/optimizely/go-sdk/pkg/event"
- "github.com/optimizely/go-sdk/pkg/logging"
+ optimizely "github.com/optimizely/go-sdk/v2"
+ "github.com/optimizely/go-sdk/v2/pkg/client"
+ "github.com/optimizely/go-sdk/v2/pkg/event"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
func main() {
- sdkKey := "4SLpaJA1r1pgE6T2CoMs9q"
+ sdkKey := "RZKHh5HhUExLvpeieGZnD"
logging.SetLogLevel(logging.LogLevelDebug)
- user := optimizely.UserContext(
- "mike ng",
- map[string]interface{}{
- "country": "Unknown",
- "likes_donuts": true,
- },
- )
-
/************* Bad SDK Key ********************/
- optimizelyClient, err := optimizely.Client("some_key")
- enabled, err := optimizelyClient.IsFeatureEnabled("mutext_feat", user)
- if err == config.Err403Forbidden {
- fmt.Println("A Valid 403 error received:", config.Err403Forbidden)
+ if optimizelyClient, err := optimizely.Client("some_key"); err == nil {
+ userContext := optimizelyClient.CreateUserContext("user1", map[string]interface{}{
+ "country": "Unknown",
+ "likes_donuts": true,
+ })
+ decision := userContext.Decide("mutext_feat", nil)
+ fmt.Printf("Is feature enabled? %v\n", decision.Enabled)
+ if len(decision.Reasons[0]) > 0 {
+ fmt.Println("A Valid 403 error received:", decision.Reasons[0])
+ }
}
/************* Simple usage ********************/
- optimizelyClient, err = optimizely.Client(sdkKey)
- enabled, _ = optimizelyClient.IsFeatureEnabled("mutext_feat", user)
-
- fmt.Printf("Is feature enabled? %v\n", enabled)
+ if optimizelyClient, err := optimizely.Client(sdkKey); err == nil {
+ userContext := optimizelyClient.CreateUserContext("user1", map[string]interface{}{
+ "country": "US",
+ "likes_donuts": false,
+ })
+ decision := userContext.Decide("mutext_feat", nil)
+ fmt.Printf("Is feature enabled? %v\n", decision.Enabled)
+ }
- /************* StaticClient ********************/
+ // /************* StaticClient ********************/
optimizelyFactory := &client.OptimizelyFactory{
SDKKey: sdkKey,
}
- optimizelyClient, err = optimizelyFactory.StaticClient()
-
+ optimizelyClient, err := optimizelyFactory.StaticClient()
if err != nil {
fmt.Printf("Error instantiating client: %s", err)
return
}
- enabled, _ = optimizelyClient.IsFeatureEnabled("mutext_feat", user)
- fmt.Printf("Is feature enabled? %v\n", enabled)
+ userContext := optimizelyClient.CreateUserContext("user1", map[string]interface{}{
+ "country": "Unknown",
+ "likes_donuts": true,
+ })
+ decision := userContext.Decide("mutext_feat", nil)
+ fmt.Printf("Is feature enabled? %v\n", decision.Enabled)
fmt.Println()
optimizelyClient.Close() // user can close dispatcher
@@ -74,8 +78,13 @@ func main() {
return
}
- enabled, _ = optimizelyClient.IsFeatureEnabled("mutext_feat", user)
- fmt.Printf("Is feature enabled? %v\n", enabled)
+ userContext = optimizelyClient.CreateUserContext("user1", map[string]interface{}{
+ "country": "Unknown",
+ "likes_donuts": true,
+ })
+ decision = userContext.Decide("mutext_feat", nil)
+ fmt.Printf("Is feature enabled? %v\n", decision.Enabled)
+
optimizelyClient.Close() // user can close dispatcher
/************* Setting Polling Interval ********************/
diff --git a/go.mod b/go.mod
index a6796ca94..b6385c52a 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
-module github.com/optimizely/go-sdk
+module github.com/optimizely/go-sdk/v2
-go 1.19
+go 1.21.0
require (
github.com/google/uuid v1.3.0
@@ -8,22 +8,24 @@ require (
github.com/json-iterator/go v1.1.12
github.com/pkg/errors v0.9.1
github.com/pkg/profile v1.7.0
- github.com/stretchr/testify v1.8.2
+ github.com/stretchr/testify v1.8.4
github.com/twmb/murmur3 v1.1.6
+ go.opentelemetry.io/otel v1.21.0
+ go.opentelemetry.io/otel/trace v1.21.0
golang.org/x/sync v0.1.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/felixge/fgprof v0.9.3 // indirect
+ github.com/go-logr/logr v1.3.0 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
+ go.opentelemetry.io/otel/metric v1.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-
-// Work around issue with git.apache.org/thrift.git
-replace git.apache.org/thrift.git => github.com/apache/thrift v0.12.0
diff --git a/go.sum b/go.sum
index a733a1c96..7527f8b7d 100644
--- a/go.sum
+++ b/go.sum
@@ -6,6 +6,13 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
+github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
@@ -37,10 +44,16 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twmb/murmur3 v1.1.6 h1:mqrRot1BRxm+Yct+vavLMou2/iJt0tNVTTC0QoIjaZg=
github.com/twmb/murmur3 v1.1.6/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
+go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc=
+go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo=
+go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4=
+go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM=
+go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc=
+go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/optimizely.go b/optimizely.go
index db340eb06..7018bbdf2 100644
--- a/optimizely.go
+++ b/optimizely.go
@@ -17,8 +17,8 @@
package optimizely
import (
- "github.com/optimizely/go-sdk/pkg/client"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/client"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// Client returns an OptimizelyClient instantiated with the given key and options
diff --git a/pkg/odp/cache/lru_cache.go b/pkg/cache/lru_cache.go
similarity index 85%
rename from pkg/odp/cache/lru_cache.go
rename to pkg/cache/lru_cache.go
index 9f20800b0..410780c8c 100644
--- a/pkg/odp/cache/lru_cache.go
+++ b/pkg/cache/lru_cache.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2022, Optimizely, Inc. and contributors *
+ * Copyright 2022-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. *
@@ -30,6 +30,13 @@ type Cache interface {
Reset()
}
+// CacheWithRemove extends the Cache interface with removal capability
+// nolint:golint // Keeping name consistent with other language SDKs
+type CacheWithRemove interface {
+ Cache
+ Remove(key string)
+}
+
type cacheElement struct {
data interface{}
time time.Time
@@ -102,6 +109,19 @@ func (l *LRUCache) Reset() {
l.items = make(map[string]*cacheElement)
}
+// Remove deletes an element from the cache by key
+func (l *LRUCache) Remove(key string) {
+ if l.maxSize <= 0 {
+ return
+ }
+ l.lock.Lock()
+ defer l.lock.Unlock()
+ if item, ok := l.items[key]; ok {
+ l.queue.Remove(item.keyPtr)
+ delete(l.items, key)
+ }
+}
+
func (l *LRUCache) isValid(e *cacheElement) bool {
if l.timeout <= 0 {
return true
diff --git a/pkg/odp/cache/lru_cache_test.go b/pkg/cache/lru_cache_test.go
similarity index 65%
rename from pkg/odp/cache/lru_cache_test.go
rename to pkg/cache/lru_cache_test.go
index ea46af910..3928e519b 100644
--- a/pkg/odp/cache/lru_cache_test.go
+++ b/pkg/cache/lru_cache_test.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2022, Optimizely, Inc. and contributors *
+ * Copyright 2022-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. *
@@ -204,3 +204,122 @@ func TestTimeout(t *testing.T) {
assert.Equal(t, 200, cache2.Lookup("2"))
assert.Equal(t, 300, cache2.Lookup("3"))
}
+
+func TestRemove(t *testing.T) {
+ // Test removing an existing key
+ t.Run("Remove existing key", func(t *testing.T) {
+ cache := NewLRUCache(3, 1000*time.Second)
+
+ // Add items to cache
+ cache.Save("1", 100)
+ cache.Save("2", 200)
+ cache.Save("3", 300)
+
+ // Verify items exist
+ assert.Equal(t, 100, cache.Lookup("1"))
+ assert.Equal(t, 200, cache.Lookup("2"))
+ assert.Equal(t, 300, cache.Lookup("3"))
+ assert.Equal(t, 3, cache.queue.Len())
+ assert.Equal(t, 3, len(cache.items))
+
+ // Remove an item
+ cache.Remove("2")
+
+ // Verify item was removed
+ assert.Equal(t, 100, cache.Lookup("1"))
+ assert.Nil(t, cache.Lookup("2"))
+ assert.Equal(t, 300, cache.Lookup("3"))
+ assert.Equal(t, 2, cache.queue.Len())
+ assert.Equal(t, 2, len(cache.items))
+ })
+
+ // Test removing a non-existent key
+ t.Run("Remove non-existent key", func(t *testing.T) {
+ cache := NewLRUCache(3, 1000*time.Second)
+
+ // Add items to cache
+ cache.Save("1", 100)
+ cache.Save("2", 200)
+
+ // Remove a non-existent key
+ cache.Remove("3")
+
+ // Verify state remains unchanged
+ assert.Equal(t, 100, cache.Lookup("1"))
+ assert.Equal(t, 200, cache.Lookup("2"))
+ assert.Equal(t, 2, cache.queue.Len())
+ assert.Equal(t, 2, len(cache.items))
+ })
+
+ // Test removing from a zero-sized cache
+ t.Run("Remove from zero-sized cache", func(t *testing.T) {
+ cache := NewLRUCache(0, 1000*time.Second)
+
+ // Try to add and remove items
+ cache.Save("1", 100)
+ cache.Remove("1")
+
+ // Verify nothing happened
+ assert.Nil(t, cache.Lookup("1"))
+ assert.Equal(t, 0, cache.queue.Len())
+ assert.Equal(t, 0, len(cache.items))
+ })
+
+ // Test removing and then adding back
+ t.Run("Remove and add back", func(t *testing.T) {
+ cache := NewLRUCache(3, 1000*time.Second)
+
+ // Add items to cache
+ cache.Save("1", 100)
+ cache.Save("2", 200)
+ cache.Save("3", 300)
+
+ // Remove an item
+ cache.Remove("2")
+
+ // Add it back with a different value
+ cache.Save("2", 201)
+
+ // Verify item was added back
+ assert.Equal(t, 100, cache.Lookup("1"))
+ assert.Equal(t, 201, cache.Lookup("2"))
+ assert.Equal(t, 300, cache.Lookup("3"))
+ assert.Equal(t, 3, cache.queue.Len())
+ assert.Equal(t, 3, len(cache.items))
+ })
+
+ // Test thread safety of Remove
+ t.Run("Thread safety", func(t *testing.T) {
+ maxSize := 100
+ cache := NewLRUCache(maxSize, 1000*time.Second)
+ wg := sync.WaitGroup{}
+
+ // Add entries
+ for i := 1; i <= maxSize; i++ {
+ cache.Save(fmt.Sprintf("%d", i), i*100)
+ }
+
+ // Concurrently remove half the entries
+ wg.Add(maxSize / 2)
+ for i := 1; i <= maxSize/2; i++ {
+ go func(k int) {
+ defer wg.Done()
+ cache.Remove(fmt.Sprintf("%d", k))
+ }(i)
+ }
+ wg.Wait()
+
+ // Verify first half is removed, second half remains
+ for i := 1; i <= maxSize; i++ {
+ if i <= maxSize/2 {
+ assert.Nil(t, cache.Lookup(fmt.Sprintf("%d", i)))
+ } else {
+ assert.Equal(t, i*100, cache.Lookup(fmt.Sprintf("%d", i)))
+ }
+ }
+
+ // Verify cache size
+ assert.Equal(t, maxSize/2, cache.queue.Len())
+ assert.Equal(t, maxSize/2, len(cache.items))
+ })
+}
diff --git a/pkg/client/client.go b/pkg/client/client.go
index 2cc01989e..254ac79f3 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2023, Optimizely, Inc. and contributors *
+ * Copyright 2019-2024, Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
@@ -18,6 +18,7 @@
package client
import (
+ "context"
"encoding/json"
"errors"
"fmt"
@@ -25,33 +26,92 @@ import (
"runtime/debug"
"strconv"
- "github.com/optimizely/go-sdk/pkg/config"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision"
- pkgReasons "github.com/optimizely/go-sdk/pkg/decision/reasons"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/event"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/notification"
- "github.com/optimizely/go-sdk/pkg/odp"
- pkgOdpSegment "github.com/optimizely/go-sdk/pkg/odp/segment"
- pkgOdpUtils "github.com/optimizely/go-sdk/pkg/odp/utils"
- "github.com/optimizely/go-sdk/pkg/optimizelyjson"
- "github.com/optimizely/go-sdk/pkg/utils"
-
"github.com/hashicorp/go-multierror"
+
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision"
+ pkgReasons "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/event"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/odp"
+ pkgOdpSegment "github.com/optimizely/go-sdk/v2/pkg/odp/segment"
+ pkgOdpUtils "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/optimizelyjson"
+ "github.com/optimizely/go-sdk/v2/pkg/tracing"
+ "github.com/optimizely/go-sdk/v2/pkg/utils"
+)
+
+const (
+ // DefaultTracerName is the name of the tracer used by the Optimizely SDK
+ DefaultTracerName = "OptimizelySDK"
+ // SpanNameDecide is the name of the span used by the Optimizely SDK for tracing decide call
+ SpanNameDecide = "decide"
+ // SpanNameDecideForKeys is the name of the span used by the Optimizely SDK for tracing decideForKeys call
+ SpanNameDecideForKeys = "decideForKeys"
+ // SpanNameDecideAll is the name of the span used by the Optimizely SDK for tracing decideAll call
+ SpanNameDecideAll = "decideAll"
+ // SpanNameActivate is the name of the span used by the Optimizely SDK for tracing Activate call
+ SpanNameActivate = "Activate"
+ // SpanNameFetchQualifiedSegments is the name of the span used by the Optimizely SDK for tracing fetchQualifiedSegments call
+ SpanNameFetchQualifiedSegments = "fetchQualifiedSegments"
+ // SpanNameSendOdpEvent is the name of the span used by the Optimizely SDK for tracing SendOdpEvent call
+ SpanNameSendOdpEvent = "SendOdpEvent"
+ // SpanNameIsFeatureEnabled is the name of the span used by the Optimizely SDK for tracing IsFeatureEnabled call
+ SpanNameIsFeatureEnabled = "IsFeatureEnabled"
+ // SpanNameGetEnabledFeatures is the name of the span used by the Optimizely SDK for tracing GetEnabledFeatures call
+ SpanNameGetEnabledFeatures = "GetEnabledFeatures"
+ // SpanNameGetFeatureVariableBoolean is the name of the span used by the Optimizely SDK for tracing GetFeatureVariableBoolean call
+ SpanNameGetFeatureVariableBoolean = "GetFeatureVariableBoolean"
+ // SpanNameGetFeatureVariableDouble is the name of the span used by the Optimizely SDK for tracing GetFeatureVariableDouble call
+ SpanNameGetFeatureVariableDouble = "GetFeatureVariableDouble"
+ // SpanNameGetFeatureVariableInteger is the name of the span used by the Optimizely SDK for tracing GetFeatureVariableInteger call
+ SpanNameGetFeatureVariableInteger = "GetFeatureVariableInteger"
+ // SpanNameGetFeatureVariableString is the name of the span used by the Optimizely SDK for tracing GetFeatureVariableString call
+ SpanNameGetFeatureVariableString = "GetFeatureVariableString"
+ // SpanNameGetFeatureVariableJSON is the name of the span used by the Optimizely SDK for tracing GetFeatureVariableJSON call
+ SpanNameGetFeatureVariableJSON = "GetFeatureVariableJSON"
+ // SpanNameGetFeatureVariablePrivate is the name of the span used by the Optimizely SDK for tracing getFeatureVariable call
+ SpanNameGetFeatureVariablePrivate = "getFeatureVariable"
+ // SpanNameGetFeatureVariablePublic is the name of the span used by the Optimizely SDK for tracing GetFeatureVariable call
+ SpanNameGetFeatureVariablePublic = "GetFeatureVariable"
+ // SpanNameGetAllFeatureVariablesWithDecision is the name of the span used by the Optimizely SDK for tracing GetAllFeatureVariablesWithDecision call
+ SpanNameGetAllFeatureVariablesWithDecision = "GetAllFeatureVariablesWithDecision"
+ // SpanNameGetDetailedFeatureDecisionUnsafe is the name of the span used by the Optimizely SDK for tracing GetDetailedFeatureDecisionUnsafe call
+ SpanNameGetDetailedFeatureDecisionUnsafe = "GetDetailedFeatureDecisionUnsafe"
+ // SpanNameGetAllFeatureVariables is the name of the span used by the Optimizely SDK for tracing GetAllFeatureVariables call
+ SpanNameGetAllFeatureVariables = "GetAllFeatureVariables"
+ // SpanNameGetVariation is the name of the span used by the Optimizely SDK for tracing GetVariation call
+ SpanNameGetVariation = "GetVariation"
+ // SpanNameTrack is the name of the span used by the Optimizely SDK for tracing Track call
+ SpanNameTrack = "Track"
+ // SpanNameGetFeatureDecision is the name of the span used by the Optimizely SDK for tracing getFeatureDecision call
+ SpanNameGetFeatureDecision = "getFeatureDecision"
+ // SpanNameGetExperimentDecision is the name of the span used by the Optimizely SDK for tracing getExperimentDecision call
+ SpanNameGetExperimentDecision = "getExperimentDecision"
+ // SpanNameGetProjectConfig is the name of the span used by the Optimizely SDK for tracing getProjectConfig call
+ SpanNameGetProjectConfig = "getProjectConfig"
+ // SpanNameGetOptimizelyConfig is the name of the span used by the Optimizely SDK for tracing GetOptimizelyConfig call
+ SpanNameGetOptimizelyConfig = "GetOptimizelyConfig"
+ // SpanNameGetDecisionVariableMap is the name of the span used by the Optimizely SDK for tracing getDecisionVariableMap call
+ SpanNameGetDecisionVariableMap = "getDecisionVariableMap"
)
// OptimizelyClient is the entry point to the Optimizely SDK
type OptimizelyClient struct {
+ ctx context.Context
ConfigManager config.ProjectConfigManager
DecisionService decision.Service
+ UserProfileService decision.UserProfileService
EventProcessor event.Processor
OdpManager odp.Manager
notificationCenter notification.Center
execGroup *utils.ExecGroup
logger logging.OptimizelyLogProducer
defaultDecideOptions *decide.Options
+ tracer tracing.Tracer
}
// CreateUserContext creates a context of the user for which decision APIs will be called.
@@ -65,7 +125,13 @@ func (o *OptimizelyClient) CreateUserContext(userID string, attributes map[strin
return newOptimizelyUserContext(o, userID, attributes, nil, nil)
}
-func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string, options *decide.Options) OptimizelyDecision {
+// WithTraceContext sets the context for the OptimizelyClient which can be used to propagate trace information
+func (o *OptimizelyClient) WithTraceContext(ctx context.Context) *OptimizelyClient {
+ o.ctx = ctx
+ return o
+}
+
+func (o *OptimizelyClient) decide(userContext *OptimizelyUserContext, key string, options *decide.Options) OptimizelyDecision {
var err error
defer func() {
if r := recover(); r != nil {
@@ -83,18 +149,22 @@ func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string,
}
}()
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameDecide)
+ defer span.End()
+
decisionContext := decision.FeatureDecisionContext{
ForcedDecisionService: userContext.forcedDecisionService,
+ UserProfile: userContext.userProfile,
}
projectConfig, err := o.getProjectConfig()
if err != nil {
- return NewErrorDecision(key, userContext, decide.GetDecideError(decide.SDKNotReady))
+ return NewErrorDecision(key, *userContext, decide.GetDecideError(decide.SDKNotReady))
}
decisionContext.ProjectConfig = projectConfig
feature, err := projectConfig.GetFeatureByKey(key)
if err != nil {
- return NewErrorDecision(key, userContext, decide.GetDecideError(decide.FlagKeyInvalid, key))
+ return NewErrorDecision(key, *userContext, decide.GetDecideError(decide.FlagKeyInvalid, key))
}
decisionContext.Feature = &feature
@@ -110,6 +180,8 @@ func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string,
decisionContext.Variable = entities.Variable{}
var featureDecision decision.FeatureDecision
var reasons decide.DecisionReasons
+ var experimentID string
+ var variationID string
// To avoid cyclo-complexity warning
findRegularDecision := func() {
@@ -134,12 +206,14 @@ func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string,
}
if err != nil {
- o.logger.Warning(fmt.Sprintf(`Received error while making a decision for feature %q: %s`, key, err))
+ return o.handleDecisionServiceError(err, key, *userContext)
}
if featureDecision.Variation != nil {
variationKey = featureDecision.Variation.Key
flagEnabled = featureDecision.Variation.FeatureEnabled
+ experimentID = featureDecision.Experiment.ID
+ variationID = featureDecision.Variation.ID
}
if !allOptions.DisableDecisionEvent {
@@ -160,14 +234,14 @@ func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string,
ruleKey := featureDecision.Experiment.Key
if o.notificationCenter != nil {
- decisionNotification := decision.FlagNotification(key, variationKey, ruleKey, flagEnabled, eventSent, usrContext, variableMap, reasonsToReport)
- o.logger.Info(fmt.Sprintf(`Feature %q is enabled for user %q? %v`, key, usrContext.ID, flagEnabled))
+ decisionNotification := decision.FlagNotification(key, variationKey, ruleKey, experimentID, variationID, flagEnabled, eventSent, usrContext, variableMap, reasonsToReport)
+ o.logger.Debug(fmt.Sprintf(`Feature %q is enabled for user %q? %v`, key, usrContext.ID, flagEnabled))
if e := o.notificationCenter.Send(notification.Decision, *decisionNotification); e != nil {
o.logger.Warning("Problem with sending notification")
}
}
- return NewOptimizelyDecision(variationKey, ruleKey, key, flagEnabled, optimizelyJSON, userContext, reasonsToReport)
+ return NewOptimizelyDecision(variationKey, ruleKey, key, flagEnabled, optimizelyJSON, *userContext, reasonsToReport)
}
func (o *OptimizelyClient) decideForKeys(userContext OptimizelyUserContext, keys []string, options *decide.Options) map[string]OptimizelyDecision {
@@ -188,6 +262,9 @@ func (o *OptimizelyClient) decideForKeys(userContext OptimizelyUserContext, keys
}
}()
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameDecideForKeys)
+ defer span.End()
+
decisionMap := map[string]OptimizelyDecision{}
if _, err = o.getProjectConfig(); err != nil {
o.logger.Error("Optimizely instance is not valid, failing decideForKeys call.", err)
@@ -197,13 +274,30 @@ func (o *OptimizelyClient) decideForKeys(userContext OptimizelyUserContext, keys
if len(keys) == 0 {
return decisionMap
}
+ allOptions := o.getAllOptions(options)
- enabledFlagsOnly := o.getAllOptions(options).EnabledFlagsOnly
- for _, key := range keys {
- optimizelyDecision := o.decide(userContext, key, options)
- if !enabledFlagsOnly || optimizelyDecision.Enabled {
- decisionMap[key] = optimizelyDecision
+ var userProfile *decision.UserProfile
+ ignoreUserProfileSvc := o.UserProfileService == nil || allOptions.IgnoreUserProfileService
+ if !ignoreUserProfileSvc {
+ up := o.UserProfileService.Lookup(userContext.GetUserID())
+ if up.ID == "" {
+ up = decision.UserProfile{
+ ID: userContext.GetUserID(),
+ ExperimentBucketMap: map[decision.UserDecisionKey]string{},
+ }
}
+ userProfile = &up
+ userContext.userProfile = userProfile
+ }
+
+ for _, key := range keys {
+ optimizelyDecision := o.decide(&userContext, key, options)
+ decisionMap[key] = optimizelyDecision
+ }
+
+ if !ignoreUserProfileSvc && userProfile != nil && userProfile.HasUnsavedChange {
+ o.UserProfileService.Save(*userProfile)
+ userProfile.HasUnsavedChange = false
}
return decisionMap
@@ -228,6 +322,9 @@ func (o *OptimizelyClient) decideAll(userContext OptimizelyUserContext, options
}
}()
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameDecideAll)
+ defer span.End()
+
projectConfig, err := o.getProjectConfig()
if err != nil {
o.logger.Error("Optimizely instance is not valid, failing decideAll call.", err)
@@ -261,6 +358,9 @@ func (o *OptimizelyClient) fetchQualifiedSegments(userContext *OptimizelyUserCon
}
}()
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameFetchQualifiedSegments)
+ defer span.End()
+
// on failure, qualifiedSegments should be reset if a previous value exists.
userContext.SetQualifiedSegments(nil)
@@ -305,6 +405,9 @@ func (o *OptimizelyClient) SendOdpEvent(eventType, action string, identifiers ma
}
}()
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameSendOdpEvent)
+ defer span.End()
+
if _, err = o.getProjectConfig(); err != nil {
o.logger.Error("SendOdpEvent failed with error:", decide.GetDecideError(decide.SDKNotReady))
return err
@@ -344,6 +447,9 @@ func (o *OptimizelyClient) Activate(experimentKey string, userContext entities.U
}
}()
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameActivate)
+ defer span.End()
+
decisionContext, experimentDecision, err := o.getExperimentDecision(experimentKey, userContext)
if err != nil {
o.logger.Error("received an error while computing experiment decision", err)
@@ -382,6 +488,9 @@ func (o *OptimizelyClient) IsFeatureEnabled(featureKey string, userContext entit
}
}()
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameIsFeatureEnabled)
+ defer span.End()
+
decisionContext, featureDecision, err := o.getFeatureDecision(featureKey, "", userContext)
if err != nil {
o.logger.Error("received an error while computing feature decision", err)
@@ -395,9 +504,9 @@ func (o *OptimizelyClient) IsFeatureEnabled(featureKey string, userContext entit
}
if result {
- o.logger.Info(fmt.Sprintf(`Feature %q is enabled for user %q.`, featureKey, userContext.ID))
+ o.logger.Debug(fmt.Sprintf(`Feature %q is enabled for user %q.`, featureKey, userContext.ID))
} else {
- o.logger.Info(fmt.Sprintf(`Feature %q is not enabled for user %q.`, featureKey, userContext.ID))
+ o.logger.Debug(fmt.Sprintf(`Feature %q is not enabled for user %q.`, featureKey, userContext.ID))
}
if o.notificationCenter != nil {
@@ -436,6 +545,9 @@ func (o *OptimizelyClient) GetEnabledFeatures(userContext entities.UserContext)
}
}()
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetEnabledFeatures)
+ defer span.End()
+
projectConfig, err := o.getProjectConfig()
if err != nil {
o.logger.Error("Error retrieving ProjectConfig", err)
@@ -453,6 +565,8 @@ func (o *OptimizelyClient) GetEnabledFeatures(userContext entities.UserContext)
// GetFeatureVariableBoolean returns the feature variable value of type bool associated with the given feature and variable keys.
func (o *OptimizelyClient) GetFeatureVariableBoolean(featureKey, variableKey string, userContext entities.UserContext) (convertedValue bool, err error) {
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetFeatureVariableBoolean)
+ defer span.End()
stringValue, variableType, featureDecision, err := o.getFeatureVariable(featureKey, variableKey, userContext)
defer func() {
@@ -486,6 +600,8 @@ func (o *OptimizelyClient) GetFeatureVariableBoolean(featureKey, variableKey str
// GetFeatureVariableDouble returns the feature variable value of type double associated with the given feature and variable keys.
func (o *OptimizelyClient) GetFeatureVariableDouble(featureKey, variableKey string, userContext entities.UserContext) (convertedValue float64, err error) {
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetFeatureVariableDouble)
+ defer span.End()
stringValue, variableType, featureDecision, err := o.getFeatureVariable(featureKey, variableKey, userContext)
defer func() {
@@ -519,6 +635,8 @@ func (o *OptimizelyClient) GetFeatureVariableDouble(featureKey, variableKey stri
// GetFeatureVariableInteger returns the feature variable value of type int associated with the given feature and variable keys.
func (o *OptimizelyClient) GetFeatureVariableInteger(featureKey, variableKey string, userContext entities.UserContext) (convertedValue int, err error) {
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetFeatureVariableInteger)
+ defer span.End()
stringValue, variableType, featureDecision, err := o.getFeatureVariable(featureKey, variableKey, userContext)
defer func() {
@@ -552,6 +670,8 @@ func (o *OptimizelyClient) GetFeatureVariableInteger(featureKey, variableKey str
// GetFeatureVariableString returns the feature variable value of type string associated with the given feature and variable keys.
func (o *OptimizelyClient) GetFeatureVariableString(featureKey, variableKey string, userContext entities.UserContext) (stringValue string, err error) {
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetFeatureVariableString)
+ defer span.End()
stringValue, variableType, featureDecision, err := o.getFeatureVariable(featureKey, variableKey, userContext)
@@ -583,6 +703,8 @@ func (o *OptimizelyClient) GetFeatureVariableString(featureKey, variableKey stri
// GetFeatureVariableJSON returns the feature variable value of type json associated with the given feature and variable keys.
func (o *OptimizelyClient) GetFeatureVariableJSON(featureKey, variableKey string, userContext entities.UserContext) (optlyJSON *optimizelyjson.OptimizelyJSON, err error) {
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetFeatureVariableJSON)
+ defer span.End()
stringVal, variableType, featureDecision, err := o.getFeatureVariable(featureKey, variableKey, userContext)
defer func() {
@@ -620,6 +742,8 @@ func (o *OptimizelyClient) GetFeatureVariableJSON(featureKey, variableKey string
// getFeatureVariable is a helper function, returns feature variable as a string along with it's associated type and feature decision
func (o *OptimizelyClient) getFeatureVariable(featureKey, variableKey string, userContext entities.UserContext) (string, entities.VariableType, *decision.FeatureDecision, error) {
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetFeatureVariablePrivate)
+ defer span.End()
featureDecisionContext, featureDecision, err := o.getFeatureDecision(featureKey, variableKey, userContext)
if err != nil {
@@ -639,6 +763,8 @@ func (o *OptimizelyClient) getFeatureVariable(featureKey, variableKey string, us
// GetFeatureVariable returns feature variable as a string along with it's associated type.
func (o *OptimizelyClient) GetFeatureVariable(featureKey, variableKey string, userContext entities.UserContext) (string, entities.VariableType, error) {
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetFeatureVariablePublic)
+ defer span.End()
stringValue, variableType, featureDecision, err := o.getFeatureVariable(featureKey, variableKey, userContext)
@@ -680,6 +806,8 @@ func (o *OptimizelyClient) GetFeatureVariable(featureKey, variableKey string, us
// GetAllFeatureVariablesWithDecision returns all the variables for a given feature along with the enabled state.
func (o *OptimizelyClient) GetAllFeatureVariablesWithDecision(featureKey string, userContext entities.UserContext) (enabled bool, variableMap map[string]interface{}, err error) {
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetAllFeatureVariablesWithDecision)
+ defer span.End()
variableMap = make(map[string]interface{})
decisionContext, featureDecision, err := o.getFeatureDecision(featureKey, "", userContext)
@@ -730,6 +858,8 @@ func (o *OptimizelyClient) GetAllFeatureVariablesWithDecision(featureKey string,
// for a given feature along with the experiment key, variation key and the enabled state.
// Usage of this method is unsafe and not recommended since it can be removed in any of the next releases.
func (o *OptimizelyClient) GetDetailedFeatureDecisionUnsafe(featureKey string, userContext entities.UserContext, disableTracking bool) (decisionInfo decision.UnsafeFeatureDecisionInfo, err error) {
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetDetailedFeatureDecisionUnsafe)
+ defer span.End()
decisionInfo = decision.UnsafeFeatureDecisionInfo{}
decisionInfo.VariableMap = make(map[string]interface{})
@@ -797,6 +927,9 @@ func (o *OptimizelyClient) GetDetailedFeatureDecisionUnsafe(featureKey string, u
// GetAllFeatureVariables returns all the variables as OptimizelyJSON object for a given feature.
func (o *OptimizelyClient) GetAllFeatureVariables(featureKey string, userContext entities.UserContext) (optlyJSON *optimizelyjson.OptimizelyJSON, err error) {
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetAllFeatureVariables)
+ defer span.End()
+
_, variableMap, err := o.GetAllFeatureVariablesWithDecision(featureKey, userContext)
if err != nil {
return optlyJSON, err
@@ -824,6 +957,9 @@ func (o *OptimizelyClient) GetVariation(experimentKey string, userContext entiti
}
}()
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetVariation)
+ defer span.End()
+
_, experimentDecision, err := o.getExperimentDecision(experimentKey, userContext)
if err != nil {
o.logger.Error("received an error while computing experiment decision", err)
@@ -856,6 +992,9 @@ func (o *OptimizelyClient) Track(eventKey string, userContext entities.UserConte
}
}()
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameTrack)
+ defer span.End()
+
projectConfig, e := o.getProjectConfig()
if e != nil {
o.logger.Error("Optimizely SDK tracking error", e)
@@ -899,6 +1038,9 @@ func (o *OptimizelyClient) getFeatureDecision(featureKey, variableKey string, us
}
}()
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetFeatureDecision)
+ defer span.End()
+
userID := userContext.ID
o.logger.Debug(fmt.Sprintf(`Evaluating feature %q for user %q.`, featureKey, userID))
@@ -937,6 +1079,8 @@ func (o *OptimizelyClient) getFeatureDecision(featureKey, variableKey string, us
}
func (o *OptimizelyClient) getExperimentDecision(experimentKey string, userContext entities.UserContext) (decisionContext decision.ExperimentDecisionContext, experimentDecision decision.ExperimentDecision, err error) {
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetExperimentDecision)
+ defer span.End()
userID := userContext.ID
o.logger.Debug(fmt.Sprintf(`Evaluating experiment %q for user %q.`, experimentKey, userID))
@@ -955,6 +1099,7 @@ func (o *OptimizelyClient) getExperimentDecision(experimentKey string, userConte
decisionContext = decision.ExperimentDecisionContext{
Experiment: &experiment,
ProjectConfig: projectConfig,
+ UserProfile: nil,
}
options := &decide.Options{}
@@ -966,9 +1111,9 @@ func (o *OptimizelyClient) getExperimentDecision(experimentKey string, userConte
if experimentDecision.Variation != nil {
result := experimentDecision.Variation.Key
- o.logger.Info(fmt.Sprintf(`User %q is bucketed into variation %q of experiment %q.`, userContext.ID, result, experimentKey))
+ o.logger.Debug(fmt.Sprintf(`User %q is bucketed into variation %q of experiment %q.`, userContext.ID, result, experimentKey))
} else {
- o.logger.Info(fmt.Sprintf(`User %q is not bucketed into any variation for experiment %q: %s.`, userContext.ID, experimentKey, experimentDecision.Reason))
+ o.logger.Debug(fmt.Sprintf(`User %q is not bucketed into any variation for experiment %q: %s.`, userContext.ID, experimentKey, experimentDecision.Reason))
}
return decisionContext, experimentDecision, err
@@ -1033,6 +1178,8 @@ func (o *OptimizelyClient) getTypedValue(value string, variableType entities.Var
}
func (o *OptimizelyClient) getProjectConfig() (projectConfig config.ProjectConfig, err error) {
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetProjectConfig)
+ defer span.End()
if isNil(o.ConfigManager) {
return nil, errors.New("project config manager is not initialized")
@@ -1057,16 +1204,25 @@ func (o *OptimizelyClient) getAllOptions(options *decide.Options) decide.Options
// GetOptimizelyConfig returns OptimizelyConfig object
func (o *OptimizelyClient) GetOptimizelyConfig() (optimizelyConfig *config.OptimizelyConfig) {
-
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetOptimizelyConfig)
+ defer span.End()
return o.ConfigManager.GetOptimizelyConfig()
}
+// GetNotificationCenter returns Optimizely Notification Center interface
+func (o *OptimizelyClient) GetNotificationCenter() notification.Center {
+ return o.notificationCenter
+}
+
// Close closes the Optimizely instance and stops any ongoing tasks from its children components.
func (o *OptimizelyClient) Close() {
o.execGroup.TerminateAndWait()
}
func (o *OptimizelyClient) getDecisionVariableMap(feature entities.Feature, variation *entities.Variation, featureEnabled bool) (map[string]interface{}, decide.DecisionReasons) {
+ _, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetDecisionVariableMap)
+ defer span.End()
+
reasons := decide.NewDecisionReasons(nil)
valuesMap := map[string]interface{}{}
@@ -1092,3 +1248,9 @@ func (o *OptimizelyClient) getDecisionVariableMap(feature entities.Feature, vari
func isNil(v interface{}) bool {
return v == nil || (reflect.ValueOf(v).Kind() == reflect.Ptr && reflect.ValueOf(v).IsNil())
}
+
+func (o *OptimizelyClient) handleDecisionServiceError(err error, key string, userContext OptimizelyUserContext) OptimizelyDecision {
+ o.logger.Warning(fmt.Sprintf(`Received error while making a decision for feature %q: %s`, key, err))
+
+ return NewErrorDecision(key, userContext, err)
+}
diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go
index 96e65a696..16f91bd0a 100644
--- a/pkg/client/client_test.go
+++ b/pkg/client/client_test.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2020,2022-2023 Optimizely, Inc. and contributors *
+ * Copyright 2019-2020,2022-2024 Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
@@ -25,21 +25,22 @@ import (
"testing"
"time"
- "github.com/optimizely/go-sdk/pkg/config"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/event"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/notification"
- "github.com/optimizely/go-sdk/pkg/odp"
- "github.com/optimizely/go-sdk/pkg/odp/segment"
- pkgOdpUtils "github.com/optimizely/go-sdk/pkg/odp/utils"
- "github.com/optimizely/go-sdk/pkg/utils"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
+
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/event"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/odp"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/segment"
+ pkgOdpUtils "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/tracing"
+ "github.com/optimizely/go-sdk/v2/pkg/utils"
)
func ValidProjectConfigManager() *MockProjectConfigManager {
@@ -205,6 +206,28 @@ func (m *MockODPManager) Update(apiKey, apiHost string, segmentsToCheck []string
m.Called(apiKey, apiHost, segmentsToCheck)
}
+type MockTracer struct {
+ StartSpanCalled bool
+ TracerName string
+ CalledSpans []string
+}
+
+func (m *MockTracer) StartSpan(ctx context.Context, tracerName, spanName string) (context.Context, tracing.Span) {
+ m.StartSpanCalled = true
+ m.TracerName = tracerName
+ if m.CalledSpans == nil {
+ m.CalledSpans = make([]string, 0)
+ }
+ m.CalledSpans = append(m.CalledSpans, spanName)
+ return ctx, &MockSpan{}
+}
+
+type MockSpan struct{}
+
+func (m *MockSpan) SetAttibutes(key string, value interface{}) {}
+
+func (m *MockSpan) End() {}
+
func TestSendODPEventWhenSDKNotReady(t *testing.T) {
factory := OptimizelyFactory{SDKKey: "121"}
client, _ := factory.Client()
@@ -259,10 +282,14 @@ func TestSendODPEventEmptyType(t *testing.T) {
optimizelyClient := OptimizelyClient{
OdpManager: mockOdpManager,
ConfigManager: getMockConfigManager(),
+ tracer: &MockTracer{},
}
err := optimizelyClient.SendOdpEvent("", action, identifiers, data)
assert.NoError(t, err)
mockOdpManager.AssertExpectations(t)
+ assert.True(t, optimizelyClient.tracer.(*MockTracer).StartSpanCalled)
+ assert.Equal(t, DefaultTracerName, optimizelyClient.tracer.(*MockTracer).TracerName)
+ assert.Contains(t, optimizelyClient.tracer.(*MockTracer).CalledSpans, SpanNameSendOdpEvent)
}
func TestSendODPEventEmptyIdentifiers(t *testing.T) {
@@ -278,9 +305,11 @@ func TestSendODPEventEmptyIdentifiers(t *testing.T) {
optimizelyClient := OptimizelyClient{
logger: logging.GetLogger("", ""),
ConfigManager: getMockConfigManager(),
+ tracer: &MockTracer{},
}
err := optimizelyClient.SendOdpEvent("", action, identifiers, data)
assert.Equal(t, errors.New("ODP events must have at least one key-value pair in identifiers"), err)
+ assert.True(t, optimizelyClient.tracer.(*MockTracer).StartSpanCalled)
}
func TestSendODPEventNilIdentifiers(t *testing.T) {
@@ -295,9 +324,11 @@ func TestSendODPEventNilIdentifiers(t *testing.T) {
optimizelyClient := OptimizelyClient{
logger: logging.GetLogger("", ""),
ConfigManager: getMockConfigManager(),
+ tracer: &MockTracer{},
}
err := optimizelyClient.SendOdpEvent("", action, nil, data)
assert.Equal(t, errors.New("ODP events must have at least one key-value pair in identifiers"), err)
+ assert.True(t, optimizelyClient.tracer.(*MockTracer).StartSpanCalled)
}
func TestSendODPEvent(t *testing.T) {
@@ -306,10 +337,12 @@ func TestSendODPEvent(t *testing.T) {
optimizelyClient := OptimizelyClient{
OdpManager: mockOdpManager,
ConfigManager: getMockConfigManager(),
+ tracer: &MockTracer{},
}
err := optimizelyClient.SendOdpEvent("123", "", map[string]string{"identifier": "123"}, nil)
assert.NoError(t, err)
mockOdpManager.AssertExpectations(t)
+ assert.True(t, optimizelyClient.tracer.(*MockTracer).StartSpanCalled)
}
func TestTrack(t *testing.T) {
@@ -322,6 +355,7 @@ func TestTrack(t *testing.T) {
DecisionService: mockDecisionService,
EventProcessor: mockProcessor,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
err := client.Track("sample_conversion", entities.UserContext{ID: "1212121", Attributes: map[string]interface{}{}}, map[string]interface{}{})
@@ -330,7 +364,7 @@ func TestTrack(t *testing.T) {
assert.True(t, len(mockProcessor.Events) == 1)
assert.True(t, mockProcessor.Events[0].VisitorID == "1212121")
assert.True(t, mockProcessor.Events[0].EventContext.ProjectID == "15389410617")
-
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestTrackFailEventNotFound(t *testing.T) {
@@ -342,13 +376,14 @@ func TestTrackFailEventNotFound(t *testing.T) {
DecisionService: mockDecisionService,
EventProcessor: mockProcessor,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
err := client.Track("bob", entities.UserContext{ID: "1212121", Attributes: map[string]interface{}{}}, map[string]interface{}{})
assert.NoError(t, err)
assert.True(t, len(mockProcessor.Events) == 0)
-
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestTrackPanics(t *testing.T) {
@@ -360,13 +395,14 @@ func TestTrackPanics(t *testing.T) {
DecisionService: mockDecisionService,
EventProcessor: mockProcessor,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
err := client.Track("bob", entities.UserContext{ID: "1212121", Attributes: map[string]interface{}{}}, map[string]interface{}{})
assert.Error(t, err)
assert.True(t, len(mockProcessor.Events) == 0)
-
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetEnabledFeaturesPanic(t *testing.T) {
@@ -377,12 +413,14 @@ func TestGetEnabledFeaturesPanic(t *testing.T) {
ConfigManager: &PanickingConfigManager{},
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
// ensure that the client calms back down and recovers
result, err := client.GetEnabledFeatures(testUserContext)
assert.Empty(t, result)
assert.True(t, assert.Error(t, err))
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetFeatureVariableBool(t *testing.T) {
@@ -448,6 +486,7 @@ func TestGetFeatureVariableBool(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
result, err := client.GetFeatureVariableBoolean(testFeatureKey, testVariableKey, testUserContext)
if ts.validBool {
@@ -461,6 +500,7 @@ func TestGetFeatureVariableBool(t *testing.T) {
mockConfig.AssertExpectations(t)
mockConfigManager.AssertExpectations(t)
mockDecisionService.AssertExpectations(t)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
}
@@ -528,6 +568,7 @@ func TestGetFeatureVariableBoolWithNotification(t *testing.T) {
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
notificationCenter: notificationCenter,
+ tracer: &MockTracer{},
}
var numberOfCalls = 0
note := notification.DecisionNotification{}
@@ -548,6 +589,7 @@ func TestGetFeatureVariableBoolWithNotification(t *testing.T) {
mockConfig.AssertExpectations(t)
mockConfigManager.AssertExpectations(t)
mockDecisionService.AssertExpectations(t)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
}
@@ -562,12 +604,14 @@ func TestGetFeatureVariableBoolPanic(t *testing.T) {
ConfigManager: &PanickingConfigManager{},
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
// ensure that the client calms back down and recovers
result, err := client.GetFeatureVariableBoolean(testFeatureKey, testVariableKey, testUserContext)
assert.Equal(t, false, result)
assert.True(t, assert.Error(t, err))
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetFeatureVariableDouble(t *testing.T) {
@@ -633,6 +677,7 @@ func TestGetFeatureVariableDouble(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
result, err := client.GetFeatureVariableDouble(testFeatureKey, testVariableKey, testUserContext)
if ts.validDouble {
@@ -646,6 +691,7 @@ func TestGetFeatureVariableDouble(t *testing.T) {
mockConfig.AssertExpectations(t)
mockConfigManager.AssertExpectations(t)
mockDecisionService.AssertExpectations(t)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
}
@@ -713,6 +759,7 @@ func TestGetFeatureVariableDoubleWithNotification(t *testing.T) {
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
notificationCenter: notificationCenter,
+ tracer: &MockTracer{},
}
var numberOfCalls = 0
note := notification.DecisionNotification{}
@@ -733,6 +780,7 @@ func TestGetFeatureVariableDoubleWithNotification(t *testing.T) {
mockConfig.AssertExpectations(t)
mockConfigManager.AssertExpectations(t)
mockDecisionService.AssertExpectations(t)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
}
@@ -747,12 +795,14 @@ func TestGetFeatureVariableDoublePanic(t *testing.T) {
ConfigManager: &PanickingConfigManager{},
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
// ensure that the client calms back down and recovers
result, err := client.GetFeatureVariableDouble(testFeatureKey, testVariableKey, testUserContext)
assert.Equal(t, float64(0), result)
assert.True(t, assert.Error(t, err))
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetFeatureVariableInteger(t *testing.T) {
@@ -818,6 +868,7 @@ func TestGetFeatureVariableInteger(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
result, err := client.GetFeatureVariableInteger(testFeatureKey, testVariableKey, testUserContext)
if ts.validInteger {
@@ -831,6 +882,7 @@ func TestGetFeatureVariableInteger(t *testing.T) {
mockConfig.AssertExpectations(t)
mockConfigManager.AssertExpectations(t)
mockDecisionService.AssertExpectations(t)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
}
@@ -898,6 +950,7 @@ func TestGetFeatureVariableIntegerWithNotification(t *testing.T) {
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
notificationCenter: notificationCenter,
+ tracer: &MockTracer{},
}
var numberOfCalls = 0
note := notification.DecisionNotification{}
@@ -918,6 +971,7 @@ func TestGetFeatureVariableIntegerWithNotification(t *testing.T) {
mockConfig.AssertExpectations(t)
mockConfigManager.AssertExpectations(t)
mockDecisionService.AssertExpectations(t)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
}
@@ -932,12 +986,14 @@ func TestGetFeatureVariableIntegerPanic(t *testing.T) {
ConfigManager: &PanickingConfigManager{},
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
// ensure that the client calms back down and recovers
result, err := client.GetFeatureVariableInteger(testFeatureKey, testVariableKey, testUserContext)
assert.Equal(t, 0, result)
assert.True(t, assert.Error(t, err))
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetFeatureVariableSting(t *testing.T) {
@@ -1001,6 +1057,7 @@ func TestGetFeatureVariableSting(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
result, err := client.GetFeatureVariableString(testFeatureKey, testVariableKey, testUserContext)
if ts.validString {
@@ -1014,6 +1071,7 @@ func TestGetFeatureVariableSting(t *testing.T) {
mockConfig.AssertExpectations(t)
mockConfigManager.AssertExpectations(t)
mockDecisionService.AssertExpectations(t)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
}
@@ -1079,6 +1137,7 @@ func TestGetFeatureVariableStringWithNotification(t *testing.T) {
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
notificationCenter: notificationCenter,
+ tracer: &MockTracer{},
}
var numberOfCalls = 0
note := notification.DecisionNotification{}
@@ -1099,6 +1158,7 @@ func TestGetFeatureVariableStringWithNotification(t *testing.T) {
mockConfig.AssertExpectations(t)
mockConfigManager.AssertExpectations(t)
mockDecisionService.AssertExpectations(t)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
}
func TestGetFeatureVariableStringPanic(t *testing.T) {
@@ -1112,12 +1172,14 @@ func TestGetFeatureVariableStringPanic(t *testing.T) {
ConfigManager: &PanickingConfigManager{},
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
// ensure that the client calms back down and recovers
result, err := client.GetFeatureVariableString(testFeatureKey, testVariableKey, testUserContext)
assert.Equal(t, "", result)
assert.True(t, assert.Error(t, err))
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetFeatureVariableJSON(t *testing.T) {
@@ -1184,6 +1246,7 @@ func TestGetFeatureVariableJSON(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
result, err := client.GetFeatureVariableJSON(testFeatureKey, testVariableKey, testUserContext)
if ts.validJson {
@@ -1203,6 +1266,7 @@ func TestGetFeatureVariableJSON(t *testing.T) {
mockConfig.AssertExpectations(t)
mockConfigManager.AssertExpectations(t)
mockDecisionService.AssertExpectations(t)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
}
@@ -1270,6 +1334,7 @@ func TestGetFeatureVariableJSONWithNotification(t *testing.T) {
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
notificationCenter: notificationCenter,
+ tracer: &MockTracer{},
}
var numberOfCalls = 0
note := notification.DecisionNotification{}
@@ -1290,6 +1355,7 @@ func TestGetFeatureVariableJSONWithNotification(t *testing.T) {
mockConfig.AssertExpectations(t)
mockConfigManager.AssertExpectations(t)
mockDecisionService.AssertExpectations(t)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
}
func TestGetFeatureVariableJSONPanic(t *testing.T) {
@@ -1303,12 +1369,14 @@ func TestGetFeatureVariableJSONPanic(t *testing.T) {
ConfigManager: &PanickingConfigManager{},
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
// ensure that the client calms back down and recovers
result, err := client.GetFeatureVariableJSON(testFeatureKey, testVariableKey, testUserContext)
assert.Nil(t, result)
assert.True(t, assert.Error(t, err))
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetFeatureVariableErrorCases(t *testing.T) {
@@ -1322,6 +1390,7 @@ func TestGetFeatureVariableErrorCases(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
_, err1 := client.GetFeatureVariableBoolean("test_feature_key", "test_variable_key", testUserContext)
_, err2 := client.GetFeatureVariableDouble("test_feature_key", "test_variable_key", testUserContext)
@@ -1336,6 +1405,7 @@ func TestGetFeatureVariableErrorCases(t *testing.T) {
mockConfigManager.AssertNotCalled(t, "GetFeatureByKey")
mockConfigManager.AssertNotCalled(t, "GetVariableByKey")
mockDecisionService.AssertNotCalled(t, "GetFeatureDecision")
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetProjectConfigIsValid(t *testing.T) {
@@ -1344,12 +1414,14 @@ func TestGetProjectConfigIsValid(t *testing.T) {
client := OptimizelyClient{
ConfigManager: mockConfigManager,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
actual, err := client.getProjectConfig()
assert.Nil(t, err)
assert.Equal(t, mockConfigManager.projectConfig, actual)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetProjectConfigIsInValid(t *testing.T) {
@@ -1357,12 +1429,14 @@ func TestGetProjectConfigIsInValid(t *testing.T) {
client := OptimizelyClient{
ConfigManager: InValidProjectConfigManager(),
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
actual, err := client.getProjectConfig()
assert.NotNil(t, err)
assert.Nil(t, actual)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetOptimizelyConfig(t *testing.T) {
@@ -1371,11 +1445,23 @@ func TestGetOptimizelyConfig(t *testing.T) {
client := OptimizelyClient{
ConfigManager: mockConfigManager,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
optimizelyConfig := client.GetOptimizelyConfig()
assert.Equal(t, &config.OptimizelyConfig{Revision: "232"}, optimizelyConfig)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
+}
+
+func TestGetNotificationCenter(t *testing.T) {
+ nc := &MockNotificationCenter{}
+ client := OptimizelyClient{
+ notificationCenter: nc,
+ tracer: &MockTracer{},
+ }
+
+ assert.Equal(t, client.GetNotificationCenter(), nc)
}
func TestGetFeatureDecisionValid(t *testing.T) {
@@ -1417,11 +1503,13 @@ func TestGetFeatureDecisionValid(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
_, featureDecision, err := client.getFeatureDecision(testFeatureKey, testVariableKey, testUserContext)
assert.Nil(t, err)
assert.Equal(t, expectedFeatureDecision, featureDecision)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetFeatureDecisionErrProjectConfig(t *testing.T) {
@@ -1463,10 +1551,12 @@ func TestGetFeatureDecisionErrProjectConfig(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
_, _, err := client.getFeatureDecision(testFeatureKey, testVariableKey, testUserContext)
assert.Error(t, err)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetFeatureDecisionPanicProjectConfig(t *testing.T) {
@@ -1507,10 +1597,12 @@ func TestGetFeatureDecisionPanicProjectConfig(t *testing.T) {
ConfigManager: &PanickingConfigManager{},
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
_, _, err := client.getFeatureDecision(testFeatureKey, testVariableKey, testUserContext)
assert.Error(t, err)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetFeatureDecisionPanicDecisionService(t *testing.T) {
@@ -1542,11 +1634,13 @@ func TestGetFeatureDecisionPanicDecisionService(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: &PanickingDecisionService{},
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
_, _, err := client.getFeatureDecision(testFeatureKey, testVariableKey, testUserContext)
assert.Error(t, err)
assert.EqualError(t, err, "I'm panicking")
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetFeatureDecisionErrFeatureDecision(t *testing.T) {
@@ -1588,11 +1682,12 @@ func TestGetFeatureDecisionErrFeatureDecision(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
- }
+ tracer: &MockTracer{}}
_, decision, err := client.getFeatureDecision(testFeatureKey, testVariableKey, testUserContext)
assert.Equal(t, expectedFeatureDecision, decision)
assert.NoError(t, err)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetAllFeatureVariablesWithDecision(t *testing.T) {
@@ -1643,6 +1738,7 @@ func TestGetAllFeatureVariablesWithDecision(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
enabled, variationMap, err := client.GetAllFeatureVariablesWithDecision(testFeatureKey, testUserContext)
@@ -1652,6 +1748,7 @@ func TestGetAllFeatureVariablesWithDecision(t *testing.T) {
for _, v := range variables {
assert.Equal(t, v.expected, variationMap[v.key])
}
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetAllFeatureVariablesWithDecisionWithNotification(t *testing.T) {
@@ -1702,6 +1799,7 @@ func TestGetAllFeatureVariablesWithDecisionWithNotification(t *testing.T) {
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
notificationCenter: notificationCenter,
+ tracer: &MockTracer{},
}
var numberOfCalls = 0
note := notification.DecisionNotification{}
@@ -1721,6 +1819,7 @@ func TestGetAllFeatureVariablesWithDecisionWithNotification(t *testing.T) {
"var_json": map[string]interface{}{"field1": 12.0, "field2": "some_value"}, "var_str": "var"}}}
assert.Equal(t, numberOfCalls, 1)
assert.Equal(t, decisionInfo, note.DecisionInfo)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetAllFeatureVariablesWithDecisionWithError(t *testing.T) {
@@ -1762,6 +1861,7 @@ func TestGetAllFeatureVariablesWithDecisionWithError(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
enabled, variationMap, err := client.GetAllFeatureVariablesWithDecision(testFeatureKey, testUserContext)
@@ -1770,6 +1870,7 @@ func TestGetAllFeatureVariablesWithDecisionWithError(t *testing.T) {
assert.True(t, enabled)
assert.Equal(t, testVariableValue, variationMap[testVariableKey])
assert.NoError(t, err)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetAllFeatureVariablesWithDecisionWithoutFeature(t *testing.T) {
@@ -1786,6 +1887,7 @@ func TestGetAllFeatureVariablesWithDecisionWithoutFeature(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
enabled, variationMap, err := client.GetAllFeatureVariablesWithDecision(invalidFeatureKey, testUserContext)
@@ -1794,6 +1896,7 @@ func TestGetAllFeatureVariablesWithDecisionWithoutFeature(t *testing.T) {
assert.False(t, enabled)
assert.Equal(t, 0, len(variationMap))
assert.NoError(t, err)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetDetailedFeatureDecisionUnsafeWithNotification(t *testing.T) {
@@ -1844,6 +1947,7 @@ func TestGetDetailedFeatureDecisionUnsafeWithNotification(t *testing.T) {
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
notificationCenter: notificationCenter,
+ tracer: &MockTracer{},
}
var numberOfCalls = 0
note := notification.DecisionNotification{}
@@ -1863,6 +1967,7 @@ func TestGetDetailedFeatureDecisionUnsafeWithNotification(t *testing.T) {
"var_json": map[string]interface{}{"field1": 12.0, "field2": "some_value"}, "var_str": "var"}}}
assert.Equal(t, numberOfCalls, 1)
assert.Equal(t, decisionInfo, note.DecisionInfo)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetDetailedFeatureDecisionUnsafeWithTrackingDisabled(t *testing.T) {
@@ -1913,6 +2018,7 @@ func TestGetDetailedFeatureDecisionUnsafeWithTrackingDisabled(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
decision, err := client.GetDetailedFeatureDecisionUnsafe(testFeatureKey, testUserContext, true)
@@ -1924,6 +2030,7 @@ func TestGetDetailedFeatureDecisionUnsafeWithTrackingDisabled(t *testing.T) {
}
assert.Equal(t, decision.ExperimentKey, "")
assert.Equal(t, decision.VariationKey, "")
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetDetailedFeatureDecisionUnsafeWithoutFeature(t *testing.T) {
@@ -1940,6 +2047,7 @@ func TestGetDetailedFeatureDecisionUnsafeWithoutFeature(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
decision, err := client.GetDetailedFeatureDecisionUnsafe(invalidFeatureKey, testUserContext, true)
@@ -1948,6 +2056,7 @@ func TestGetDetailedFeatureDecisionUnsafeWithoutFeature(t *testing.T) {
assert.False(t, decision.Enabled)
assert.Equal(t, 0, len(decision.VariableMap))
assert.NoError(t, err)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetDetailedFeatureDecisionUnsafeWithError(t *testing.T) {
@@ -1973,11 +2082,13 @@ func TestGetDetailedFeatureDecisionUnsafeWithError(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
decision, err := client.GetDetailedFeatureDecisionUnsafe(testFeatureKey, testUserContext, true)
assert.False(t, decision.Enabled)
assert.Error(t, err)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetDetailedFeatureDecisionUnsafeWithFeatureTestAndTrackingEnabled(t *testing.T) {
@@ -2014,6 +2125,7 @@ func TestGetDetailedFeatureDecisionUnsafeWithFeatureTestAndTrackingEnabled(t *te
DecisionService: mockDecisionService,
EventProcessor: mockEventProcessor,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
decision, err := client.GetDetailedFeatureDecisionUnsafe(testFeature.Key, testUserContext, false)
@@ -2026,6 +2138,7 @@ func TestGetDetailedFeatureDecisionUnsafeWithFeatureTestAndTrackingEnabled(t *te
mockConfigManager.AssertExpectations(t)
mockDecisionService.AssertExpectations(t)
mockEventProcessor.AssertExpectations(t)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetAllFeatureVariables(t *testing.T) {
@@ -2074,6 +2187,7 @@ func TestGetAllFeatureVariables(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
optlyJSON, err := client.GetAllFeatureVariables(testFeatureKey, testUserContext)
@@ -2091,6 +2205,7 @@ func TestGetAllFeatureVariables(t *testing.T) {
assert.Equal(t, 12.0, jsonVarMap["field1"])
assert.Equal(t, "some_value", jsonVarMap["field2"])
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
func TestGetAllFeatureVariablesWithoutFeature(t *testing.T) {
@@ -2107,6 +2222,7 @@ func TestGetAllFeatureVariablesWithoutFeature(t *testing.T) {
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
optlyJson, err := client.GetAllFeatureVariables(invalidFeatureKey, testUserContext)
@@ -2119,6 +2235,7 @@ func TestGetAllFeatureVariablesWithoutFeature(t *testing.T) {
variationString, err := optlyJson.ToString()
assert.Equal(t, "{}", variationString)
+ assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
// Helper Methods
@@ -2199,6 +2316,7 @@ func (s *ClientTestSuiteAB) TestActivate() {
DecisionService: s.mockDecisionService,
EventProcessor: s.mockEventProcessor,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
variationKey1, err1 := testClient.Activate("test_exp_1", testUserContext)
@@ -2222,6 +2340,7 @@ func (s *ClientTestSuiteAB) TestActivatePanics() {
ConfigManager: new(PanickingConfigManager),
DecisionService: s.mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
variationKey, err := testClient.Activate("test_exp_1", testUserContext)
@@ -2238,6 +2357,7 @@ func (s *ClientTestSuiteAB) TestActivateInvalidConfig() {
testClient := OptimizelyClient{
ConfigManager: mockConfigManager,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
variationKey, err := testClient.Activate("test_exp_1", testUserContext)
@@ -2266,6 +2386,7 @@ func (s *ClientTestSuiteAB) TestGetVariation() {
ConfigManager: s.mockConfigManager,
DecisionService: s.mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
variationKey, err := testClient.GetVariation("test_exp_1", testUserContext)
@@ -2296,6 +2417,7 @@ func (s *ClientTestSuiteAB) TestGetVariationWithDecisionError() {
ConfigManager: s.mockConfigManager,
DecisionService: s.mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
variationKey, err := testClient.GetVariation("test_exp_1", testUserContext)
@@ -2313,6 +2435,7 @@ func (s *ClientTestSuiteAB) TestGetVariationPanics() {
ConfigManager: new(PanickingConfigManager),
DecisionService: s.mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
variationKey, err := testClient.GetVariation("test_exp_1", testUserContext)
@@ -2365,6 +2488,7 @@ func (s *ClientTestSuiteFM) TestIsFeatureEnabled() {
DecisionService: s.mockDecisionService,
EventProcessor: s.mockEventProcessor,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
result, _ := client.IsFeatureEnabled(testFeature.Key, testUserContext)
s.True(result)
@@ -2403,6 +2527,7 @@ func (s *ClientTestSuiteFM) TestIsFeatureEnabledWithNotification() {
DecisionService: s.mockDecisionService,
logger: logging.GetLogger("", ""),
notificationCenter: notificationCenter,
+ tracer: &MockTracer{},
}
var numberOfCalls = 0
note := notification.DecisionNotification{}
@@ -2457,6 +2582,7 @@ func (s *ClientTestSuiteFM) TestIsFeatureEnabledWithDecisionError() {
DecisionService: s.mockDecisionService,
EventProcessor: s.mockEventProcessor,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
// should still return the decision because the error is non-fatal
@@ -2479,6 +2605,7 @@ func (s *ClientTestSuiteFM) TestIsFeatureEnabledErrorConfig() {
ConfigManager: s.mockConfigManager,
DecisionService: s.mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
result, _ := client.IsFeatureEnabled(testFeatureKey, testUserContext)
s.False(result)
@@ -2502,6 +2629,7 @@ func (s *ClientTestSuiteFM) TestIsFeatureEnabledErrorFeatureKey() {
ConfigManager: s.mockConfigManager,
DecisionService: s.mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
result, err := client.IsFeatureEnabled(testFeatureKey, testUserContext)
s.NoError(err)
@@ -2517,6 +2645,7 @@ func (s *ClientTestSuiteFM) TestIsFeatureEnabledPanic() {
client := OptimizelyClient{
ConfigManager: &PanickingConfigManager{},
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
// ensure that the client calms back down and recovers
@@ -2565,6 +2694,7 @@ func (s *ClientTestSuiteFM) TestGetEnabledFeatures() {
ConfigManager: s.mockConfigManager,
DecisionService: s.mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
result, err := client.GetEnabledFeatures(testUserContext)
s.NoError(err)
@@ -2586,6 +2716,7 @@ func (s *ClientTestSuiteFM) TestGetEnabledFeaturesErrorCases() {
ConfigManager: mockConfigManager,
DecisionService: s.mockDecisionService,
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
result, err := client.GetEnabledFeatures(testUserContext)
s.Error(err)
@@ -2721,6 +2852,7 @@ func (s *ClientTestSuiteTrackEvent) SetupTest() {
EventProcessor: s.mockProcessor,
notificationCenter: notification.NewNotificationCenter(),
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
}
@@ -2900,6 +3032,7 @@ func (s *ClientTestSuiteTrackNotification) SetupTest() {
EventProcessor: s.mockProcessor,
notificationCenter: notification.NewNotificationCenter(),
logger: logging.GetLogger("", ""),
+ tracer: &MockTracer{},
}
}
diff --git a/pkg/client/factory.go b/pkg/client/factory.go
index 2a718dd9a..e4a59d535 100644
--- a/pkg/client/factory.go
+++ b/pkg/client/factory.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2020,2022-2023 Optimizely, Inc. and contributors *
+ * Copyright 2019-2020,2022-2024 Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
@@ -22,17 +22,18 @@ import (
"errors"
"time"
- "github.com/optimizely/go-sdk/pkg/config"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision"
- "github.com/optimizely/go-sdk/pkg/event"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/metrics"
- "github.com/optimizely/go-sdk/pkg/notification"
- "github.com/optimizely/go-sdk/pkg/odp"
- pkgUtils "github.com/optimizely/go-sdk/pkg/odp/utils"
- "github.com/optimizely/go-sdk/pkg/registry"
- "github.com/optimizely/go-sdk/pkg/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision"
+ "github.com/optimizely/go-sdk/v2/pkg/event"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/metrics"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/odp"
+ pkgUtils "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/registry"
+ "github.com/optimizely/go-sdk/v2/pkg/tracing"
+ "github.com/optimizely/go-sdk/v2/pkg/utils"
)
// OptimizelyFactory is used to customize and construct an instance of the OptimizelyClient.
@@ -48,8 +49,10 @@ type OptimizelyFactory struct {
eventDispatcher event.Dispatcher
eventProcessor event.Processor
metricsRegistry metrics.Registry
+ tracer tracing.Tracer
overrideStore decision.ExperimentOverrideStore
userProfileService decision.UserProfileService
+ notificationCenter notification.Center
// ODP
segmentsCacheSize int
@@ -62,6 +65,8 @@ type OptimizelyFactory struct {
type OptionFunc func(*OptimizelyFactory)
// Client instantiates a new OptimizelyClient with the given options.
+//
+//nolint:gocyclo // exceeds gocyclo cyclomatic complexity
func (f *OptimizelyFactory) Client(clientOptions ...OptionFunc) (*OptimizelyClient, error) {
// Default values for odp cache
f.segmentsCacheSize = pkgUtils.DefaultSegmentsCacheSize
@@ -101,8 +106,20 @@ func (f *OptimizelyFactory) Client(clientOptions ...OptionFunc) (*OptimizelyClie
appClient := &OptimizelyClient{
defaultDecideOptions: decideOptions,
execGroup: eg,
- notificationCenter: registry.GetNotificationCenter(f.SDKKey),
logger: logging.GetLogger(f.SDKKey, "OptimizelyClient"),
+ ctx: ctx,
+ }
+
+ if f.notificationCenter != nil {
+ appClient.notificationCenter = f.notificationCenter
+ } else {
+ appClient.notificationCenter = registry.GetNotificationCenter(f.SDKKey)
+ }
+
+ if f.tracer != nil {
+ appClient.tracer = f.tracer
+ } else {
+ appClient.tracer = &tracing.NoopTracer{}
}
if f.configManager != nil {
@@ -128,6 +145,10 @@ func (f *OptimizelyFactory) Client(clientOptions ...OptionFunc) (*OptimizelyClie
appClient.EventProcessor = event.NewBatchEventProcessor(eventProcessorOptions...)
}
+ if f.userProfileService != nil {
+ appClient.UserProfileService = f.userProfileService
+ }
+
if f.decisionService != nil {
appClient.DecisionService = f.decisionService
} else {
@@ -285,6 +306,20 @@ func WithMetricsRegistry(metricsRegistry metrics.Registry) OptionFunc {
}
}
+// WithNotificationCenter allows user to pass in their own implementation of the notification.Center interface
+func WithNotificationCenter(nc notification.Center) OptionFunc {
+ return func(f *OptimizelyFactory) {
+ f.notificationCenter = nc
+ }
+}
+
+// WithTracer allows user to pass in their own implementation of the Tracer interface
+func WithTracer(tracer tracing.Tracer) OptionFunc {
+ return func(f *OptimizelyFactory) {
+ f.tracer = tracer
+ }
+}
+
// StaticClient returns a client initialized with a static project config.
func (f *OptimizelyFactory) StaticClient() (optlyClient *OptimizelyClient, err error) {
@@ -371,6 +406,12 @@ func convertDecideOptions(options []decide.OptimizelyDecideOptions) *decide.Opti
finalOptions.IncludeReasons = true
case decide.ExcludeVariables:
finalOptions.ExcludeVariables = true
+ case decide.IgnoreCMABCache:
+ finalOptions.IgnoreCMABCache = true
+ case decide.ResetCMABCache:
+ finalOptions.ResetCMABCache = true
+ case decide.InvalidateUserCMABCache:
+ finalOptions.InvalidateUserCMABCache = true
}
}
return &finalOptions
diff --git a/pkg/client/factory_test.go b/pkg/client/factory_test.go
index 0d9ae2b03..fb6326a2b 100644
--- a/pkg/client/factory_test.go
+++ b/pkg/client/factory_test.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2020,2022 Optimizely, Inc. and contributors *
+ * Copyright 2019-2020,2022,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. *
@@ -24,22 +24,23 @@ import (
"testing"
"time"
- "github.com/optimizely/go-sdk/pkg/config"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision"
- "github.com/optimizely/go-sdk/pkg/event"
- "github.com/optimizely/go-sdk/pkg/metrics"
- "github.com/optimizely/go-sdk/pkg/notification"
- "github.com/optimizely/go-sdk/pkg/odp"
- "github.com/optimizely/go-sdk/pkg/odp/cache"
- pkgOdpEvent "github.com/optimizely/go-sdk/pkg/odp/event"
- pkgOdpSegment "github.com/optimizely/go-sdk/pkg/odp/segment"
- pkgOdpUtils "github.com/optimizely/go-sdk/pkg/odp/utils"
- "github.com/optimizely/go-sdk/pkg/registry"
- "github.com/optimizely/go-sdk/pkg/utils"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
+
+ "github.com/optimizely/go-sdk/v2/pkg/cache"
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision"
+ "github.com/optimizely/go-sdk/v2/pkg/event"
+ "github.com/optimizely/go-sdk/v2/pkg/metrics"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/odp"
+ pkgOdpEvent "github.com/optimizely/go-sdk/v2/pkg/odp/event"
+ pkgOdpSegment "github.com/optimizely/go-sdk/v2/pkg/odp/segment"
+ pkgOdpUtils "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/registry"
+ "github.com/optimizely/go-sdk/v2/pkg/tracing"
+ "github.com/optimizely/go-sdk/v2/pkg/utils"
)
type MockRequester struct {
@@ -179,6 +180,14 @@ func TestClientWithDefaultSDKSettings(t *testing.T) {
assert.NotNil(t, optimizelyClient.OdpManager)
}
+func TestClientWithNotificationCenterInOptions(t *testing.T) {
+ factory := OptimizelyFactory{SDKKey: "1212"}
+ nc := &MockNotificationCenter{}
+ optimizelyClient, err := factory.Client(WithNotificationCenter(nc))
+ assert.NoError(t, err)
+ assert.Equal(t, nc, optimizelyClient.notificationCenter)
+}
+
func TestDummy(t *testing.T) {
factory := OptimizelyFactory{}
configManager := config.NewPollingProjectConfigManager("123")
@@ -358,3 +367,70 @@ func TestClientWithDefaultDecideOptions(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, &decide.Options{}, optimizelyClient.defaultDecideOptions)
}
+
+func TestOptimizelyClientWithTracer(t *testing.T) {
+ factory := OptimizelyFactory{SDKKey: "1212"}
+ optimizelyClient, err := factory.Client(WithTracer(&MockTracer{}))
+ assert.NoError(t, err)
+ assert.NotNil(t, optimizelyClient.tracer)
+ tracer := optimizelyClient.tracer.(*MockTracer)
+ assert.NotNil(t, tracer)
+}
+
+func TestOptimizelyClientWithNoTracer(t *testing.T) {
+ factory := OptimizelyFactory{SDKKey: "1212"}
+ optimizelyClient, err := factory.Client()
+ assert.NoError(t, err)
+ assert.NotNil(t, optimizelyClient.tracer)
+ tracer := optimizelyClient.tracer.(*tracing.NoopTracer)
+ assert.NotNil(t, tracer)
+}
+
+func TestConvertDecideOptionsWithCMABOptions(t *testing.T) {
+ // Test with IgnoreCMABCache option
+ options := []decide.OptimizelyDecideOptions{decide.IgnoreCMABCache}
+ convertedOptions := convertDecideOptions(options)
+ assert.True(t, convertedOptions.IgnoreCMABCache)
+ assert.False(t, convertedOptions.ResetCMABCache)
+ assert.False(t, convertedOptions.InvalidateUserCMABCache)
+
+ // Test with ResetCMABCache option
+ options = []decide.OptimizelyDecideOptions{decide.ResetCMABCache}
+ convertedOptions = convertDecideOptions(options)
+ assert.False(t, convertedOptions.IgnoreCMABCache)
+ assert.True(t, convertedOptions.ResetCMABCache)
+ assert.False(t, convertedOptions.InvalidateUserCMABCache)
+
+ // Test with InvalidateUserCMABCache option
+ options = []decide.OptimizelyDecideOptions{decide.InvalidateUserCMABCache}
+ convertedOptions = convertDecideOptions(options)
+ assert.False(t, convertedOptions.IgnoreCMABCache)
+ assert.False(t, convertedOptions.ResetCMABCache)
+ assert.True(t, convertedOptions.InvalidateUserCMABCache)
+
+ // Test with all CMAB options
+ options = []decide.OptimizelyDecideOptions{
+ decide.IgnoreCMABCache,
+ decide.ResetCMABCache,
+ decide.InvalidateUserCMABCache,
+ }
+ convertedOptions = convertDecideOptions(options)
+ assert.True(t, convertedOptions.IgnoreCMABCache)
+ assert.True(t, convertedOptions.ResetCMABCache)
+ assert.True(t, convertedOptions.InvalidateUserCMABCache)
+
+ // Test with CMAB options mixed with other options
+ options = []decide.OptimizelyDecideOptions{
+ decide.DisableDecisionEvent,
+ decide.IgnoreCMABCache,
+ decide.EnabledFlagsOnly,
+ decide.ResetCMABCache,
+ decide.InvalidateUserCMABCache,
+ }
+ convertedOptions = convertDecideOptions(options)
+ assert.True(t, convertedOptions.DisableDecisionEvent)
+ assert.True(t, convertedOptions.EnabledFlagsOnly)
+ assert.True(t, convertedOptions.IgnoreCMABCache)
+ assert.True(t, convertedOptions.ResetCMABCache)
+ assert.True(t, convertedOptions.InvalidateUserCMABCache)
+}
diff --git a/pkg/client/fixtures_test.go b/pkg/client/fixtures_test.go
index 109934085..12e8a855d 100644
--- a/pkg/client/fixtures_test.go
+++ b/pkg/client/fixtures_test.go
@@ -20,12 +20,12 @@ package client
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/config"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/event"
- "github.com/optimizely/go-sdk/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/event"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
"github.com/stretchr/testify/mock"
)
diff --git a/pkg/client/optimizely_decision.go b/pkg/client/optimizely_decision.go
index 719832ceb..588e7cb50 100644
--- a/pkg/client/optimizely_decision.go
+++ b/pkg/client/optimizely_decision.go
@@ -18,7 +18,7 @@
package client
import (
- "github.com/optimizely/go-sdk/pkg/optimizelyjson"
+ "github.com/optimizely/go-sdk/v2/pkg/optimizelyjson"
)
// OptimizelyDecision defines the decision returned by decide api.
diff --git a/pkg/client/optimizely_decision_test.go b/pkg/client/optimizely_decision_test.go
index fe2c06cb4..69139f398 100644
--- a/pkg/client/optimizely_decision_test.go
+++ b/pkg/client/optimizely_decision_test.go
@@ -20,7 +20,7 @@ import (
"errors"
"testing"
- "github.com/optimizely/go-sdk/pkg/optimizelyjson"
+ "github.com/optimizely/go-sdk/v2/pkg/optimizelyjson"
"github.com/stretchr/testify/suite"
)
diff --git a/pkg/client/optimizely_user_context.go b/pkg/client/optimizely_user_context.go
index 297ef6383..e62543463 100644
--- a/pkg/client/optimizely_user_context.go
+++ b/pkg/client/optimizely_user_context.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2020-2022, Optimizely, Inc. and contributors *
+ * Copyright 2020-2022, 2024 Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
@@ -21,10 +21,10 @@ import (
"errors"
"sync"
- "github.com/optimizely/go-sdk/pkg/decide"
- pkgDecision "github.com/optimizely/go-sdk/pkg/decision"
- "github.com/optimizely/go-sdk/pkg/entities"
- pkgOdpSegment "github.com/optimizely/go-sdk/pkg/odp/segment"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ pkgDecision "github.com/optimizely/go-sdk/v2/pkg/decision"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ pkgOdpSegment "github.com/optimizely/go-sdk/v2/pkg/odp/segment"
)
// OptimizelyUserContext defines user contexts that the SDK will use to make decisions for.
@@ -35,6 +35,7 @@ type OptimizelyUserContext struct {
qualifiedSegments []string
optimizely *OptimizelyClient
forcedDecisionService *pkgDecision.ForcedDecisionService
+ userProfile *pkgDecision.UserProfile
mutex *sync.RWMutex
}
@@ -130,21 +131,31 @@ func (o *OptimizelyUserContext) IsQualifiedFor(segment string) bool {
func (o *OptimizelyUserContext) Decide(key string, options []decide.OptimizelyDecideOptions) OptimizelyDecision {
// use a copy of the user context so that any changes to the original context are not reflected inside the decision
userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService(), o.GetQualifiedSegments())
- return o.optimizely.decide(userContextCopy, key, convertDecideOptions(options))
+ decision, found := o.optimizely.decideForKeys(userContextCopy, []string{key}, convertDecideOptions(options))[key]
+ if !found {
+ return NewErrorDecision(key, *o, decide.GetDecideError(decide.SDKNotReady))
+ }
+ return decision
}
// DecideAll returns a key-map of decision results for all active flag keys with options.
func (o *OptimizelyUserContext) DecideAll(options []decide.OptimizelyDecideOptions) map[string]OptimizelyDecision {
// use a copy of the user context so that any changes to the original context are not reflected inside the decision
userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService(), o.GetQualifiedSegments())
- return o.optimizely.decideAll(userContextCopy, convertDecideOptions(options))
+ decideOptions := convertDecideOptions(options)
+ decisionMap := o.optimizely.decideAll(userContextCopy, decideOptions)
+
+ return filteredDecision(decisionMap, o.optimizely.getAllOptions(decideOptions).EnabledFlagsOnly)
}
// DecideForKeys returns a key-map of decision results for multiple flag keys and options.
func (o *OptimizelyUserContext) DecideForKeys(keys []string, options []decide.OptimizelyDecideOptions) map[string]OptimizelyDecision {
// use a copy of the user context so that any changes to the original context are not reflected inside the decision
userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService(), o.GetQualifiedSegments())
- return o.optimizely.decideForKeys(userContextCopy, keys, convertDecideOptions(options))
+ decideOptions := convertDecideOptions(options)
+ decisionMap := o.optimizely.decideForKeys(userContextCopy, keys, decideOptions)
+
+ return filteredDecision(decisionMap, o.optimizely.getAllOptions(decideOptions).EnabledFlagsOnly)
}
// TrackEvent generates a conversion event with the given event key if it exists and queues it up to be sent to the Optimizely
@@ -159,27 +170,27 @@ func (o *OptimizelyUserContext) TrackEvent(eventKey string, eventTags map[string
// SetForcedDecision sets the forced decision (variation key) for a given decision context (flag key and optional rule key).
// returns true if the forced decision has been set successfully.
-func (o *OptimizelyUserContext) SetForcedDecision(context pkgDecision.OptimizelyDecisionContext, decision pkgDecision.OptimizelyForcedDecision) bool {
+func (o *OptimizelyUserContext) SetForcedDecision(ctx pkgDecision.OptimizelyDecisionContext, decision pkgDecision.OptimizelyForcedDecision) bool {
if o.forcedDecisionService == nil {
o.forcedDecisionService = pkgDecision.NewForcedDecisionService(o.GetUserID())
}
- return o.forcedDecisionService.SetForcedDecision(context, decision)
+ return o.forcedDecisionService.SetForcedDecision(ctx, decision)
}
// GetForcedDecision returns the forced decision for a given flag and an optional rule
-func (o *OptimizelyUserContext) GetForcedDecision(context pkgDecision.OptimizelyDecisionContext) (pkgDecision.OptimizelyForcedDecision, error) {
+func (o *OptimizelyUserContext) GetForcedDecision(ctx pkgDecision.OptimizelyDecisionContext) (pkgDecision.OptimizelyForcedDecision, error) {
if o.forcedDecisionService == nil {
return pkgDecision.OptimizelyForcedDecision{}, errors.New("decision not found")
}
- return o.forcedDecisionService.GetForcedDecision(context)
+ return o.forcedDecisionService.GetForcedDecision(ctx)
}
// RemoveForcedDecision removes the forced decision for a given flag and an optional rule.
-func (o *OptimizelyUserContext) RemoveForcedDecision(context pkgDecision.OptimizelyDecisionContext) bool {
+func (o *OptimizelyUserContext) RemoveForcedDecision(ctx pkgDecision.OptimizelyDecisionContext) bool {
if o.forcedDecisionService == nil {
return false
}
- return o.forcedDecisionService.RemoveForcedDecision(context)
+ return o.forcedDecisionService.RemoveForcedDecision(ctx)
}
// RemoveAllForcedDecisions removes all forced decisions bound to this user context.
@@ -208,3 +219,13 @@ func copyQualifiedSegments(qualifiedSegments []string) (qualifiedSegmentsCopy []
copy(qualifiedSegmentsCopy, qualifiedSegments)
return
}
+
+func filteredDecision(decisionMap map[string]OptimizelyDecision, enabledFlagsOnly bool) map[string]OptimizelyDecision {
+ filteredDecision := make(map[string]OptimizelyDecision)
+ for key, decision := range decisionMap {
+ if !enabledFlagsOnly || decision.Enabled {
+ filteredDecision[key] = decision
+ }
+ }
+ return filteredDecision
+}
diff --git a/pkg/client/optimizely_user_context_odp_test.go b/pkg/client/optimizely_user_context_odp_test.go
index f3b811932..56082ed7e 100644
--- a/pkg/client/optimizely_user_context_odp_test.go
+++ b/pkg/client/optimizely_user_context_odp_test.go
@@ -23,13 +23,14 @@ import (
"sync"
"testing"
- "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/odp"
- "github.com/optimizely/go-sdk/pkg/odp/event"
- "github.com/optimizely/go-sdk/pkg/odp/segment"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
+
+ "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/odp"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/event"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/segment"
)
type OptimizelyUserContextODPTestSuite struct {
diff --git a/pkg/client/optimizely_user_context_test.go b/pkg/client/optimizely_user_context_test.go
index 7c8d7961c..2d5295633 100644
--- a/pkg/client/optimizely_user_context_test.go
+++ b/pkg/client/optimizely_user_context_test.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2020-2022, Optimizely, Inc. and contributors *
+ * Copyright 2020-2022, 2024 Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
@@ -22,14 +22,15 @@ import (
"sync"
"testing"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/event"
- "github.com/optimizely/go-sdk/pkg/notification"
- "github.com/optimizely/go-sdk/pkg/optimizelyjson"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
+
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/event"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/optimizelyjson"
)
var doOnce sync.Once // required since we only need to read datafile once
@@ -820,6 +821,8 @@ func (s *OptimizelyUserContextTestSuite) TestDecisionNotification() {
enabled := true
variablesExpected, err := s.OptimizelyClient.GetAllFeatureVariables(flagKey, entities.UserContext{ID: s.userID})
s.Nil(err)
+ experimentId := "10420810910"
+ variationId := "10418551353"
ruleKey := "exp_no_audience"
reasons := []string{}
@@ -838,6 +841,8 @@ func (s *OptimizelyUserContextTestSuite) TestDecisionNotification() {
"ruleKey": ruleKey,
"reasons": reasons,
"decisionEventDispatched": true,
+ "experimentId": experimentId,
+ "variationId": variationId,
}
s.OptimizelyClient.DecisionService.OnDecision(callback)
_ = user.Decide(flagKey, nil)
@@ -1220,6 +1225,97 @@ func (s *OptimizelyUserContextTestSuite) TestForcedDecision() {
s.Error(err)
}
+func (s *OptimizelyUserContextTestSuite) TestDecideAllFlagsWithBatchUPS() {
+ userProfileService := new(MockUserProfileService)
+ var err error
+ s.OptimizelyClient, err = s.factory.Client(
+ WithEventProcessor(s.eventProcessor),
+ WithUserProfileService(userProfileService),
+ )
+ s.Nil(err)
+
+ savedUserProfile := decision.UserProfile{
+ ID: s.userID,
+ }
+ userProfileService.On("Lookup", s.userID).Return(savedUserProfile)
+ userProfileService.On("Save", mock.Anything)
+
+ user := s.OptimizelyClient.CreateUserContext(s.userID, nil)
+ decisions := user.DecideAll(nil)
+ s.Len(decisions, 3)
+
+ userProfileService.AssertNumberOfCalls(s.T(), "Lookup", 1)
+ userProfileService.AssertNumberOfCalls(s.T(), "Save", 1)
+}
+
+func (s *OptimizelyUserContextTestSuite) TestDecideForKeysWithBatchUPS() {
+ flagKey1 := "feature_1"
+ experimentID1 := "10390977673"
+ variationKey1 := "18257766532"
+ variationID1 := "variation_with_traffic"
+ flagKey2 := "feature_2" // embedding experiment: "exp_no_audience"
+ experimentID2 := "10420810910"
+ variationID2 := "10418510624"
+ variationKey2 := "variation_no_traffic"
+ userProfileService := new(MockUserProfileService)
+ var err error
+ s.OptimizelyClient, err = s.factory.Client(
+ WithEventProcessor(s.eventProcessor),
+ WithUserProfileService(userProfileService),
+ )
+ s.Nil(err)
+
+ savedUserProfile := decision.UserProfile{
+ ID: s.userID,
+ ExperimentBucketMap: map[decision.UserDecisionKey]string{
+ decision.NewUserDecisionKey(experimentID1): variationID1,
+ decision.NewUserDecisionKey(experimentID2): variationID2,
+ },
+ }
+ userProfileService.On("Lookup", s.userID).Return(savedUserProfile)
+ userProfileService.On("Save", mock.Anything)
+
+ user := s.OptimizelyClient.CreateUserContext(s.userID, nil)
+ decisions := user.DecideForKeys([]string{flagKey1, flagKey2}, nil)
+ s.Len(decisions, 2)
+ s.Equal(variationKey1, decisions[flagKey1].VariationKey)
+ s.Equal(variationKey2, decisions[flagKey2].VariationKey)
+
+ userProfileService.AssertNumberOfCalls(s.T(), "Lookup", 1)
+ userProfileService.AssertNumberOfCalls(s.T(), "Save", 0)
+}
+
+func (s *OptimizelyUserContextTestSuite) TestDecideWithBatchUPS() {
+ flagKey := "feature_2" // embedding experiment: "exp_no_audience"
+ experimentID := "10420810910"
+ variationID2 := "10418510624"
+ variationKey1 := "variation_no_traffic"
+
+ userProfileService := new(MockUserProfileService)
+ s.OptimizelyClient, _ = s.factory.Client(
+ WithEventProcessor(s.eventProcessor),
+ WithUserProfileService(userProfileService),
+ )
+
+ decisionKey := decision.NewUserDecisionKey(experimentID)
+ savedUserProfile := decision.UserProfile{
+ ID: s.userID,
+ ExperimentBucketMap: map[decision.UserDecisionKey]string{decisionKey: variationID2},
+ }
+ userProfileService.On("Lookup", s.userID).Return(savedUserProfile)
+ userProfileService.On("Save", mock.Anything)
+
+ client, err := s.factory.Client(WithUserProfileService(userProfileService))
+ s.Nil(err)
+ user := client.CreateUserContext(s.userID, nil)
+ decision := user.Decide(flagKey, []decide.OptimizelyDecideOptions{decide.IncludeReasons})
+ s.Len(decision.Reasons, 1)
+
+ s.Equal(variationKey1, decision.VariationKey)
+ userProfileService.AssertCalled(s.T(), "Lookup", s.userID)
+ userProfileService.AssertNotCalled(s.T(), "Save", mock.Anything)
+}
+
func TestOptimizelyUserContextTestSuite(t *testing.T) {
suite.Run(t, new(OptimizelyUserContextTestSuite))
}
diff --git a/pkg/cmab/client.go b/pkg/cmab/client.go
new file mode 100644
index 000000000..8a010e3c3
--- /dev/null
+++ b/pkg/cmab/client.go
@@ -0,0 +1,263 @@
+/****************************************************************************
+ * 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 cmab provides contextual multi-armed bandit functionality
+package cmab
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "time"
+
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+)
+
+// CMABPredictionEndpoint is the endpoint for CMAB predictions
+var CMABPredictionEndpoint = "https://prediction.cmab.optimizely.com/predict/%s"
+
+const (
+ // DefaultMaxRetries is the default number of retries for CMAB requests
+ DefaultMaxRetries = 3
+ // DefaultInitialBackoff is the default initial backoff duration
+ DefaultInitialBackoff = 100 * time.Millisecond
+ // DefaultMaxBackoff is the default maximum backoff duration
+ DefaultMaxBackoff = 10 * time.Second
+ // DefaultBackoffMultiplier is the default multiplier for exponential backoff
+ DefaultBackoffMultiplier = 2.0
+)
+
+// Attribute represents an attribute in a CMAB request
+type Attribute struct {
+ ID string `json:"id"`
+ Value interface{} `json:"value"`
+ Type string `json:"type"`
+}
+
+// Instance represents an instance in a CMAB request
+type Instance struct {
+ VisitorID string `json:"visitorId"`
+ ExperimentID string `json:"experimentId"`
+ Attributes []Attribute `json:"attributes"`
+ CmabUUID string `json:"cmabUUID"`
+}
+
+// Request represents a request to the CMAB API
+type Request struct {
+ Instances []Instance `json:"instances"`
+}
+
+// Prediction represents a prediction in a CMAB response
+type Prediction struct {
+ VariationID string `json:"variation_id"`
+}
+
+// Response represents a response from the CMAB API
+type Response struct {
+ Predictions []Prediction `json:"predictions"`
+}
+
+// RetryConfig defines configuration for retry behavior
+type RetryConfig struct {
+ // MaxRetries is the maximum number of retry attempts
+ MaxRetries int
+ // InitialBackoff is the initial backoff duration
+ InitialBackoff time.Duration
+ // MaxBackoff is the maximum backoff duration
+ MaxBackoff time.Duration
+ // BackoffMultiplier is the multiplier for exponential backoff
+ BackoffMultiplier float64
+}
+
+// DefaultCmabClient implements the CmabClient interface
+type DefaultCmabClient struct {
+ httpClient *http.Client
+ retryConfig *RetryConfig
+ logger logging.OptimizelyLogProducer
+}
+
+// ClientOptions defines options for creating a CMAB client
+type ClientOptions struct {
+ HTTPClient *http.Client
+ RetryConfig *RetryConfig
+ Logger logging.OptimizelyLogProducer
+}
+
+// NewDefaultCmabClient creates a new instance of DefaultCmabClient
+func NewDefaultCmabClient(options ClientOptions) *DefaultCmabClient {
+ httpClient := options.HTTPClient
+ if httpClient == nil {
+ httpClient = &http.Client{
+ Timeout: 10 * time.Second,
+ }
+ }
+
+ // retry is optional:
+ // retryConfig can be nil - in that case, no retries will be performed
+ retryConfig := options.RetryConfig
+
+ logger := options.Logger
+ if logger == nil {
+ logger = logging.GetLogger("", "DefaultCmabClient")
+ }
+
+ return &DefaultCmabClient{
+ httpClient: httpClient,
+ retryConfig: retryConfig,
+ logger: logger,
+ }
+}
+
+// FetchDecision fetches a decision from the CMAB API
+func (c *DefaultCmabClient) FetchDecision(
+ ruleID string,
+ userID string,
+ attributes map[string]interface{},
+ cmabUUID string,
+) (string, error) {
+
+ // Create the URL
+ url := fmt.Sprintf(CMABPredictionEndpoint, ruleID)
+
+ // Convert attributes to CMAB format
+ cmabAttributes := make([]Attribute, 0, len(attributes))
+ for key, value := range attributes {
+ cmabAttributes = append(cmabAttributes, Attribute{
+ ID: key,
+ Value: value,
+ Type: "custom_attribute",
+ })
+ }
+
+ // Create the request body
+ requestBody := Request{
+ Instances: []Instance{
+ {
+ VisitorID: userID,
+ ExperimentID: ruleID,
+ Attributes: cmabAttributes,
+ CmabUUID: cmabUUID,
+ },
+ },
+ }
+
+ // Serialize the request body
+ bodyBytes, err := json.Marshal(requestBody)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal CMAB request: %w", err)
+ }
+
+ // If no retry config, just do a single fetch
+ if c.retryConfig == nil {
+ return c.doFetch(context.Background(), url, bodyBytes)
+ }
+
+ // Retry sending request with exponential backoff
+ var lastErr error
+ for i := 0; i <= c.retryConfig.MaxRetries; i++ {
+ // Make the request
+ result, err := c.doFetch(context.Background(), url, bodyBytes)
+ if err == nil {
+ return result, nil
+ }
+
+ lastErr = err
+
+ // Don't wait after the last attempt
+ if i < c.retryConfig.MaxRetries {
+ backoffDuration := c.retryConfig.InitialBackoff * time.Duration(1< c.retryConfig.MaxBackoff {
+ backoffDuration = c.retryConfig.MaxBackoff
+ }
+
+ c.logger.Debug(fmt.Sprintf("CMAB request retry %d/%d, backing off for %v",
+ i+1, c.retryConfig.MaxRetries, backoffDuration))
+
+ // Wait for backoff duration with context awareness
+ select {
+ case <-context.Background().Done():
+ return "", fmt.Errorf("context canceled or timed out during backoff: %w", context.Background().Err())
+ case <-time.After(backoffDuration):
+ // Continue with retry
+ }
+
+ c.logger.Warning(fmt.Sprintf("CMAB API request failed (attempt %d/%d): %v",
+ i+1, c.retryConfig.MaxRetries, err))
+ }
+
+ // This should never be reached due to the return in the loop above
+ return "", fmt.Errorf("failed to fetch CMAB decision after %d attempts: %w", c.retryConfig.MaxRetries, lastErr)
+}
+
+// doFetch performs a single fetch operation to the CMAB API
+func (c *DefaultCmabClient) doFetch(ctx context.Context, url string, bodyBytes []byte) (string, error) {
+ // Create the request
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
+ if err != nil {
+ return "", fmt.Errorf("failed to create CMAB request: %w", err)
+ }
+
+ // Set headers
+ req.Header.Set("Content-Type", "application/json")
+
+ // Execute the request
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("CMAB request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ // Check status code
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return "", fmt.Errorf("CMAB API returned non-success status code: %d", resp.StatusCode)
+ }
+
+ // Read response body
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read CMAB response body: %w", err)
+ }
+
+ // Parse response
+ var cmabResponse Response
+ if err := json.Unmarshal(respBody, &cmabResponse); err != nil {
+ return "", fmt.Errorf("failed to unmarshal CMAB response: %w", err)
+ }
+
+ // Validate response
+ if !c.validateResponse(cmabResponse) {
+ return "", fmt.Errorf("invalid CMAB response: missing predictions or variation_id")
+ }
+
+ // Return the variation ID
+ return cmabResponse.Predictions[0].VariationID, nil
+}
+
+// validateResponse validates the CMAB response
+func (c *DefaultCmabClient) validateResponse(response Response) bool {
+ return len(response.Predictions) > 0 && response.Predictions[0].VariationID != ""
+}
diff --git a/pkg/cmab/client_test.go b/pkg/cmab/client_test.go
new file mode 100644
index 000000000..9080a0f5d
--- /dev/null
+++ b/pkg/cmab/client_test.go
@@ -0,0 +1,643 @@
+/****************************************************************************
+ * 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 cmab //
+package cmab
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Mock logger for testing
+type mockLogger struct {
+ debugFn func(message string)
+ warningFn func(message string)
+}
+
+func (m *mockLogger) Debug(message string) {
+ if m.debugFn != nil {
+ m.debugFn(message)
+ }
+}
+
+func (m *mockLogger) Info(message string) {}
+
+func (m *mockLogger) Warning(message string) {
+ if m.warningFn != nil {
+ m.warningFn(message)
+ }
+}
+
+// Update the Error method to match the expected interface
+func (m *mockLogger) Error(message string, err interface{}) {}
+
+func TestDefaultCmabClient_FetchDecision(t *testing.T) {
+ // Setup test server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Verify request method
+ assert.Equal(t, http.MethodPost, r.Method)
+
+ // Verify content type
+ assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
+
+ // Parse request body
+ var requestBody Request
+ err := json.NewDecoder(r.Body).Decode(&requestBody)
+ assert.NoError(t, err)
+
+ // Verify request structure
+ assert.Len(t, requestBody.Instances, 1)
+ instance := requestBody.Instances[0]
+ assert.Equal(t, "user123", instance.VisitorID)
+ assert.Equal(t, "rule456", instance.ExperimentID)
+ assert.Equal(t, "test-uuid", instance.CmabUUID)
+
+ // Verify attributes - check for various types
+ assert.Len(t, instance.Attributes, 5)
+
+ // Create a map for easier attribute checking
+ attrMap := make(map[string]Attribute)
+ for _, attr := range instance.Attributes {
+ attrMap[attr.ID] = attr
+ assert.Equal(t, "custom_attribute", attr.Type)
+ }
+
+ // Check string attribute
+ assert.Contains(t, attrMap, "string_attr")
+ assert.Equal(t, "string value", attrMap["string_attr"].Value)
+
+ // Check int attribute
+ assert.Contains(t, attrMap, "int_attr")
+ assert.Equal(t, float64(42), attrMap["int_attr"].Value) // JSON numbers are float64
+
+ // Check float attribute
+ assert.Contains(t, attrMap, "float_attr")
+ assert.Equal(t, 3.14, attrMap["float_attr"].Value)
+
+ // Check bool attribute
+ assert.Contains(t, attrMap, "bool_attr")
+ assert.Equal(t, true, attrMap["bool_attr"].Value)
+
+ // Check null attribute
+ assert.Contains(t, attrMap, "null_attr")
+ assert.Nil(t, attrMap["null_attr"].Value)
+
+ // Return response
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ response := Response{
+ Predictions: []Prediction{
+ {
+ VariationID: "var123",
+ },
+ },
+ }
+ json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ // Create client with custom endpoint
+ client := NewDefaultCmabClient(ClientOptions{
+ HTTPClient: &http.Client{
+ Timeout: 5 * time.Second,
+ },
+ })
+
+ // Override the endpoint for testing
+ originalEndpoint := CMABPredictionEndpoint
+ CMABPredictionEndpoint = server.URL + "/%s"
+ defer func() { CMABPredictionEndpoint = originalEndpoint }()
+
+ // Test with various attribute types
+ attributes := map[string]interface{}{
+ "string_attr": "string value",
+ "int_attr": 42,
+ "float_attr": 3.14,
+ "bool_attr": true,
+ "null_attr": nil,
+ }
+
+ variationID, err := client.FetchDecision("rule456", "user123", attributes, "test-uuid")
+
+ // Verify results
+ assert.NoError(t, err)
+ assert.Equal(t, "var123", variationID)
+}
+
+func TestDefaultCmabClient_FetchDecision_WithRetry(t *testing.T) {
+ // Setup counter for tracking request attempts
+ requestCount := 0
+
+ // Setup test server that fails initially then succeeds
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestCount++
+
+ // Verify request method and content type
+ assert.Equal(t, http.MethodPost, r.Method)
+ assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
+
+ // Parse request body to verify it's consistent across retries
+ body, err := io.ReadAll(r.Body)
+ assert.NoError(t, err)
+
+ var requestBody Request
+ err = json.Unmarshal(body, &requestBody)
+ assert.NoError(t, err)
+
+ // Verify request structure is consistent
+ assert.Len(t, requestBody.Instances, 1)
+ instance := requestBody.Instances[0]
+ assert.Equal(t, "user123", instance.VisitorID)
+ assert.Equal(t, "rule456", instance.ExperimentID)
+ assert.Equal(t, "test-uuid", instance.CmabUUID)
+
+ // First two requests fail, third succeeds
+ if requestCount <= 2 {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ // Return success response on third attempt
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ response := Response{
+ Predictions: []Prediction{
+ {
+ VariationID: "var123",
+ },
+ },
+ }
+ json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ // Create client with custom endpoint and retry config
+ client := NewDefaultCmabClient(ClientOptions{
+ HTTPClient: &http.Client{
+ Timeout: 5 * time.Second,
+ },
+ RetryConfig: &RetryConfig{
+ MaxRetries: 5,
+ InitialBackoff: 10 * time.Millisecond, // Short backoff for testing
+ MaxBackoff: 100 * time.Millisecond,
+ BackoffMultiplier: 2.0,
+ },
+ })
+
+ // Override the endpoint for testing
+ originalEndpoint := CMABPredictionEndpoint
+ CMABPredictionEndpoint = server.URL + "/%s"
+ defer func() { CMABPredictionEndpoint = originalEndpoint }()
+
+ // Test fetch decision with retry
+ attributes := map[string]interface{}{
+ "browser": "chrome",
+ "isMobile": true,
+ }
+
+ startTime := time.Now()
+ variationID, err := client.FetchDecision("rule456", "user123", attributes, "test-uuid")
+ duration := time.Since(startTime)
+
+ // Verify results
+ assert.NoError(t, err)
+ assert.Equal(t, "var123", variationID)
+ assert.Equal(t, 3, requestCount, "Expected 3 request attempts")
+
+ // Verify that backoff was applied (at least some delay between requests)
+ assert.True(t, duration >= 30*time.Millisecond, "Expected some backoff delay between requests")
+}
+
+func TestDefaultCmabClient_FetchDecision_ExhaustedRetries(t *testing.T) {
+ // Setup counter for tracking request attempts
+ requestCount := 0
+
+ // Setup test server that always fails
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestCount++
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer server.Close()
+
+ // Create client with custom endpoint and retry config
+ client := NewDefaultCmabClient(ClientOptions{
+ HTTPClient: &http.Client{
+ Timeout: 5 * time.Second,
+ },
+ RetryConfig: &RetryConfig{
+ MaxRetries: 2, // Allow 2 retries (3 total attempts)
+ InitialBackoff: 10 * time.Millisecond,
+ MaxBackoff: 100 * time.Millisecond,
+ BackoffMultiplier: 2.0,
+ },
+ })
+
+ // Override the endpoint for testing
+ originalEndpoint := CMABPredictionEndpoint
+ CMABPredictionEndpoint = server.URL + "/%s"
+ defer func() { CMABPredictionEndpoint = originalEndpoint }()
+
+ // Test fetch decision with exhausted retries
+ attributes := map[string]interface{}{
+ "browser": "chrome",
+ "isMobile": true,
+ }
+
+ variationID, err := client.FetchDecision("rule456", "user123", attributes, "test-uuid")
+
+ t.Logf("Actual error: %q", err.Error())
+ t.Logf("Request count: %d", requestCount)
+
+ // Verify results
+ assert.Error(t, err)
+ assert.Equal(t, "", variationID)
+ assert.Equal(t, 3, requestCount, "Expected 3 request attempts (initial + 2 retries)")
+
+ // Verify results
+ assert.Error(t, err)
+ assert.Equal(t, "", variationID)
+ assert.Equal(t, 3, requestCount, "Expected 3 request attempts (initial + 2 retries)")
+ assert.Contains(t, err.Error(), "failed to fetch CMAB decision after 2 attempts")
+ assert.Contains(t, err.Error(), "non-success status code: 500")
+}
+
+func TestDefaultCmabClient_FetchDecision_NoRetryConfig(t *testing.T) {
+ // Setup counter for tracking request attempts
+ requestCount := 0
+
+ // Setup test server that fails
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestCount++
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer server.Close()
+
+ // Create client with custom endpoint but no retry config
+ client := NewDefaultCmabClient(ClientOptions{
+ HTTPClient: &http.Client{
+ Timeout: 5 * time.Second,
+ },
+ RetryConfig: nil, // Explicitly set to nil to override default
+ })
+
+ // Override the endpoint for testing
+ originalEndpoint := CMABPredictionEndpoint
+ CMABPredictionEndpoint = server.URL + "/%s"
+ defer func() { CMABPredictionEndpoint = originalEndpoint }()
+
+ // Test fetch decision without retry config
+ attributes := map[string]interface{}{
+ "browser": "chrome",
+ }
+
+ _, err := client.FetchDecision("rule456", "user123", attributes, "test-uuid")
+
+ // Verify results
+ assert.Error(t, err)
+ assert.Equal(t, 1, requestCount, "Expected only 1 request attempt without retry config")
+}
+
+func TestDefaultCmabClient_FetchDecision_InvalidResponse(t *testing.T) {
+ // Test cases for invalid responses
+ testCases := []struct {
+ name string
+ responseBody string
+ expectedErrMsg string
+ }{
+ {
+ name: "Empty predictions array",
+ responseBody: `{"predictions": []}`,
+ expectedErrMsg: "invalid CMAB response",
+ },
+ {
+ name: "Missing variation_id",
+ responseBody: `{"predictions": [{"some_field": "value"}]}`,
+ expectedErrMsg: "invalid CMAB response",
+ },
+ {
+ name: "Empty variation_id",
+ responseBody: `{"predictions": [{"variation_id": ""}]}`,
+ expectedErrMsg: "invalid CMAB response",
+ },
+ {
+ name: "Invalid JSON",
+ responseBody: `{invalid json`,
+ expectedErrMsg: "failed to unmarshal CMAB response",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup test server that returns the test case response
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(tc.responseBody))
+ }))
+ defer server.Close()
+
+ // Create client with custom endpoint
+ client := NewDefaultCmabClient(ClientOptions{
+ HTTPClient: &http.Client{
+ Timeout: 5 * time.Second,
+ },
+ })
+
+ // Override the endpoint for testing
+ originalEndpoint := CMABPredictionEndpoint
+ CMABPredictionEndpoint = server.URL + "/%s"
+ defer func() { CMABPredictionEndpoint = originalEndpoint }()
+
+ // Test fetch decision with invalid response
+ attributes := map[string]interface{}{
+ "browser": "chrome",
+ }
+
+ _, err := client.FetchDecision("rule456", "user123", attributes, "test-uuid")
+
+ // Verify results
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ })
+ }
+}
+
+func TestDefaultCmabClient_FetchDecision_NetworkErrors(t *testing.T) {
+ // Create a custom logger that captures log messages to verify retries
+ retryAttempted := false
+ mockLogger := &mockLogger{
+ warningFn: func(message string) {
+ if strings.Contains(message, "CMAB API request failed (attempt") {
+ retryAttempted = true
+ }
+ },
+ }
+
+ // Create client with non-existent server to simulate network errors
+ client := NewDefaultCmabClient(ClientOptions{
+ HTTPClient: &http.Client{
+ Timeout: 100 * time.Millisecond, // Short timeout to fail quickly
+ },
+ RetryConfig: &RetryConfig{
+ MaxRetries: 1,
+ InitialBackoff: 10 * time.Millisecond,
+ MaxBackoff: 100 * time.Millisecond,
+ BackoffMultiplier: 2.0,
+ },
+ Logger: mockLogger,
+ })
+
+ // Set endpoint to a non-existent server
+ originalEndpoint := CMABPredictionEndpoint
+ CMABPredictionEndpoint = "http://non-existent-server.example.com/%s"
+ defer func() { CMABPredictionEndpoint = originalEndpoint }()
+
+ // Test fetch decision with network error
+ attributes := map[string]interface{}{
+ "browser": "chrome",
+ }
+
+ _, err := client.FetchDecision("rule456", "user123", attributes, "test-uuid")
+
+ // Verify results
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to fetch CMAB decision after 1 attempts")
+
+ // Verify that retry was attempted by checking if the warning log was produced
+ assert.True(t, retryAttempted, "Expected retry to be attempted")
+}
+
+func TestDefaultCmabClient_ExponentialBackoff(t *testing.T) {
+ // Setup test server that tracks request times
+ requestTimes := []time.Time{}
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestTimes = append(requestTimes, time.Now())
+
+ // First 3 requests fail, 4th succeeds
+ if len(requestTimes) < 4 {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ // Return success response
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ response := Response{
+ Predictions: []Prediction{
+ {
+ VariationID: "var123",
+ },
+ },
+ }
+ json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ // Create client with custom endpoint and specific retry config
+ client := NewDefaultCmabClient(ClientOptions{
+ HTTPClient: &http.Client{
+ Timeout: 5 * time.Second,
+ },
+ RetryConfig: &RetryConfig{
+ MaxRetries: 5,
+ InitialBackoff: 50 * time.Millisecond,
+ MaxBackoff: 1 * time.Second,
+ BackoffMultiplier: 2.0,
+ },
+ })
+
+ // Override the endpoint for testing
+ originalEndpoint := CMABPredictionEndpoint
+ CMABPredictionEndpoint = server.URL + "/%s"
+ defer func() { CMABPredictionEndpoint = originalEndpoint }()
+
+ // Test fetch decision with exponential backoff
+ attributes := map[string]interface{}{
+ "browser": "chrome",
+ }
+
+ variationID, err := client.FetchDecision("rule456", "user123", attributes, "test-uuid")
+
+ // Verify results
+ require.NoError(t, err)
+ assert.Equal(t, "var123", variationID)
+ assert.Equal(t, 4, len(requestTimes), "Expected 4 request attempts")
+
+ // Verify exponential backoff intervals
+ // First request happens immediately, then we should see increasing intervals
+ if len(requestTimes) >= 4 {
+ interval1 := requestTimes[1].Sub(requestTimes[0])
+ interval2 := requestTimes[2].Sub(requestTimes[1])
+ interval3 := requestTimes[3].Sub(requestTimes[2])
+
+ // Each interval should be approximately double the previous one
+ // Allow some margin for test execution timing variations
+ assert.True(t, interval1 >= 50*time.Millisecond, "First backoff should be at least initialBackoff")
+ assert.True(t, interval2 >= 100*time.Millisecond, "Second backoff should be at least 2x initialBackoff")
+ assert.True(t, interval3 >= 200*time.Millisecond, "Third backoff should be at least 4x initialBackoff")
+
+ // Verify increasing pattern
+ assert.True(t, interval2 > interval1, "Backoff intervals should increase")
+ assert.True(t, interval3 > interval2, "Backoff intervals should increase")
+ }
+}
+
+func TestNewDefaultCmabClient_DefaultValues(t *testing.T) {
+ // Test with empty options
+ client := NewDefaultCmabClient(ClientOptions{})
+
+ // Verify default values
+ assert.NotNil(t, client.httpClient)
+ assert.Nil(t, client.retryConfig) // retryConfig should be nil by default
+ assert.NotNil(t, client.logger)
+}
+
+func TestDefaultCmabClient_LoggingBehavior(t *testing.T) {
+ // Create a custom logger that captures log messages
+ logMessages := []string{}
+ mockLogger := &mockLogger{
+ debugFn: func(message string) {
+ logMessages = append(logMessages, "DEBUG: "+message)
+ },
+ warningFn: func(message string) {
+ logMessages = append(logMessages, "WARNING: "+message)
+ },
+ }
+
+ // Setup test server that fails then succeeds
+ requestCount := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestCount++
+
+ if requestCount == 1 {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `{"predictions":[{"variation_id":"var123"}]}`)
+ }))
+ defer server.Close()
+
+ // Create client with custom logger
+ client := NewDefaultCmabClient(ClientOptions{
+ HTTPClient: &http.Client{
+ Timeout: 5 * time.Second,
+ },
+ RetryConfig: &RetryConfig{
+ MaxRetries: 1,
+ InitialBackoff: 10 * time.Millisecond,
+ MaxBackoff: 100 * time.Millisecond,
+ BackoffMultiplier: 2.0,
+ },
+ Logger: mockLogger,
+ })
+
+ // Override the endpoint for testing
+ originalEndpoint := CMABPredictionEndpoint
+ CMABPredictionEndpoint = server.URL + "/%s"
+ defer func() { CMABPredictionEndpoint = originalEndpoint }()
+
+ // Test fetch decision
+ attributes := map[string]interface{}{
+ "browser": "chrome",
+ }
+
+ _, err := client.FetchDecision("rule456", "user123", attributes, "test-uuid")
+ assert.NoError(t, err)
+
+ // Verify log messages
+ assert.True(t, len(logMessages) >= 2, "Expected at least 2 log messages")
+
+ // Check for retry warning
+ foundRetryWarning := false
+ foundBackoffDebug := false
+ for _, msg := range logMessages {
+ if strings.Contains(msg, "WARNING") && strings.Contains(msg, "CMAB API request failed") {
+ foundRetryWarning = true
+ }
+ if strings.Contains(msg, "DEBUG") && strings.Contains(msg, "CMAB request retry") {
+ foundBackoffDebug = true
+ }
+ }
+
+ assert.True(t, foundRetryWarning, "Expected warning log about API request failure")
+ assert.True(t, foundBackoffDebug, "Expected debug log about retry backoff")
+}
+
+func TestDefaultCmabClient_NonSuccessStatusCode(t *testing.T) {
+ // Setup test server that returns different non-2xx status codes
+ testCases := []struct {
+ name string
+ statusCode int
+ statusText string
+ }{
+ {"BadRequest", http.StatusBadRequest, "Bad Request"},
+ {"Unauthorized", http.StatusUnauthorized, "Unauthorized"},
+ {"Forbidden", http.StatusForbidden, "Forbidden"},
+ {"NotFound", http.StatusNotFound, "Not Found"},
+ {"InternalServerError", http.StatusInternalServerError, "Internal Server Error"},
+ {"ServiceUnavailable", http.StatusServiceUnavailable, "Service Unavailable"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(tc.statusCode)
+ w.Write([]byte(tc.statusText))
+ }))
+ defer server.Close()
+
+ // Create client with custom endpoint and no retries
+ client := NewDefaultCmabClient(ClientOptions{
+ HTTPClient: &http.Client{
+ Timeout: 5 * time.Second,
+ },
+ // No retry config to simplify the test
+ })
+
+ // Override the endpoint for testing
+ originalEndpoint := CMABPredictionEndpoint
+ CMABPredictionEndpoint = server.URL + "/%s"
+ defer func() { CMABPredictionEndpoint = originalEndpoint }()
+
+ // Test fetch decision
+ attributes := map[string]interface{}{
+ "browser": "chrome",
+ }
+
+ variationID, err := client.FetchDecision("rule456", "user123", attributes, "test-uuid")
+
+ // Verify results
+ assert.Error(t, err, "Expected error for non-success status code")
+ assert.Equal(t, "", variationID, "Expected empty variation ID for error response")
+ assert.Contains(t, err.Error(), "non-success status code")
+ assert.Contains(t, err.Error(), fmt.Sprintf("%d", tc.statusCode))
+ })
+ }
+}
diff --git a/pkg/cmab/service.go b/pkg/cmab/service.go
new file mode 100644
index 000000000..3801dc075
--- /dev/null
+++ b/pkg/cmab/service.go
@@ -0,0 +1,273 @@
+/****************************************************************************
+ * 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 cmab //
+package cmab
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/optimizely/go-sdk/v2/pkg/cache"
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/twmb/murmur3"
+)
+
+// DefaultCmabService implements the CmabService interface
+type DefaultCmabService struct {
+ cmabCache cache.CacheWithRemove
+ cmabClient Client
+ logger logging.OptimizelyLogProducer
+}
+
+// ServiceOptions defines options for creating a CMAB service
+type ServiceOptions struct {
+ Logger logging.OptimizelyLogProducer
+ CmabCache cache.CacheWithRemove
+ CmabClient Client
+}
+
+// NewDefaultCmabService creates a new instance of DefaultCmabService
+func NewDefaultCmabService(options ServiceOptions) *DefaultCmabService {
+ logger := options.Logger
+ if logger == nil {
+ logger = logging.GetLogger("", "DefaultCmabService")
+ }
+
+ return &DefaultCmabService{
+ cmabCache: options.CmabCache,
+ cmabClient: options.CmabClient,
+ logger: logger,
+ }
+}
+
+// GetDecision returns a CMAB decision for the given rule and user context
+func (s *DefaultCmabService) GetDecision(
+ projectConfig config.ProjectConfig,
+ userContext entities.UserContext,
+ ruleID string,
+ options *decide.Options,
+) (Decision, error) {
+ // Initialize reasons slice for decision
+ reasons := []string{}
+
+ // Filter attributes based on CMAB configuration
+ filteredAttributes := s.filterAttributes(projectConfig, userContext, ruleID)
+
+ // Check if we should ignore the cache
+ if options != nil && hasOption(options, decide.IgnoreCMABCache) {
+ reasons = append(reasons, "Ignoring CMAB cache as requested")
+ decision, err := s.fetchDecisionWithRetry(ruleID, userContext.ID, filteredAttributes)
+ if err != nil {
+ return Decision{Reasons: reasons}, err
+ }
+ decision.Reasons = append(reasons, decision.Reasons...)
+ return decision, nil
+ }
+
+ // Reset cache if requested
+ if options != nil && hasOption(options, decide.ResetCMABCache) {
+ s.cmabCache.Reset()
+ reasons = append(reasons, "Reset CMAB cache as requested")
+ }
+
+ // Create cache key
+ cacheKey := s.getCacheKey(userContext.ID, ruleID)
+
+ // Invalidate user cache if requested
+ if options != nil && hasOption(options, decide.InvalidateUserCMABCache) {
+ s.cmabCache.Remove(cacheKey)
+ reasons = append(reasons, "Invalidated user CMAB cache as requested")
+ }
+
+ // Generate attributes hash for cache validation
+ attributesJSON, err := s.getAttributesJSON(filteredAttributes)
+ if err != nil {
+ reasons = append(reasons, fmt.Sprintf("Failed to serialize attributes: %v", err))
+ return Decision{Reasons: reasons}, fmt.Errorf("failed to serialize attributes: %w", err)
+ }
+ hasher := murmur3.SeedNew32(1) // Use seed 1 for consistency
+ _, err = hasher.Write([]byte(attributesJSON))
+ if err != nil {
+ reasons = append(reasons, fmt.Sprintf("Failed to hash attributes: %v", err))
+ return Decision{Reasons: reasons}, fmt.Errorf("failed to hash attributes: %w", err)
+ }
+ attributesHash := strconv.FormatUint(uint64(hasher.Sum32()), 10)
+
+ // Try to get from cache
+ cachedValue := s.cmabCache.Lookup(cacheKey)
+ if cachedValue != nil {
+ // Need to type assert since Lookup returns interface{}
+ if cacheVal, ok := cachedValue.(CacheValue); ok {
+ // Check if attributes have changed
+ if cacheVal.AttributesHash == attributesHash {
+ s.logger.Debug(fmt.Sprintf("Returning cached CMAB decision for rule %s and user %s", ruleID, userContext.ID))
+ reasons = append(reasons, "Returning cached CMAB decision")
+ return Decision{
+ VariationID: cacheVal.VariationID,
+ CmabUUID: cacheVal.CmabUUID,
+ Reasons: reasons,
+ }, nil
+ }
+
+ // Attributes changed, remove from cache
+ s.cmabCache.Remove(cacheKey)
+ reasons = append(reasons, "Attributes changed, invalidating cache")
+ }
+ }
+
+ // Fetch new decision
+ decision, err := s.fetchDecisionWithRetry(ruleID, userContext.ID, filteredAttributes)
+ if err != nil {
+ decision.Reasons = append(reasons, decision.Reasons...)
+ return decision, fmt.Errorf("CMAB API error: %w", err)
+ }
+
+ // Cache the decision
+ cacheValue := CacheValue{
+ AttributesHash: attributesHash,
+ VariationID: decision.VariationID,
+ CmabUUID: decision.CmabUUID,
+ }
+
+ s.cmabCache.Save(cacheKey, cacheValue)
+ reasons = append(reasons, "Fetched new CMAB decision and cached it")
+ decision.Reasons = append(reasons, decision.Reasons...)
+
+ return decision, nil
+}
+
+// fetchDecisionWithRetry fetches a decision from the CMAB API with retry logic
+func (s *DefaultCmabService) fetchDecisionWithRetry(
+ ruleID string,
+ userID string,
+ attributes map[string]interface{},
+) (Decision, error) {
+ cmabUUID := uuid.New().String()
+ reasons := []string{}
+
+ // Retry configuration
+ maxRetries := 3
+ backoffFactor := 2
+ initialBackoff := 100 * time.Millisecond
+
+ var lastErr error
+
+ for attempt := 0; attempt < maxRetries; attempt++ {
+ // Exponential backoff if this is a retry
+ if attempt > 0 {
+ backoffDuration := initialBackoff * time.Duration(backoffFactor^attempt)
+ time.Sleep(backoffDuration)
+ reasons = append(reasons, fmt.Sprintf("Retry attempt %d/%d after backoff", attempt+1, maxRetries))
+ }
+
+ s.logger.Debug(fmt.Sprintf("Fetching CMAB decision for rule %s and user %s (attempt %d/%d)",
+ ruleID, userID, attempt+1, maxRetries))
+
+ variationID, err := s.cmabClient.FetchDecision(ruleID, userID, attributes, cmabUUID)
+ if err == nil {
+ reasons = append(reasons, fmt.Sprintf("Successfully fetched CMAB decision on attempt %d/%d", attempt+1, maxRetries))
+ return Decision{
+ VariationID: variationID,
+ CmabUUID: cmabUUID,
+ Reasons: reasons,
+ }, nil
+ }
+
+ lastErr = err
+ s.logger.Warning(fmt.Sprintf("CMAB API request failed (attempt %d/%d): %v",
+ attempt+1, maxRetries, err))
+ }
+
+ reasons = append(reasons, fmt.Sprintf("Failed to fetch CMAB decision after %d attempts", maxRetries))
+ return Decision{Reasons: reasons}, fmt.Errorf("failed to fetch CMAB decision after %d attempts: %w",
+ maxRetries, lastErr)
+}
+
+// filterAttributes filters user attributes based on CMAB configuration
+func (s *DefaultCmabService) filterAttributes(
+ projectConfig config.ProjectConfig,
+ userContext entities.UserContext,
+ ruleID string,
+) map[string]interface{} {
+ filteredAttributes := make(map[string]interface{})
+
+ // Get experiment by ID directly using the interface method
+ targetExperiment, err := projectConfig.GetExperimentByID(ruleID)
+ if err != nil || targetExperiment.Cmab == nil {
+ return filteredAttributes
+ }
+
+ // Get attribute IDs from CMAB configuration
+ cmabAttributeIDs := targetExperiment.Cmab.AttributeIds
+
+ // Filter attributes based on CMAB configuration
+ for _, attributeID := range cmabAttributeIDs {
+ // Get the attribute key for this ID
+ attributeKey, err := projectConfig.GetAttributeKeyByID(attributeID)
+ if err != nil {
+ s.logger.Debug(fmt.Sprintf("Attribute with ID %s not found in project config: %v", attributeID, err))
+ continue
+ }
+
+ if value, exists := userContext.Attributes[attributeKey]; exists {
+ filteredAttributes[attributeKey] = value
+ }
+ }
+
+ return filteredAttributes
+}
+
+// getAttributesJSON serializes attributes to a JSON string
+func (s *DefaultCmabService) getAttributesJSON(attributes map[string]interface{}) (string, error) {
+ // Serialize to JSON - json.Marshal already sorts map keys alphabetically
+ jsonBytes, err := json.Marshal(attributes)
+ if err != nil {
+ return "", err
+ }
+
+ return string(jsonBytes), nil
+}
+
+// getCacheKey generates a cache key for the user and rule
+func (s *DefaultCmabService) getCacheKey(userID, ruleID string) string {
+ // Include length of userID to avoid ambiguity when IDs contain the separator
+ return fmt.Sprintf("%d:%s:%s", len(userID), userID, ruleID)
+}
+
+// hasOption checks if a specific CMAB option is set
+func hasOption(options *decide.Options, option decide.OptimizelyDecideOptions) bool {
+ if options == nil {
+ return false
+ }
+
+ switch option {
+ case decide.IgnoreCMABCache:
+ return options.IgnoreCMABCache
+ case decide.ResetCMABCache:
+ return options.ResetCMABCache
+ case decide.InvalidateUserCMABCache:
+ return options.InvalidateUserCMABCache
+ default:
+ return false
+ }
+}
diff --git a/pkg/cmab/service_test.go b/pkg/cmab/service_test.go
new file mode 100644
index 000000000..db49eff9b
--- /dev/null
+++ b/pkg/cmab/service_test.go
@@ -0,0 +1,831 @@
+/****************************************************************************
+ * 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 cmab //
+package cmab
+
+import (
+ "errors"
+ "fmt"
+ "reflect"
+ "strconv"
+ "testing"
+
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/suite"
+ "github.com/twmb/murmur3"
+)
+
+// MockCmabClient is a mock implementation of CmabClient
+type MockCmabClient struct {
+ mock.Mock
+}
+
+func (m *MockCmabClient) FetchDecision(ruleID, userID string, attributes map[string]interface{}, cmabUUID string) (string, error) {
+ args := m.Called(ruleID, userID, attributes, cmabUUID)
+ return args.String(0), args.Error(1)
+}
+
+// MockCache is a mock implementation of cache.CacheWithRemove
+type MockCache struct {
+ mock.Mock
+}
+
+func (m *MockCache) Save(key string, value interface{}) {
+ m.Called(key, value)
+}
+
+func (m *MockCache) Lookup(key string) interface{} {
+ args := m.Called(key)
+ return args.Get(0)
+}
+
+func (m *MockCache) Reset() {
+ m.Called()
+}
+
+func (m *MockCache) Remove(key string) {
+ m.Called(key)
+}
+
+// MockProjectConfig is a mock implementation of config.ProjectConfig
+type MockProjectConfig struct {
+ mock.Mock
+}
+
+func (m *MockProjectConfig) GetProjectID() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetRevision() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetAccountID() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetAnonymizeIP() bool {
+ args := m.Called()
+ return args.Bool(0)
+}
+
+func (m *MockProjectConfig) GetAttributeID(key string) string {
+ args := m.Called(key)
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetAttributes() []entities.Attribute {
+ args := m.Called()
+ return args.Get(0).([]entities.Attribute)
+}
+
+func (m *MockProjectConfig) GetAttributeByKey(key string) (entities.Attribute, error) {
+ args := m.Called(key)
+ return args.Get(0).(entities.Attribute), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetAttributeKeyByID(id string) (string, error) {
+ args := m.Called(id)
+ return args.String(0), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetAudienceByID(id string) (entities.Audience, error) {
+ args := m.Called(id)
+ return args.Get(0).(entities.Audience), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetEventByKey(key string) (entities.Event, error) {
+ args := m.Called(key)
+ return args.Get(0).(entities.Event), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetEvents() []entities.Event {
+ args := m.Called()
+ return args.Get(0).([]entities.Event)
+}
+
+func (m *MockProjectConfig) GetFeatureByKey(featureKey string) (entities.Feature, error) {
+ args := m.Called(featureKey)
+ return args.Get(0).(entities.Feature), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetExperimentByKey(experimentKey string) (entities.Experiment, error) {
+ args := m.Called(experimentKey)
+ return args.Get(0).(entities.Experiment), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetExperimentByID(id string) (entities.Experiment, error) {
+ args := m.Called(id)
+ return args.Get(0).(entities.Experiment), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetExperimentList() []entities.Experiment {
+ args := m.Called()
+ return args.Get(0).([]entities.Experiment)
+}
+
+func (m *MockProjectConfig) GetPublicKeyForODP() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetHostForODP() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetSegmentList() []string {
+ args := m.Called()
+ return args.Get(0).([]string)
+}
+
+func (m *MockProjectConfig) GetBotFiltering() bool {
+ args := m.Called()
+ return args.Bool(0)
+}
+
+func (m *MockProjectConfig) GetSdkKey() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetEnvironmentKey() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetVariableByKey(featureKey, variableKey string) (entities.Variable, error) {
+ args := m.Called(featureKey, variableKey)
+ return args.Get(0).(entities.Variable), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetFeatureList() []entities.Feature {
+ args := m.Called()
+ return args.Get(0).([]entities.Feature)
+}
+
+func (m *MockProjectConfig) GetIntegrationList() []entities.Integration {
+ args := m.Called()
+ return args.Get(0).([]entities.Integration)
+}
+
+func (m *MockProjectConfig) GetRolloutList() []entities.Rollout {
+ args := m.Called()
+ return args.Get(0).([]entities.Rollout)
+}
+
+func (m *MockProjectConfig) GetAudienceList() []entities.Audience {
+ args := m.Called()
+ return args.Get(0).([]entities.Audience)
+}
+
+func (m *MockProjectConfig) GetAudienceMap() map[string]entities.Audience {
+ args := m.Called()
+ return args.Get(0).(map[string]entities.Audience)
+}
+
+func (m *MockProjectConfig) GetGroupByID(groupID string) (entities.Group, error) {
+ args := m.Called(groupID)
+ return args.Get(0).(entities.Group), args.Error(1)
+}
+
+func (m *MockProjectConfig) SendFlagDecisions() bool {
+ args := m.Called()
+ return args.Bool(0)
+}
+
+func (m *MockProjectConfig) GetFlagVariationsMap() map[string][]entities.Variation {
+ args := m.Called()
+ return args.Get(0).(map[string][]entities.Variation)
+}
+
+func (m *MockProjectConfig) GetDatafile() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+type CmabServiceTestSuite struct {
+ suite.Suite
+ mockClient *MockCmabClient
+ mockCache *MockCache
+ mockConfig *MockProjectConfig
+ cmabService *DefaultCmabService
+ testRuleID string
+ testUserID string
+ testAttributes map[string]interface{}
+}
+
+func (s *CmabServiceTestSuite) SetupTest() {
+ s.mockClient = new(MockCmabClient)
+ s.mockCache = new(MockCache)
+ s.mockConfig = new(MockProjectConfig)
+
+ // Set up the CMAB service
+ s.cmabService = NewDefaultCmabService(ServiceOptions{
+ Logger: logging.GetLogger("test", "CmabService"),
+ CmabCache: s.mockCache,
+ CmabClient: s.mockClient,
+ })
+
+ s.testRuleID = "rule-123"
+ s.testUserID = "user-456"
+ s.testAttributes = map[string]interface{}{
+ "age": 30,
+ "location": "San Francisco",
+ }
+}
+
+func (s *CmabServiceTestSuite) TestGetDecision() {
+ // Setup mock experiment with CMAB configuration
+ experiment := entities.Experiment{
+ ID: s.testRuleID,
+ Key: "test_experiment",
+ Cmab: &entities.Cmab{
+ AttributeIds: []string{"attr1", "attr2"},
+ },
+ TrafficAllocation: []entities.Range{
+ {
+ EntityID: "variation1",
+ EndOfRange: 10000,
+ },
+ },
+ }
+
+ // Setup mock config
+ s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil)
+ s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil)
+ s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil)
+
+ // Create user context
+ userContext := entities.UserContext{
+ ID: s.testUserID,
+ Attributes: s.testAttributes,
+ }
+
+ // Setup cache key
+ cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID)
+
+ // Setup cache lookup - return nil to simulate cache miss
+ s.mockCache.On("Lookup", cacheKey).Return(nil)
+
+ // Setup mock API response
+ expectedVariationID := "variant-1"
+ s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return(expectedVariationID, nil)
+
+ // Setup cache save
+ s.mockCache.On("Save", cacheKey, mock.Anything).Return()
+
+ // Test with no options
+ decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil)
+ s.NoError(err)
+ s.Equal(expectedVariationID, decision.VariationID)
+ s.NotEmpty(decision.CmabUUID)
+
+ // Verify expectations
+ s.mockConfig.AssertExpectations(s.T())
+ s.mockCache.AssertExpectations(s.T())
+ s.mockClient.AssertExpectations(s.T())
+}
+
+func (s *CmabServiceTestSuite) TestGetDecisionWithCache() {
+ // Setup mock experiment with CMAB configuration
+ experiment := entities.Experiment{
+ ID: s.testRuleID,
+ Key: "test_experiment",
+ Cmab: &entities.Cmab{
+ AttributeIds: []string{"attr1", "attr2"},
+ },
+ TrafficAllocation: []entities.Range{
+ {
+ EntityID: "variation1",
+ EndOfRange: 10000,
+ },
+ },
+ }
+
+ // Setup mock config
+ s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil)
+ s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil)
+ s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil).Maybe()
+ s.mockConfig.On("GetExperimentByKey", "test_experiment").Return(experiment, nil)
+
+ // Create user context
+ userContext := entities.UserContext{
+ ID: s.testUserID,
+ Attributes: s.testAttributes,
+ }
+
+ // Setup cache key
+ cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID)
+
+ // Calculate attributes hash using murmur3 as in your implementation
+ attributesJSON, _ := s.cmabService.getAttributesJSON(s.testAttributes)
+ hasher := murmur3.SeedNew32(1)
+ hasher.Write([]byte(attributesJSON))
+ attributesHash := strconv.FormatUint(uint64(hasher.Sum32()), 10)
+
+ // Setup cache hit with matching attributes hash
+ cachedValue := CacheValue{
+ AttributesHash: attributesHash,
+ VariationID: "cached-variant",
+ CmabUUID: "cached-uuid",
+ }
+ s.mockCache.On("Lookup", cacheKey).Return(cachedValue)
+
+ // Mock the Remove method - it might be called if attributes hash doesn't match
+ s.mockCache.On("Remove", cacheKey).Maybe()
+
+ // Test with cache hit
+ decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil)
+ s.NoError(err)
+ s.Equal("cached-variant", decision.VariationID)
+ s.Equal("cached-uuid", decision.CmabUUID)
+
+ // Verify API was not called
+ s.mockClient.AssertNotCalled(s.T(), "FetchDecision")
+}
+
+func (s *CmabServiceTestSuite) TestGetDecisionWithIgnoreCache() {
+ // Setup mock experiment with CMAB configuration
+ experiment := entities.Experiment{
+ ID: s.testRuleID,
+ Key: "test_experiment",
+ Cmab: &entities.Cmab{
+ AttributeIds: []string{"attr1", "attr2"},
+ },
+ TrafficAllocation: []entities.Range{
+ {
+ EntityID: "variation1",
+ EndOfRange: 10000,
+ },
+ },
+ }
+
+ // Setup mock config
+ s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil)
+ s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil)
+ s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil)
+ s.mockConfig.On("GetExperimentByKey", "test_experiment").Return(experiment, nil)
+
+ // Create user context
+ userContext := entities.UserContext{
+ ID: s.testUserID,
+ Attributes: s.testAttributes,
+ }
+
+ // Setup cache key
+ cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID)
+
+ // Setup mock API response
+ expectedVariationID := "variant-1"
+ s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return(expectedVariationID, nil)
+
+ // Test with IgnoreCMABCache option
+ options := &decide.Options{
+ IgnoreCMABCache: true,
+ }
+
+ decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, options)
+ s.NoError(err)
+ s.Equal(expectedVariationID, decision.VariationID)
+
+ // Verify API was called (cache was ignored)
+ s.mockClient.AssertCalled(s.T(), "FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything)
+
+ // Verify cache lookup was not called
+ s.mockCache.AssertNotCalled(s.T(), "Lookup", cacheKey)
+
+ // Verify cache save was not called
+ s.mockCache.AssertNotCalled(s.T(), "Save", cacheKey, mock.Anything)
+}
+
+func (s *CmabServiceTestSuite) TestGetDecisionWithResetCache() {
+ // Setup mock experiment with CMAB configuration
+ experiment := entities.Experiment{
+ ID: s.testRuleID,
+ Key: "test_experiment",
+ Cmab: &entities.Cmab{
+ AttributeIds: []string{"attr1", "attr2"},
+ },
+ TrafficAllocation: []entities.Range{
+ {
+ EntityID: "variation1",
+ EndOfRange: 10000,
+ },
+ },
+ }
+
+ // Setup mock config
+ s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil)
+ s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil)
+ s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil)
+ s.mockConfig.On("GetExperimentByKey", "test_experiment").Return(experiment, nil)
+
+ // Create user context
+ userContext := entities.UserContext{
+ ID: s.testUserID,
+ Attributes: s.testAttributes,
+ }
+
+ // Setup cache reset
+ s.mockCache.On("Reset").Return()
+
+ // Setup cache key
+ cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID)
+
+ // Setup cache lookup after reset
+ s.mockCache.On("Lookup", cacheKey).Return(nil)
+
+ // Setup mock API response
+ expectedVariationID := "variant-1"
+ s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return(expectedVariationID, nil)
+
+ // Setup cache save
+ s.mockCache.On("Save", cacheKey, mock.Anything).Return()
+
+ // Test with ResetCMABCache option
+ options := &decide.Options{
+ ResetCMABCache: true,
+ }
+
+ decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, options)
+ s.NoError(err)
+ s.Equal(expectedVariationID, decision.VariationID)
+
+ // Verify cache was reset
+ s.mockCache.AssertCalled(s.T(), "Reset")
+}
+
+func (s *CmabServiceTestSuite) TestGetDecisionWithInvalidateUserCache() {
+ // Setup mock experiment with CMAB configuration
+ experiment := entities.Experiment{
+ ID: s.testRuleID,
+ Key: "test_experiment",
+ Cmab: &entities.Cmab{
+ AttributeIds: []string{"attr1", "attr2"},
+ },
+ TrafficAllocation: []entities.Range{
+ {
+ EntityID: "variation1",
+ EndOfRange: 10000,
+ },
+ },
+ }
+
+ // Setup mock config
+ s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil)
+ s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil)
+ s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil)
+ s.mockConfig.On("GetExperimentByKey", "test_experiment").Return(experiment, nil)
+
+ // Create user context
+ userContext := entities.UserContext{
+ ID: s.testUserID,
+ Attributes: s.testAttributes,
+ }
+
+ // Setup cache key
+ cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID)
+
+ // Setup cache remove
+ s.mockCache.On("Remove", cacheKey).Return()
+
+ // Setup cache lookup after remove
+ s.mockCache.On("Lookup", cacheKey).Return(nil)
+
+ // Setup mock API response
+ expectedVariationID := "variant-1"
+ s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return(expectedVariationID, nil)
+
+ // Setup cache save
+ s.mockCache.On("Save", cacheKey, mock.Anything).Return()
+
+ // Test with InvalidateUserCMABCache option
+ options := &decide.Options{
+ InvalidateUserCMABCache: true,
+ }
+
+ decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, options)
+ s.NoError(err)
+ s.Equal(expectedVariationID, decision.VariationID)
+
+ // Verify user cache was invalidated
+ s.mockCache.AssertCalled(s.T(), "Remove", cacheKey)
+}
+
+func (s *CmabServiceTestSuite) TestGetDecisionError() {
+ // Setup mock experiment with CMAB configuration
+ experiment := entities.Experiment{
+ ID: s.testRuleID,
+ Key: "test_experiment", // Add experiment key
+ // Other experiment properties...
+ TrafficAllocation: []entities.Range{ // Add traffic allocation
+ {
+ EntityID: "variation1",
+ EndOfRange: 10000,
+ },
+ },
+ }
+
+ // Setup mock config
+ s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil)
+ s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil)
+ s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil)
+ s.mockConfig.On("GetExperimentByKey", "test_experiment").Return(experiment, nil)
+
+ // Create user context
+ userContext := entities.UserContext{
+ ID: s.testUserID,
+ Attributes: s.testAttributes,
+ }
+
+ // Setup cache key
+ cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID)
+
+ // Setup cache miss
+ s.mockCache.On("Lookup", cacheKey).Return(nil)
+
+ // Setup mock API error
+ expectedError := errors.New("API error")
+ s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return("", expectedError)
+
+ // Test error handling
+ decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil)
+ s.Error(err)
+ s.Equal("", decision.VariationID) // Should be empty
+}
+
+func (s *CmabServiceTestSuite) TestFilterAttributes() {
+ // Setup mock experiment with CMAB configuration
+ experiment := entities.Experiment{
+ ID: s.testRuleID,
+ Cmab: &entities.Cmab{
+ AttributeIds: []string{"attr1", "attr2", "attr3"},
+ },
+ }
+
+ // Setup mock config
+ s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil)
+ s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil)
+ s.mockConfig.On("GetAttributeKeyByID", "attr3").Return("", errors.New("attribute not found"))
+ s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil)
+
+ // Create user context with extra attributes that should be filtered out
+ userContext := entities.UserContext{
+ ID: s.testUserID,
+ Attributes: map[string]interface{}{
+ "age": 30,
+ "location": "San Francisco",
+ "extra_key": "should be filtered out",
+ },
+ }
+
+ // Call filterAttributes directly
+ filteredAttrs := s.cmabService.filterAttributes(s.mockConfig, userContext, s.testRuleID)
+
+ // Verify only the configured attributes are included
+ s.Equal(2, len(filteredAttrs))
+ s.Equal(30, filteredAttrs["age"])
+ s.Equal("San Francisco", filteredAttrs["location"])
+ s.NotContains(filteredAttrs, "extra_key")
+}
+
+func (s *CmabServiceTestSuite) TestOnlyFilteredAttributesPassedToClient() {
+ // Setup mock experiment with CMAB configuration
+ experiment := entities.Experiment{
+ ID: s.testRuleID,
+ Key: "test_experiment",
+ Cmab: &entities.Cmab{
+ AttributeIds: []string{"attr1", "attr2"},
+ },
+ TrafficAllocation: []entities.Range{
+ {
+ EntityID: "variation1",
+ EndOfRange: 10000,
+ },
+ },
+ }
+
+ // Setup mock config
+ s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil)
+ s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil)
+ s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil)
+ s.mockConfig.On("GetExperimentByKey", "test_experiment").Return(experiment, nil)
+
+ // Create user context with extra attributes that should be filtered out
+ userContext := entities.UserContext{
+ ID: s.testUserID,
+ Attributes: map[string]interface{}{
+ "age": 30,
+ "location": "San Francisco",
+ "extra_key": "should be filtered out",
+ },
+ }
+
+ // Expected filtered attributes
+ expectedFilteredAttrs := map[string]interface{}{
+ "age": 30,
+ "location": "San Francisco",
+ }
+
+ // Setup cache key
+ cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID)
+
+ // Setup cache lookup
+ s.mockCache.On("Lookup", cacheKey).Return(nil)
+
+ // Setup mock API response with attribute verification
+ expectedVariationID := "variant-1"
+ s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.MatchedBy(func(attrs map[string]interface{}) bool {
+ // Verify only the filtered attributes are passed
+ if len(attrs) != 2 {
+ return false
+ }
+ if attrs["age"] != 30 {
+ return false
+ }
+ if attrs["location"] != "San Francisco" {
+ return false
+ }
+ if _, exists := attrs["extra_key"]; exists {
+ return false
+ }
+ return true
+ }), mock.Anything).Return(expectedVariationID, nil)
+
+ // Setup cache save
+ s.mockCache.On("Save", cacheKey, mock.Anything).Return()
+
+ // Call GetDecision
+ decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil)
+ s.NoError(err)
+ s.Equal(expectedVariationID, decision.VariationID)
+
+ // Verify client was called with the filtered attributes
+ s.mockClient.AssertCalled(s.T(), "FetchDecision", s.testRuleID, s.testUserID, mock.MatchedBy(func(attrs map[string]interface{}) bool {
+ return reflect.DeepEqual(attrs, expectedFilteredAttrs)
+ }), mock.Anything)
+}
+
+func (s *CmabServiceTestSuite) TestCacheInvalidatedWhenAttributesChange() {
+ // Setup mock experiment with CMAB configuration
+ experiment := entities.Experiment{
+ ID: s.testRuleID,
+ Key: "test_experiment", // Add experiment key
+ // Other experiment properties...
+ TrafficAllocation: []entities.Range{ // Add traffic allocation
+ {
+ EntityID: "variation1",
+ EndOfRange: 10000,
+ },
+ },
+ }
+
+ // Setup mock config
+ s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil)
+ s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil)
+ s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil)
+ s.mockConfig.On("GetExperimentByKey", "test_experiment").Return(experiment, nil)
+
+ // Create user context
+ userContext := entities.UserContext{
+ ID: s.testUserID,
+ Attributes: map[string]interface{}{
+ "age": 30,
+ "location": "San Francisco",
+ },
+ }
+
+ // Setup cache key
+ cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID)
+
+ // First, create a cached value with a different attributes hash
+ oldAttributesHash := "old-hash"
+ cachedValue := CacheValue{
+ AttributesHash: oldAttributesHash,
+ VariationID: "cached-variant",
+ CmabUUID: "cached-uuid",
+ }
+
+ // Setup cache lookup to return the cached value
+ s.mockCache.On("Lookup", cacheKey).Return(cachedValue)
+
+ // Setup cache remove (should be called when attributes change)
+ s.mockCache.On("Remove", cacheKey).Return()
+
+ // Setup mock API response (should be called when attributes change)
+ expectedVariationID := "new-variant"
+ s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return(expectedVariationID, nil)
+
+ // Setup cache save for the new decision
+ s.mockCache.On("Save", cacheKey, mock.Anything).Return()
+
+ // Call GetDecision
+ decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil)
+ s.NoError(err)
+ s.Equal(expectedVariationID, decision.VariationID)
+
+ // Verify cache was looked up
+ s.mockCache.AssertCalled(s.T(), "Lookup", cacheKey)
+
+ // Verify cache entry was removed due to attribute change
+ s.mockCache.AssertCalled(s.T(), "Remove", cacheKey)
+
+ // Verify API was called to get a new decision
+ s.mockClient.AssertCalled(s.T(), "FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything)
+
+ // Verify new decision was cached
+ s.mockCache.AssertCalled(s.T(), "Save", cacheKey, mock.MatchedBy(func(value CacheValue) bool {
+ return value.VariationID == expectedVariationID && value.AttributesHash != oldAttributesHash
+ }))
+}
+
+func (s *CmabServiceTestSuite) TestGetAttributesJSON() {
+ // Test with empty attributes
+ emptyJSON, err := s.cmabService.getAttributesJSON(map[string]interface{}{})
+ s.NoError(err)
+ s.Equal("{}", emptyJSON)
+
+ // Test with attributes
+ attributes := map[string]interface{}{
+ "c": 3,
+ "a": 1,
+ "b": 2,
+ }
+ json, err := s.cmabService.getAttributesJSON(attributes)
+ s.NoError(err)
+ // Keys should be sorted alphabetically
+ s.Equal(`{"a":1,"b":2,"c":3}`, json)
+}
+
+func (s *CmabServiceTestSuite) TestGetCacheKey() {
+ // Update the expected format to match the new implementation
+ expected := fmt.Sprintf("%d:%s:%s", len("user123"), "user123", "rule456")
+ actual := s.cmabService.getCacheKey("user123", "rule456")
+ s.Equal(expected, actual)
+}
+
+func (s *CmabServiceTestSuite) TestNewDefaultCmabService() {
+ // Test with default options
+ service := NewDefaultCmabService(ServiceOptions{})
+
+ // Only check that the service is created, not the specific fields
+ s.NotNil(service)
+}
+
+func TestCmabServiceTestSuite(t *testing.T) {
+ suite.Run(t, new(CmabServiceTestSuite))
+}
+
+func (s *CmabServiceTestSuite) TestGetDecisionApiError() {
+ // Setup cache key
+ cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID)
+
+ // Setup cache lookup (cache miss)
+ s.mockCache.On("Lookup", cacheKey).Return(nil)
+
+ // Setup mock to return error for experiment lookup (but this won't stop the flow anymore)
+ s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(entities.Experiment{}, fmt.Errorf("experiment not found")).Once()
+
+ // Mock the FetchDecision call that will now happen
+ s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return("", fmt.Errorf("invalid rule ID"))
+
+ // Call the method
+ userContext := entities.UserContext{
+ ID: s.testUserID,
+ Attributes: map[string]interface{}{
+ "age": 30,
+ },
+ }
+
+ _, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil)
+
+ // Should return error from FetchDecision, not from experiment validation
+ s.Error(err)
+ s.Contains(err.Error(), "CMAB API error")
+
+ // Verify expectations
+ s.mockConfig.AssertExpectations(s.T())
+ s.mockCache.AssertExpectations(s.T())
+ s.mockClient.AssertExpectations(s.T())
+}
diff --git a/pkg/cmab/types.go b/pkg/cmab/types.go
new file mode 100644
index 000000000..66cab15aa
--- /dev/null
+++ b/pkg/cmab/types.go
@@ -0,0 +1,62 @@
+/****************************************************************************
+ * 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 cmab provides functionality for Contextual Multi-Armed Bandit (CMAB)
+// decision-making, including client and service implementations for making and
+// handling CMAB requests and responses.
+package cmab
+
+import (
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+)
+
+// Decision represents a decision from the CMAB service
+type Decision struct {
+ VariationID string
+ CmabUUID string
+ Reasons []string
+}
+
+// CacheValue represents a cached CMAB decision with attribute hash
+type CacheValue struct {
+ AttributesHash string
+ VariationID string
+ CmabUUID string
+}
+
+// Service defines the interface for CMAB decision services
+type Service interface {
+ // GetDecision returns a CMAB decision for the given rule and user context
+ GetDecision(
+ projectConfig config.ProjectConfig,
+ userContext entities.UserContext,
+ ruleID string,
+ options *decide.Options,
+ ) (Decision, error)
+}
+
+// Client defines the interface for CMAB API clients
+type Client interface {
+ // FetchDecision fetches a decision from the CMAB API
+ FetchDecision(
+ ruleID string,
+ userID string,
+ attributes map[string]interface{},
+ cmabUUID string,
+ ) (string, error)
+}
diff --git a/pkg/config/datafileprojectconfig/config.go b/pkg/config/datafileprojectconfig/config.go
index 925422b3b..c55fda8cb 100644
--- a/pkg/config/datafileprojectconfig/config.go
+++ b/pkg/config/datafileprojectconfig/config.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2022, Optimizely, Inc. and contributors *
+ * Copyright 2019-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. *
@@ -21,9 +21,9 @@ import (
"errors"
"fmt"
- "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/mappers"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/mappers"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
var datafileVersions = map[string]struct{}{
@@ -41,8 +41,10 @@ type DatafileProjectConfig struct {
experimentKeyToIDMap map[string]string
audienceMap map[string]entities.Audience
attributeMap map[string]entities.Attribute
+ attributeKeyMap map[string]entities.Attribute
eventMap map[string]entities.Event
attributeKeyToIDMap map[string]string
+ attributeIDToKeyMap map[string]string
experimentMap map[string]entities.Experiment
featureMap map[string]entities.Feature
groupMap map[string]entities.Group
@@ -107,6 +109,24 @@ func (c DatafileProjectConfig) GetAttributeID(key string) string {
return c.attributeKeyToIDMap[key]
}
+// GetAttributeByKey returns the attribute with the given key
+func (c DatafileProjectConfig) GetAttributeByKey(key string) (entities.Attribute, error) {
+ if attribute, ok := c.attributeKeyMap[key]; ok {
+ return attribute, nil
+ }
+
+ return entities.Attribute{}, fmt.Errorf(`attribute with key "%s" not found`, key)
+}
+
+// GetAttributeKeyByID returns the attribute key for the given ID
+func (c DatafileProjectConfig) GetAttributeKeyByID(id string) (string, error) {
+ if key, ok := c.attributeIDToKeyMap[id]; ok {
+ return key, nil
+ }
+
+ return "", fmt.Errorf(`attribute with ID "%s" not found`, id)
+}
+
// GetBotFiltering returns botFiltering
func (c DatafileProjectConfig) GetBotFiltering() bool {
return c.botFiltering
@@ -163,17 +183,6 @@ func (c DatafileProjectConfig) GetVariableByKey(featureKey, variableKey string)
return variable, err
}
-// GetAttributeByKey returns the attribute with the given key
-func (c DatafileProjectConfig) GetAttributeByKey(key string) (entities.Attribute, error) {
- if attributeID, ok := c.attributeKeyToIDMap[key]; ok {
- if attribute, ok := c.attributeMap[attributeID]; ok {
- return attribute, nil
- }
- }
-
- return entities.Attribute{}, fmt.Errorf(`attribute with key "%s" not found`, key)
-}
-
// GetFeatureList returns an array of all the features
func (c DatafileProjectConfig) GetFeatureList() (featureList []entities.Feature) {
for _, feature := range c.featureMap {
@@ -238,6 +247,15 @@ func (c DatafileProjectConfig) GetExperimentByKey(experimentKey string) (entitie
return entities.Experiment{}, fmt.Errorf(`experiment with key "%s" not found`, experimentKey)
}
+// GetExperimentByID returns the experiment with the given ID
+func (c DatafileProjectConfig) GetExperimentByID(experimentID string) (entities.Experiment, error) {
+ if experiment, ok := c.experimentMap[experimentID]; ok {
+ return experiment, nil
+ }
+
+ return entities.Experiment{}, fmt.Errorf(`experiment with ID "%s" not found`, experimentID)
+}
+
// GetGroupByID returns the group with the given ID
func (c DatafileProjectConfig) GetGroupByID(groupID string) (entities.Group, error) {
if group, ok := c.groupMap[groupID]; ok {
@@ -300,6 +318,14 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP
audienceMap, audienceSegmentList := mappers.MapAudiences(append(datafile.TypedAudiences, datafile.Audiences...))
flagVariationsMap := mappers.MapFlagVariations(featureMap)
+ attributeKeyMap := make(map[string]entities.Attribute)
+ attributeIDToKeyMap := make(map[string]string)
+
+ for id, attribute := range attributeMap {
+ attributeIDToKeyMap[id] = attribute.Key
+ attributeKeyMap[attribute.Key] = attribute
+ }
+
config := &DatafileProjectConfig{
hostForODP: hostForODP,
publicKeyForODP: publicKeyForODP,
@@ -325,6 +351,8 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP
rolloutMap: rolloutMap,
sendFlagDecisions: datafile.SendFlagDecisions,
flagVariationsMap: flagVariationsMap,
+ attributeKeyMap: attributeKeyMap,
+ attributeIDToKeyMap: attributeIDToKeyMap,
}
logger.Info("Datafile is valid.")
diff --git a/pkg/config/datafileprojectconfig/config_test.go b/pkg/config/datafileprojectconfig/config_test.go
index 475fc0d59..ec14fbcbc 100644
--- a/pkg/config/datafileprojectconfig/config_test.go
+++ b/pkg/config/datafileprojectconfig/config_test.go
@@ -24,8 +24,8 @@ import (
"path/filepath"
"testing"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
"github.com/stretchr/testify/assert"
)
@@ -316,18 +316,24 @@ func TestGetVariableByKeyWithMissingVariableError(t *testing.T) {
}
func TestGetAttributeByKey(t *testing.T) {
- id := "id"
key := "key"
- attributeKeyToIDMap := make(map[string]string)
- attributeKeyToIDMap[key] = id
-
attribute := entities.Attribute{
Key: key,
}
+
+ // The old and new mappings to ensure backward compatibility
+ attributeKeyMap := make(map[string]entities.Attribute)
+ attributeKeyMap[key] = attribute
+
+ id := "id"
+ attributeKeyToIDMap := make(map[string]string)
+ attributeKeyToIDMap[key] = id
+
attributeMap := make(map[string]entities.Attribute)
attributeMap[id] = attribute
config := &DatafileProjectConfig{
+ attributeKeyMap: attributeKeyMap,
attributeKeyToIDMap: attributeKeyToIDMap,
attributeMap: attributeMap,
}
@@ -568,3 +574,157 @@ func TestGetFlagVariationsMap(t *testing.T) {
assert.NotNil(t, flagVariationsMap["feature_3"])
assert.Len(t, flagVariationsMap["feature_3"], 0)
}
+
+func TestCmabExperiments(t *testing.T) {
+ // Load the decide-test-datafile.json
+ absPath, _ := filepath.Abs("../../../test-data/decide-test-datafile.json")
+ datafile, err := os.ReadFile(absPath)
+ assert.NoError(t, err)
+
+ // Parse the datafile to modify it
+ var datafileJSON map[string]interface{}
+ err = json.Unmarshal(datafile, &datafileJSON)
+ assert.NoError(t, err)
+
+ // Add CMAB to the first experiment with traffic allocation as an integer
+ experiments := datafileJSON["experiments"].([]interface{})
+ exp0 := experiments[0].(map[string]interface{})
+ exp0["cmab"] = map[string]interface{}{
+ "attributeIds": []string{"808797688", "808797689"},
+ "trafficAllocation": 5000, // Changed from array to integer
+ }
+
+ // Convert back to JSON
+ modifiedDatafile, err := json.Marshal(datafileJSON)
+ assert.NoError(t, err)
+
+ // Create project config from modified datafile
+ config, err := NewDatafileProjectConfig(modifiedDatafile, logging.GetLogger("", "DatafileProjectConfig"))
+ assert.NoError(t, err)
+
+ // Get the experiment key from the datafile
+ exp0Key := exp0["key"].(string)
+
+ // Test that Cmab fields are correctly mapped for experiment 0
+ experiment0, err := config.GetExperimentByKey(exp0Key)
+ assert.NoError(t, err)
+ assert.NotNil(t, experiment0.Cmab)
+ if experiment0.Cmab != nil {
+ // Test attribute IDs
+ assert.Equal(t, 2, len(experiment0.Cmab.AttributeIds))
+ assert.Contains(t, experiment0.Cmab.AttributeIds, "808797688")
+ assert.Contains(t, experiment0.Cmab.AttributeIds, "808797689")
+
+ // Test traffic allocation as integer
+ assert.Equal(t, 5000, experiment0.Cmab.TrafficAllocation)
+ }
+}
+
+func TestCmabExperimentsNil(t *testing.T) {
+ // Load the decide-test-datafile.json (which doesn't have CMAB by default)
+ absPath, _ := filepath.Abs("../../../test-data/decide-test-datafile.json")
+ datafile, err := os.ReadFile(absPath)
+ assert.NoError(t, err)
+
+ // Create project config from the original datafile
+ config, err := NewDatafileProjectConfig(datafile, logging.GetLogger("", "DatafileProjectConfig"))
+ assert.NoError(t, err)
+
+ // Parse the datafile to get experiment keys
+ var datafileJSON map[string]interface{}
+ err = json.Unmarshal(datafile, &datafileJSON)
+ assert.NoError(t, err)
+
+ experiments := datafileJSON["experiments"].([]interface{})
+ exp0 := experiments[0].(map[string]interface{})
+ exp0Key := exp0["key"].(string)
+
+ // Test that Cmab field is nil for experiment 0
+ experiment0, err := config.GetExperimentByKey(exp0Key)
+ assert.NoError(t, err)
+ assert.Nil(t, experiment0.Cmab, "CMAB field should be nil when not present in datafile")
+
+ // Test another experiment if available
+ if len(experiments) > 1 {
+ exp1 := experiments[1].(map[string]interface{})
+ exp1Key := exp1["key"].(string)
+
+ experiment1, err := config.GetExperimentByKey(exp1Key)
+ assert.NoError(t, err)
+ assert.Nil(t, experiment1.Cmab, "CMAB field should be nil when not present in datafile")
+ }
+}
+
+func TestGetExperimentByID(t *testing.T) {
+ // Create a test config with some experiments
+ testConfig := DatafileProjectConfig{
+ experimentMap: map[string]entities.Experiment{
+ "exp1": {ID: "exp1", Key: "experiment_1"},
+ "exp2": {ID: "exp2", Key: "experiment_2"},
+ },
+ }
+
+ // Test getting an experiment that exists
+ experiment, err := testConfig.GetExperimentByID("exp1")
+ assert.NoError(t, err)
+ assert.Equal(t, "exp1", experiment.ID)
+ assert.Equal(t, "experiment_1", experiment.Key)
+
+ // Test getting another experiment that exists
+ experiment, err = testConfig.GetExperimentByID("exp2")
+ assert.NoError(t, err)
+ assert.Equal(t, "exp2", experiment.ID)
+ assert.Equal(t, "experiment_2", experiment.Key)
+
+ // Test getting an experiment that doesn't exist
+ experiment, err = testConfig.GetExperimentByID("non_existent")
+ assert.Error(t, err)
+ assert.Equal(t, `experiment with ID "non_existent" not found`, err.Error())
+ assert.Equal(t, entities.Experiment{}, experiment)
+}
+
+func TestGetAttributeKeyByID(t *testing.T) {
+ // Setup
+ id := "id"
+ key := "key"
+ attributeIDToKeyMap := make(map[string]string)
+ attributeIDToKeyMap[id] = key
+
+ config := &DatafileProjectConfig{
+ attributeIDToKeyMap: attributeIDToKeyMap,
+ }
+
+ // Test successful case
+ actual, err := config.GetAttributeKeyByID(id)
+ assert.Nil(t, err)
+ assert.Equal(t, key, actual)
+}
+
+func TestGetAttributeKeyByIDWithMissingIDError(t *testing.T) {
+ // Setup
+ config := &DatafileProjectConfig{}
+
+ // Test error case
+ _, err := config.GetAttributeKeyByID("id")
+ if assert.Error(t, err) {
+ assert.Equal(t, fmt.Errorf(`attribute with ID "id" not found`), err)
+ }
+}
+
+func TestGetAttributeByKeyWithDirectMapping(t *testing.T) {
+ key := "key"
+ attribute := entities.Attribute{
+ Key: key,
+ }
+
+ attributeKeyMap := make(map[string]entities.Attribute)
+ attributeKeyMap[key] = attribute
+
+ config := &DatafileProjectConfig{
+ attributeKeyMap: attributeKeyMap,
+ }
+
+ actual, err := config.GetAttributeByKey(key)
+ assert.Nil(t, err)
+ assert.Equal(t, attribute, actual)
+}
diff --git a/pkg/config/datafileprojectconfig/entities/entities.go b/pkg/config/datafileprojectconfig/entities/entities.go
index 5ae123571..cecd0b023 100644
--- a/pkg/config/datafileprojectconfig/entities/entities.go
+++ b/pkg/config/datafileprojectconfig/entities/entities.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019,2021-2022, Optimizely, Inc. and contributors *
+ * Copyright 2019,2021-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. *
@@ -17,7 +17,7 @@
// Package entities has entity definitions
package entities
-import "github.com/optimizely/go-sdk/pkg/entities"
+import "github.com/optimizely/go-sdk/v2/pkg/entities"
// Audience represents an Audience object from the Optimizely datafile
type Audience struct {
@@ -32,6 +32,14 @@ type Attribute struct {
Key string `json:"key"`
}
+// Cmab represents the Contextual Multi-Armed Bandit configuration for an experiment.
+// It contains a list of attribute IDs that are used for the CMAB algorithm and
+// traffic allocation settings for the CMAB implementation.
+type Cmab struct {
+ AttributeIds []string `json:"attributeIds"`
+ TrafficAllocation int `json:"trafficAllocation"`
+}
+
// Experiment represents an Experiment object from the Optimizely datafile
type Experiment struct {
ID string `json:"id"`
@@ -43,6 +51,7 @@ type Experiment struct {
AudienceIds []string `json:"audienceIds"`
ForcedVariations map[string]string `json:"forcedVariations"`
AudienceConditions interface{} `json:"audienceConditions"`
+ Cmab *Cmab `json:"cmab,omitempty"` // is optional
}
// Group represents an Group object from the Optimizely datafile
diff --git a/pkg/config/datafileprojectconfig/json_parser.go b/pkg/config/datafileprojectconfig/json_parser.go
index 1df3e33c0..1bfa14892 100644
--- a/pkg/config/datafileprojectconfig/json_parser.go
+++ b/pkg/config/datafileprojectconfig/json_parser.go
@@ -18,9 +18,9 @@
package datafileprojectconfig
import (
- "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
- "github.com/json-iterator/go"
+ jsoniter "github.com/json-iterator/go"
)
var json = jsoniter.ConfigCompatibleWithStandardLibrary
diff --git a/pkg/config/datafileprojectconfig/json_parser_test.go b/pkg/config/datafileprojectconfig/json_parser_test.go
index d27e96776..eeff1dd5c 100644
--- a/pkg/config/datafileprojectconfig/json_parser_test.go
+++ b/pkg/config/datafileprojectconfig/json_parser_test.go
@@ -21,7 +21,7 @@ import (
"os"
"testing"
- "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
"github.com/stretchr/testify/assert"
)
diff --git a/pkg/config/datafileprojectconfig/mappers/attribute.go b/pkg/config/datafileprojectconfig/mappers/attribute.go
index 915bd4ec4..3d0ad15bd 100644
--- a/pkg/config/datafileprojectconfig/mappers/attribute.go
+++ b/pkg/config/datafileprojectconfig/mappers/attribute.go
@@ -18,8 +18,8 @@
package mappers
import (
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// MapAttributes maps the raw datafile attribute entities to SDK Attribute entities
diff --git a/pkg/config/datafileprojectconfig/mappers/attribute_test.go b/pkg/config/datafileprojectconfig/mappers/attribute_test.go
index 4eebc4b45..3cbcfebfc 100644
--- a/pkg/config/datafileprojectconfig/mappers/attribute_test.go
+++ b/pkg/config/datafileprojectconfig/mappers/attribute_test.go
@@ -19,8 +19,8 @@ package mappers
import (
"testing"
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/stretchr/testify/assert"
)
diff --git a/pkg/config/datafileprojectconfig/mappers/audience.go b/pkg/config/datafileprojectconfig/mappers/audience.go
index 2a3eda2d1..7d638de01 100644
--- a/pkg/config/datafileprojectconfig/mappers/audience.go
+++ b/pkg/config/datafileprojectconfig/mappers/audience.go
@@ -18,8 +18,8 @@
package mappers
import (
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// MapAudiences maps the raw datafile audience entities to SDK Audience entities
diff --git a/pkg/config/datafileprojectconfig/mappers/audience_test.go b/pkg/config/datafileprojectconfig/mappers/audience_test.go
index 51e2882da..53bde88c8 100644
--- a/pkg/config/datafileprojectconfig/mappers/audience_test.go
+++ b/pkg/config/datafileprojectconfig/mappers/audience_test.go
@@ -20,8 +20,8 @@ package mappers
import (
"testing"
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/stretchr/testify/assert"
)
diff --git a/pkg/config/datafileprojectconfig/mappers/condition_trees.go b/pkg/config/datafileprojectconfig/mappers/condition_trees.go
index c22df0c3e..8d56a86ca 100644
--- a/pkg/config/datafileprojectconfig/mappers/condition_trees.go
+++ b/pkg/config/datafileprojectconfig/mappers/condition_trees.go
@@ -22,8 +22,8 @@ import (
"reflect"
jsoniter "github.com/json-iterator/go"
- "github.com/optimizely/go-sdk/pkg/decision/evaluator/matchers"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/evaluator/matchers"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
var errEmptyTree = errors.New("empty tree")
@@ -44,7 +44,7 @@ func buildConditionTree(conditions interface{}) (conditionTree *entities.TreeNod
parsedConditions, retErr := parseConditions(conditions)
if retErr != nil {
- return
+ return nil, nil, retErr
}
odpSegments = []string{}
value := reflect.ValueOf(parsedConditions)
@@ -101,7 +101,7 @@ func buildConditionTree(conditions interface{}) (conditionTree *entities.TreeNod
n := &entities.TreeNode{}
if err := createLeafCondition(v, n); err != nil {
retErr = err
- return
+ return nil, nil, retErr
}
// Extract odp segment from leaf node if applicable
extractSegment(&odpSegments, n)
diff --git a/pkg/config/datafileprojectconfig/mappers/condition_trees_test.go b/pkg/config/datafileprojectconfig/mappers/condition_trees_test.go
index 9bd18ca48..6e9323086 100644
--- a/pkg/config/datafileprojectconfig/mappers/condition_trees_test.go
+++ b/pkg/config/datafileprojectconfig/mappers/condition_trees_test.go
@@ -19,8 +19,8 @@ package mappers
import (
"testing"
- datafileConfig "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileConfig "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/stretchr/testify/assert"
)
diff --git a/pkg/config/datafileprojectconfig/mappers/events.go b/pkg/config/datafileprojectconfig/mappers/events.go
index 63606ec2e..53ddbac70 100644
--- a/pkg/config/datafileprojectconfig/mappers/events.go
+++ b/pkg/config/datafileprojectconfig/mappers/events.go
@@ -18,8 +18,8 @@
package mappers
import (
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// MapEvents maps the raw datafile event entities to SDK Event entities
diff --git a/pkg/config/datafileprojectconfig/mappers/events_test.go b/pkg/config/datafileprojectconfig/mappers/events_test.go
index 62f5788b4..075f71e59 100644
--- a/pkg/config/datafileprojectconfig/mappers/events_test.go
+++ b/pkg/config/datafileprojectconfig/mappers/events_test.go
@@ -19,8 +19,8 @@ package mappers
import (
"testing"
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/stretchr/testify/assert"
)
diff --git a/pkg/config/datafileprojectconfig/mappers/experiment.go b/pkg/config/datafileprojectconfig/mappers/experiment.go
index e8efd8353..5f6e3bf60 100644
--- a/pkg/config/datafileprojectconfig/mappers/experiment.go
+++ b/pkg/config/datafileprojectconfig/mappers/experiment.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019,2021, Optimizely, Inc. and contributors *
+ * Copyright 2019,2021-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. *
@@ -18,8 +18,8 @@
package mappers
import (
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// MapExperiments maps the raw experiments entities from the datafile to SDK Experiment entities and also returns a map of experiment key to experiment ID
@@ -91,6 +91,7 @@ func mapExperiment(rawExperiment datafileEntities.Experiment) entities.Experimen
AudienceConditionTree: audienceConditionTree,
Whitelist: rawExperiment.ForcedVariations,
IsFeatureExperiment: false,
+ Cmab: mapCmab(rawExperiment.Cmab),
}
for _, variation := range rawExperiment.Variations {
@@ -113,3 +114,15 @@ func MergeExperiments(rawExperiments []datafileEntities.Experiment, rawGroups []
}
return mergedExperiments
}
+
+func mapCmab(rawCmab *datafileEntities.Cmab) *entities.Cmab {
+ // handle nil case because cmab is optional and can be nil
+ if rawCmab == nil {
+ return nil
+ }
+
+ return &entities.Cmab{
+ AttributeIds: rawCmab.AttributeIds,
+ TrafficAllocation: rawCmab.TrafficAllocation,
+ }
+}
diff --git a/pkg/config/datafileprojectconfig/mappers/experiment_test.go b/pkg/config/datafileprojectconfig/mappers/experiment_test.go
index 421c6a96c..fd3a58997 100644
--- a/pkg/config/datafileprojectconfig/mappers/experiment_test.go
+++ b/pkg/config/datafileprojectconfig/mappers/experiment_test.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019,2021, Optimizely, Inc. and contributors *
+ * Copyright 2019,2021-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. *
@@ -20,8 +20,8 @@ import (
"testing"
jsoniter "github.com/json-iterator/go"
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/stretchr/testify/assert"
)
@@ -236,3 +236,92 @@ func TestMapExperimentsAudienceIdsOnly(t *testing.T) {
experimentsIDMap, _ := MapExperiments([]datafileEntities.Experiment{rawExperiment}, map[string]string{})
assert.Equal(t, expectedExperiment.AudienceConditionTree, experimentsIDMap[rawExperiment.ID].AudienceConditionTree)
}
+
+func TestMapCmab(t *testing.T) {
+ tests := []struct {
+ name string
+ input *datafileEntities.Cmab
+ expected *entities.Cmab
+ }{
+ {
+ name: "nil input",
+ input: nil,
+ expected: nil,
+ },
+ {
+ name: "with attributes only",
+ input: &datafileEntities.Cmab{
+ AttributeIds: []string{"attr1", "attr2"},
+ TrafficAllocation: 0, // Changed from empty array to 0
+ },
+ expected: &entities.Cmab{
+ AttributeIds: []string{"attr1", "attr2"},
+ TrafficAllocation: 0, // Changed from empty array to 0
+ },
+ },
+ {
+ name: "with traffic allocation",
+ input: &datafileEntities.Cmab{
+ AttributeIds: []string{},
+ TrafficAllocation: 5000, // Changed from array to int
+ },
+ expected: &entities.Cmab{
+ AttributeIds: []string{},
+ TrafficAllocation: 5000, // Changed from array to int
+ },
+ },
+ {
+ name: "with both attributes and traffic allocation",
+ input: &datafileEntities.Cmab{
+ AttributeIds: []string{"attr1", "attr2"},
+ TrafficAllocation: 10000, // Changed from array to int
+ },
+ expected: &entities.Cmab{
+ AttributeIds: []string{"attr1", "attr2"},
+ TrafficAllocation: 10000, // Changed from array to int
+ },
+ },
+ }
+
+ // Run tests
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := mapCmab(tt.input)
+
+ if tt.expected == nil {
+ assert.Nil(t, result)
+ } else {
+ assert.NotNil(t, result)
+ assert.Equal(t, tt.expected.AttributeIds, result.AttributeIds)
+ assert.Equal(t, tt.expected.TrafficAllocation, result.TrafficAllocation) // Simplified assertion
+ }
+ })
+ }
+}
+
+func TestMapExperimentWithCmab(t *testing.T) {
+ // Create a raw experiment with CMAB configuration
+ rawExperiment := datafileEntities.Experiment{
+ ID: "exp1",
+ Key: "experiment_1",
+ LayerID: "layer1",
+ Variations: []datafileEntities.Variation{
+ {ID: "var1", Key: "variation_1"},
+ },
+ TrafficAllocation: []datafileEntities.TrafficAllocation{
+ {EntityID: "var1", EndOfRange: 10000},
+ },
+ Cmab: &datafileEntities.Cmab{
+ AttributeIds: []string{"attr1", "attr2"},
+ TrafficAllocation: 5000, // Changed from array to int
+ },
+ }
+
+ // Map the experiment
+ experiment := mapExperiment(rawExperiment)
+
+ // Verify CMAB mapping
+ assert.NotNil(t, experiment.Cmab)
+ assert.Equal(t, []string{"attr1", "attr2"}, experiment.Cmab.AttributeIds)
+ assert.Equal(t, 5000, experiment.Cmab.TrafficAllocation) // Simplified assertion
+}
diff --git a/pkg/config/datafileprojectconfig/mappers/feature.go b/pkg/config/datafileprojectconfig/mappers/feature.go
index b7d1d3bf5..5514857c1 100644
--- a/pkg/config/datafileprojectconfig/mappers/feature.go
+++ b/pkg/config/datafileprojectconfig/mappers/feature.go
@@ -18,8 +18,8 @@
package mappers
import (
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// MapFeatures maps the raw datafile feature flag entities to SDK Feature entities
diff --git a/pkg/config/datafileprojectconfig/mappers/feature_test.go b/pkg/config/datafileprojectconfig/mappers/feature_test.go
index d02eb9341..b5dc54ba8 100644
--- a/pkg/config/datafileprojectconfig/mappers/feature_test.go
+++ b/pkg/config/datafileprojectconfig/mappers/feature_test.go
@@ -20,8 +20,8 @@ import (
"testing"
jsoniter "github.com/json-iterator/go"
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/stretchr/testify/assert"
)
diff --git a/pkg/config/datafileprojectconfig/mappers/forced_decision.go b/pkg/config/datafileprojectconfig/mappers/forced_decision.go
index e7fe0eeb4..164f510cf 100644
--- a/pkg/config/datafileprojectconfig/mappers/forced_decision.go
+++ b/pkg/config/datafileprojectconfig/mappers/forced_decision.go
@@ -18,7 +18,7 @@
package mappers
import (
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// MapFlagVariations all variations for each flag
diff --git a/pkg/config/datafileprojectconfig/mappers/group.go b/pkg/config/datafileprojectconfig/mappers/group.go
index ab9949470..dfa5ef2bd 100644
--- a/pkg/config/datafileprojectconfig/mappers/group.go
+++ b/pkg/config/datafileprojectconfig/mappers/group.go
@@ -18,8 +18,8 @@
package mappers
import (
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// MapGroups maps the raw group entity from the datafile to an SDK Group entity
diff --git a/pkg/config/datafileprojectconfig/mappers/group_test.go b/pkg/config/datafileprojectconfig/mappers/group_test.go
index 6fed98fdb..4695cb340 100644
--- a/pkg/config/datafileprojectconfig/mappers/group_test.go
+++ b/pkg/config/datafileprojectconfig/mappers/group_test.go
@@ -19,8 +19,8 @@ package mappers
import (
"testing"
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/stretchr/testify/assert"
)
diff --git a/pkg/config/datafileprojectconfig/mappers/rollout.go b/pkg/config/datafileprojectconfig/mappers/rollout.go
index 4e5f110d5..786e87c7d 100644
--- a/pkg/config/datafileprojectconfig/mappers/rollout.go
+++ b/pkg/config/datafileprojectconfig/mappers/rollout.go
@@ -18,8 +18,8 @@
package mappers
import (
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// MapRollouts maps the raw datafile rollout entities to SDK Rollout entities
diff --git a/pkg/config/datafileprojectconfig/mappers/rollout_test.go b/pkg/config/datafileprojectconfig/mappers/rollout_test.go
index 373dcb274..c4f29f119 100644
--- a/pkg/config/datafileprojectconfig/mappers/rollout_test.go
+++ b/pkg/config/datafileprojectconfig/mappers/rollout_test.go
@@ -19,8 +19,8 @@ package mappers
import (
"testing"
- datafileEntities "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/entities"
- "github.com/optimizely/go-sdk/pkg/entities"
+ datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/stretchr/testify/assert"
)
diff --git a/pkg/config/interface.go b/pkg/config/interface.go
index 3e74078c2..1c9f4736a 100644
--- a/pkg/config/interface.go
+++ b/pkg/config/interface.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2022, Optimizely, Inc. and contributors *
+ * Copyright 2019-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. *
@@ -18,8 +18,8 @@
package config
import (
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
)
// ProjectConfig represents the project's experiments and feature flags and contains methods for accessing the them.
@@ -31,6 +31,8 @@ type ProjectConfig interface {
GetAnonymizeIP() bool
GetAttributeID(id string) string // returns "" if there is no id
GetAttributeByKey(key string) (entities.Attribute, error)
+ GetAttributeKeyByID(id string) (string, error) // method is intended for internal use only
+ GetExperimentByID(id string) (entities.Experiment, error) // method is intended for internal use only
GetAudienceList() (audienceList []entities.Audience)
GetAudienceByID(string) (entities.Audience, error)
GetAudienceMap() map[string]entities.Audience
diff --git a/pkg/config/optimizely_config.go b/pkg/config/optimizely_config.go
index 691b26fb1..289e90e69 100644
--- a/pkg/config/optimizely_config.go
+++ b/pkg/config/optimizely_config.go
@@ -22,8 +22,8 @@ import (
"fmt"
"strings"
- "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig/mappers"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/mappers"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// OptimizelyConfig is a snapshot of the experiments and features in the project config
diff --git a/pkg/config/polling_manager.go b/pkg/config/polling_manager.go
index 27fe227d8..654bba4e9 100644
--- a/pkg/config/polling_manager.go
+++ b/pkg/config/polling_manager.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2020,2022 Optimizely, Inc. and contributors *
+ * Copyright 2019-2020,2022-2023 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. *
@@ -24,11 +24,11 @@ import (
"sync"
"time"
- "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/notification"
- "github.com/optimizely/go-sdk/pkg/registry"
- "github.com/optimizely/go-sdk/pkg/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/registry"
+ "github.com/optimizely/go-sdk/v2/pkg/utils"
"github.com/pkg/errors"
)
@@ -130,7 +130,7 @@ func (cm *PollingProjectConfigManager) SyncConfig() {
if e != nil {
msg := "unable to fetch fresh datafile"
- cm.logger.Warning(msg)
+ cm.logger.Error(msg, e)
cm.configLock.Lock()
if code == http.StatusForbidden {
@@ -156,7 +156,7 @@ func (cm *PollingProjectConfigManager) SyncConfig() {
projectConfig, err := datafileprojectconfig.NewDatafileProjectConfig(datafile, logging.GetLogger(cm.sdkKey, "NewDatafileProjectConfig"))
if err != nil {
- cm.logger.Warning("failed to create project config")
+ cm.logger.Error("failed to create project config", err)
closeMutex(errors.New("unable to parse datafile"))
return
}
@@ -184,6 +184,9 @@ func (cm *PollingProjectConfigManager) Start(ctx context.Context) {
cm.logger.Info("Polling Config Manager Disabled")
return
}
+ if cm.pollingInterval < 30*time.Second {
+ cm.logger.Warning("Polling intervals below 30 seconds are not recommended.")
+ }
cm.logger.Debug("Polling Config Manager Initiated")
t := time.NewTicker(cm.pollingInterval)
for {
@@ -191,6 +194,7 @@ func (cm *PollingProjectConfigManager) Start(ctx context.Context) {
case <-t.C:
cm.SyncConfig()
case <-ctx.Done():
+ t.Stop()
cm.logger.Debug("Polling Config Manager Stopped")
return
}
diff --git a/pkg/config/polling_manager_test.go b/pkg/config/polling_manager_test.go
index 8e2b4a900..96673ed88 100644
--- a/pkg/config/polling_manager_test.go
+++ b/pkg/config/polling_manager_test.go
@@ -23,10 +23,10 @@ import (
"testing"
"time"
- "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/notification"
- "github.com/optimizely/go-sdk/pkg/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
diff --git a/pkg/config/static_manager.go b/pkg/config/static_manager.go
index 582e0968e..3540b44b7 100644
--- a/pkg/config/static_manager.go
+++ b/pkg/config/static_manager.go
@@ -22,10 +22,10 @@ import (
"fmt"
"sync"
- "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/notification"
- "github.com/optimizely/go-sdk/pkg/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/utils"
)
// StaticProjectConfigManager maintains a static copy of the project config
diff --git a/pkg/config/static_manager_test.go b/pkg/config/static_manager_test.go
index 569ad0c84..b318f3867 100644
--- a/pkg/config/static_manager_test.go
+++ b/pkg/config/static_manager_test.go
@@ -20,9 +20,9 @@ import (
"errors"
"testing"
- "github.com/optimizely/go-sdk/pkg/config/datafileprojectconfig"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
"github.com/stretchr/testify/assert"
)
diff --git a/pkg/decide/decide_options.go b/pkg/decide/decide_options.go
index cd50189dd..8d3bc0d35 100644
--- a/pkg/decide/decide_options.go
+++ b/pkg/decide/decide_options.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2020-2021, Optimizely, Inc. and contributors *
+ * Copyright 2020-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. *
@@ -33,6 +33,12 @@ const (
IncludeReasons OptimizelyDecideOptions = "INCLUDE_REASONS"
// ExcludeVariables when set, excludes variable values from the decision result.
ExcludeVariables OptimizelyDecideOptions = "EXCLUDE_VARIABLES"
+ // IgnoreCMABCache instructs the SDK to ignore the CMAB cache and make a fresh request
+ IgnoreCMABCache OptimizelyDecideOptions = "IGNORE_CMAB_CACHE"
+ // ResetCMABCache instructs the SDK to reset the entire CMAB cache
+ ResetCMABCache OptimizelyDecideOptions = "RESET_CMAB_CACHE"
+ // InvalidateUserCMABCache instructs the SDK to invalidate CMAB cache entries for the current user
+ InvalidateUserCMABCache OptimizelyDecideOptions = "INVALIDATE_USER_CMAB_CACHE"
)
// Options defines options for controlling flag decisions.
@@ -42,6 +48,9 @@ type Options struct {
IgnoreUserProfileService bool
IncludeReasons bool
ExcludeVariables bool
+ IgnoreCMABCache bool
+ ResetCMABCache bool
+ InvalidateUserCMABCache bool
}
// TranslateOptions converts string options array to array of OptimizelyDecideOptions
@@ -59,6 +68,12 @@ func TranslateOptions(options []string) ([]OptimizelyDecideOptions, error) {
decideOptions = append(decideOptions, ExcludeVariables)
case IncludeReasons:
decideOptions = append(decideOptions, IncludeReasons)
+ case IgnoreCMABCache:
+ decideOptions = append(decideOptions, IgnoreCMABCache)
+ case ResetCMABCache:
+ decideOptions = append(decideOptions, ResetCMABCache)
+ case InvalidateUserCMABCache:
+ decideOptions = append(decideOptions, InvalidateUserCMABCache)
default:
return []OptimizelyDecideOptions{}, errors.New("invalid option: " + val)
}
diff --git a/pkg/decide/decide_options_test.go b/pkg/decide/decide_options_test.go
index aebfca029..097074e22 100644
--- a/pkg/decide/decide_options_test.go
+++ b/pkg/decide/decide_options_test.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2021, Optimizely, Inc. and contributors *
+ * Copyright 2021-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. *
@@ -68,3 +68,47 @@ func TestTranslateOptionsInvalidCases(t *testing.T) {
assert.Equal(t, fmt.Errorf("invalid option: %v", options[0]), err)
assert.Len(t, translatedOptions, 0)
}
+
+// TestTranslateOptionsCMABOptions tests the new CMAB-related options
+func TestTranslateOptionsCMABOptions(t *testing.T) {
+ // Test IGNORE_CMAB_CACHE option
+ options := []string{"IGNORE_CMAB_CACHE"}
+ translatedOptions, err := TranslateOptions(options)
+ assert.NoError(t, err)
+ assert.Len(t, translatedOptions, 1)
+ assert.Equal(t, IgnoreCMABCache, translatedOptions[0])
+
+ // Test RESET_CMAB_CACHE option
+ options = []string{"RESET_CMAB_CACHE"}
+ translatedOptions, err = TranslateOptions(options)
+ assert.NoError(t, err)
+ assert.Len(t, translatedOptions, 1)
+ assert.Equal(t, ResetCMABCache, translatedOptions[0])
+
+ // Test INVALIDATE_USER_CMAB_CACHE option
+ options = []string{"INVALIDATE_USER_CMAB_CACHE"}
+ translatedOptions, err = TranslateOptions(options)
+ assert.NoError(t, err)
+ assert.Len(t, translatedOptions, 1)
+ assert.Equal(t, InvalidateUserCMABCache, translatedOptions[0])
+
+ // Test all CMAB options together
+ options = []string{"IGNORE_CMAB_CACHE", "RESET_CMAB_CACHE", "INVALIDATE_USER_CMAB_CACHE"}
+ translatedOptions, err = TranslateOptions(options)
+ assert.NoError(t, err)
+ assert.Len(t, translatedOptions, 3)
+ assert.Equal(t, IgnoreCMABCache, translatedOptions[0])
+ assert.Equal(t, ResetCMABCache, translatedOptions[1])
+ assert.Equal(t, InvalidateUserCMABCache, translatedOptions[2])
+
+ // Test CMAB options with other options
+ options = []string{"DISABLE_DECISION_EVENT", "IGNORE_CMAB_CACHE", "ENABLED_FLAGS_ONLY", "RESET_CMAB_CACHE", "INVALIDATE_USER_CMAB_CACHE"}
+ translatedOptions, err = TranslateOptions(options)
+ assert.NoError(t, err)
+ assert.Len(t, translatedOptions, 5)
+ assert.Equal(t, DisableDecisionEvent, translatedOptions[0])
+ assert.Equal(t, IgnoreCMABCache, translatedOptions[1])
+ assert.Equal(t, EnabledFlagsOnly, translatedOptions[2])
+ assert.Equal(t, ResetCMABCache, translatedOptions[3])
+ assert.Equal(t, InvalidateUserCMABCache, translatedOptions[4])
+}
diff --git a/pkg/decision/bucketer/experiment_bucketer.go b/pkg/decision/bucketer/experiment_bucketer.go
index 13c17f426..716685be5 100644
--- a/pkg/decision/bucketer/experiment_bucketer.go
+++ b/pkg/decision/bucketer/experiment_bucketer.go
@@ -18,14 +18,16 @@
package bucketer
import (
- "github.com/optimizely/go-sdk/pkg/decision/reasons"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// ExperimentBucketer is used to bucket the user into a particular entity in the experiment's traffic alloc range
type ExperimentBucketer interface {
Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (*entities.Variation, reasons.Reason, error)
+ // New method for CMAB - returns entity ID instead of variation
+ BucketToEntityID(bucketingID string, experiment entities.Experiment, group entities.Group) (string, reasons.Reason, error)
}
// MurmurhashExperimentBucketer buckets the user using the mmh3 algorightm
@@ -33,6 +35,27 @@ type MurmurhashExperimentBucketer struct {
bucketer Bucketer
}
+// BucketToEntityID buckets the user and returns the entity ID (for CMAB experiments)
+func (b MurmurhashExperimentBucketer) BucketToEntityID(bucketingID string, experiment entities.Experiment, group entities.Group) (string, reasons.Reason, error) {
+ if experiment.GroupID != "" && group.Policy == "random" {
+ bucketKey := bucketingID + group.ID
+ bucketedExperimentID := b.bucketer.BucketToEntity(bucketKey, group.TrafficAllocation)
+ if bucketedExperimentID == "" || bucketedExperimentID != experiment.ID {
+ // User is not bucketed into provided experiment in mutex group
+ return "", reasons.NotBucketedIntoVariation, nil
+ }
+ }
+
+ bucketKey := bucketingID + experiment.ID
+ bucketedEntityID := b.bucketer.BucketToEntity(bucketKey, experiment.TrafficAllocation)
+ if bucketedEntityID == "" {
+ // User is not bucketed into any entity in the experiment
+ return "", reasons.NotBucketedIntoVariation, nil
+ }
+
+ return bucketedEntityID, reasons.BucketedIntoVariation, nil
+}
+
// NewMurmurhashExperimentBucketer returns a new instance of the murmurhash experiment bucketer
func NewMurmurhashExperimentBucketer(logger logging.OptimizelyLogProducer, hashSeed uint32) *MurmurhashExperimentBucketer {
return &MurmurhashExperimentBucketer{
diff --git a/pkg/decision/bucketer/experiment_bucketer_test.go b/pkg/decision/bucketer/experiment_bucketer_test.go
index 8ec61b880..91c3f2a6a 100644
--- a/pkg/decision/bucketer/experiment_bucketer_test.go
+++ b/pkg/decision/bucketer/experiment_bucketer_test.go
@@ -1,12 +1,13 @@
package bucketer
import (
- "github.com/optimizely/go-sdk/pkg/logging"
"testing"
- "github.com/optimizely/go-sdk/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/stretchr/testify/assert"
)
diff --git a/pkg/decision/bucketer/murmurhashbucketer.go b/pkg/decision/bucketer/murmurhashbucketer.go
index 879b00e14..c2667ff38 100644
--- a/pkg/decision/bucketer/murmurhashbucketer.go
+++ b/pkg/decision/bucketer/murmurhashbucketer.go
@@ -21,8 +21,8 @@ import (
"fmt"
"math"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
"github.com/twmb/murmur3"
)
diff --git a/pkg/decision/bucketer/murmurhashbucketer_test.go b/pkg/decision/bucketer/murmurhashbucketer_test.go
index 3db47c552..273aaa792 100644
--- a/pkg/decision/bucketer/murmurhashbucketer_test.go
+++ b/pkg/decision/bucketer/murmurhashbucketer_test.go
@@ -2,10 +2,11 @@ package bucketer
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/logging"
"testing"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/stretchr/testify/assert"
)
diff --git a/pkg/decision/composite_experiment_service.go b/pkg/decision/composite_experiment_service.go
index 3f33a9f1b..e8054bb6c 100644
--- a/pkg/decision/composite_experiment_service.go
+++ b/pkg/decision/composite_experiment_service.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2020, Optimizely, Inc. and contributors *
+ * Copyright 2019-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. *
@@ -18,9 +18,9 @@
package decision
import (
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// CESOptionFunc is used to assign optional configuration options
@@ -48,26 +48,38 @@ type CompositeExperimentService struct {
logger logging.OptimizelyLogProducer
}
-// NewCompositeExperimentService creates a new instance of the CompositeExperimentService
+// NewCompositeExperimentService creates a new composite experiment service with the given SDK key.
+// It initializes a service that combines multiple decision services in a specific order:
+// 1. Overrides (if supplied)
+// 2. Whitelist
+// 3. CMAB (Contextual Multi-Armed Bandit)
+// 4. Bucketing (with User profile integration if supplied)
+// Additional options can be provided via CESOptionFunc parameters.
func NewCompositeExperimentService(sdkKey string, options ...CESOptionFunc) *CompositeExperimentService {
// These decision services are applied in order:
// 1. Overrides (if supplied)
// 2. Whitelist
- // 3. Bucketing (with User profile integration if supplied)
+ // 3. CMAB (always created)
+ // 4. Bucketing (with User profile integration if supplied)
compositeExperimentService := &CompositeExperimentService{logger: logging.GetLogger(sdkKey, "CompositeExperimentService")}
+
for _, opt := range options {
opt(compositeExperimentService)
}
+
experimentServices := []ExperimentService{
- NewExperimentWhitelistService(),
+ NewExperimentWhitelistService(), // No logger argument
}
- // Prepend overrides if supplied
if compositeExperimentService.overrideStore != nil {
overrideService := NewExperimentOverrideService(compositeExperimentService.overrideStore, logging.GetLogger(sdkKey, "ExperimentOverrideService"))
experimentServices = append([]ExperimentService{overrideService}, experimentServices...)
}
+ // Create CMAB service with all initialization handled internally
+ experimentCmabService := NewExperimentCmabService(sdkKey)
+ experimentServices = append(experimentServices, experimentCmabService)
+
experimentBucketerService := NewExperimentBucketerService(logging.GetLogger(sdkKey, "ExperimentBucketerService"))
if compositeExperimentService.userProfileService != nil {
persistingExperimentService := NewPersistingExperimentService(compositeExperimentService.userProfileService, experimentBucketerService, logging.GetLogger(sdkKey, "PersistingExperimentService"))
@@ -75,26 +87,35 @@ func NewCompositeExperimentService(sdkKey string, options ...CESOptionFunc) *Com
} else {
experimentServices = append(experimentServices, experimentBucketerService)
}
- compositeExperimentService.experimentServices = experimentServices
+ compositeExperimentService.experimentServices = experimentServices
return compositeExperimentService
}
-// GetDecision returns a decision for the given experiment and user context
-func (s CompositeExperimentService) GetDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext, options *decide.Options) (decision ExperimentDecision, reasons decide.DecisionReasons, err error) {
- // Run through the various decision services until we get a decision
- reasons = decide.NewDecisionReasons(options)
+// GetDecision attempts to get an experiment decision by trying each registered service until one returns a variation or error
+func (s *CompositeExperimentService) GetDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext, options *decide.Options) (ExperimentDecision, decide.DecisionReasons, error) {
+ var experDecision ExperimentDecision
+ reasons := decide.NewDecisionReasons(options)
+
for _, experimentService := range s.experimentServices {
- var decisionReasons decide.DecisionReasons
- decision, decisionReasons, err = experimentService.GetDecision(decisionContext, userContext, options)
- reasons.Append(decisionReasons)
+ var serviceReasons decide.DecisionReasons
+
+ decision, serviceReasons, err := experimentService.GetDecision(decisionContext, userContext, options)
+ reasons.Append(serviceReasons)
+
+ // If there's an actual error (not just "no decision"), stop and return it
if err != nil {
- s.logger.Debug(err.Error())
+ return decision, reasons, err // RETURN ERROR - don't continue!
}
- if decision.Variation != nil && err == nil {
- return decision, reasons, err
+
+ // If we got a valid decision (has a variation), return it
+ if decision.Variation != nil {
+ return decision, reasons, nil
}
+
+ // No error and no decision - continue to next service
}
- return decision, reasons, err
+ // No service could make a decision
+ return experDecision, reasons, nil // No error, just no decision
}
diff --git a/pkg/decision/composite_experiment_service_test.go b/pkg/decision/composite_experiment_service_test.go
index 38704c16f..bf524a965 100644
--- a/pkg/decision/composite_experiment_service_test.go
+++ b/pkg/decision/composite_experiment_service_test.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2020, Optimizely, Inc. and contributors *
+ * Copyright 2019-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. *
@@ -20,11 +20,12 @@ import (
"errors"
"testing"
+ "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
type CompositeExperimentTestSuite struct {
@@ -32,6 +33,7 @@ type CompositeExperimentTestSuite struct {
mockConfig *mockProjectConfig
mockExperimentService *MockExperimentDecisionService
mockExperimentService2 *MockExperimentDecisionService
+ mockCmabService *MockExperimentDecisionService
testDecisionContext ExperimentDecisionContext
options *decide.Options
reasons decide.DecisionReasons
@@ -41,6 +43,7 @@ func (s *CompositeExperimentTestSuite) SetupTest() {
s.mockConfig = new(mockProjectConfig)
s.mockExperimentService = new(MockExperimentDecisionService)
s.mockExperimentService2 = new(MockExperimentDecisionService)
+ s.mockCmabService = new(MockExperimentDecisionService)
s.options = &decide.Options{}
s.reasons = decide.NewDecisionReasons(s.options)
@@ -64,15 +67,15 @@ func (s *CompositeExperimentTestSuite) TestGetDecision() {
s.mockExperimentService.On("GetDecision", s.testDecisionContext, testUserContext, s.options).Return(expectedExperimentDecision, s.reasons, nil)
compositeExperimentService := &CompositeExperimentService{
- experimentServices: []ExperimentService{s.mockExperimentService, s.mockExperimentService2},
+ experimentServices: []ExperimentService{s.mockExperimentService, s.mockCmabService, s.mockExperimentService2},
logger: logging.GetLogger("sdkKey", "ExperimentService"),
}
decision, _, err := compositeExperimentService.GetDecision(s.testDecisionContext, testUserContext, s.options)
s.Equal(expectedExperimentDecision, decision)
s.NoError(err)
s.mockExperimentService.AssertExpectations(s.T())
+ s.mockCmabService.AssertNotCalled(s.T(), "GetDecision")
s.mockExperimentService2.AssertNotCalled(s.T(), "GetDecision")
-
}
func (s *CompositeExperimentTestSuite) TestGetDecisionFallthrough() {
@@ -82,8 +85,9 @@ func (s *CompositeExperimentTestSuite) TestGetDecisionFallthrough() {
}
expectedVariation := testExp1111.Variations["2222"]
- expectedExperimentDecision := ExperimentDecision{}
- s.mockExperimentService.On("GetDecision", s.testDecisionContext, testUserContext, s.options).Return(expectedExperimentDecision, s.reasons, nil)
+ emptyExperimentDecision := ExperimentDecision{}
+ s.mockExperimentService.On("GetDecision", s.testDecisionContext, testUserContext, s.options).Return(emptyExperimentDecision, s.reasons, nil)
+ s.mockCmabService.On("GetDecision", s.testDecisionContext, testUserContext, s.options).Return(emptyExperimentDecision, s.reasons, nil)
expectedExperimentDecision2 := ExperimentDecision{
Variation: &expectedVariation,
@@ -91,7 +95,7 @@ func (s *CompositeExperimentTestSuite) TestGetDecisionFallthrough() {
s.mockExperimentService2.On("GetDecision", s.testDecisionContext, testUserContext, s.options).Return(expectedExperimentDecision2, s.reasons, nil)
compositeExperimentService := &CompositeExperimentService{
- experimentServices: []ExperimentService{s.mockExperimentService, s.mockExperimentService2},
+ experimentServices: []ExperimentService{s.mockExperimentService, s.mockCmabService, s.mockExperimentService2},
logger: logging.GetLogger("sdkKey", "CompositeExperimentService"),
}
decision, _, err := compositeExperimentService.GetDecision(s.testDecisionContext, testUserContext, s.options)
@@ -99,6 +103,7 @@ func (s *CompositeExperimentTestSuite) TestGetDecisionFallthrough() {
s.NoError(err)
s.Equal(expectedExperimentDecision2, decision)
s.mockExperimentService.AssertExpectations(s.T())
+ s.mockCmabService.AssertExpectations(s.T())
s.mockExperimentService2.AssertExpectations(s.T())
}
@@ -107,26 +112,82 @@ func (s *CompositeExperimentTestSuite) TestGetDecisionNoDecisionsMade() {
testUserContext := entities.UserContext{
ID: "test_user_1",
}
- expectedExperimentDecision := ExperimentDecision{}
- s.mockExperimentService.On("GetDecision", s.testDecisionContext, testUserContext, s.options).Return(expectedExperimentDecision, s.reasons, nil)
-
- expectedExperimentDecision2 := ExperimentDecision{}
- s.mockExperimentService2.On("GetDecision", s.testDecisionContext, testUserContext, s.options).Return(expectedExperimentDecision2, s.reasons, nil)
+ emptyExperimentDecision := ExperimentDecision{}
+ s.mockExperimentService.On("GetDecision", s.testDecisionContext, testUserContext, s.options).Return(emptyExperimentDecision, s.reasons, nil)
+ s.mockCmabService.On("GetDecision", s.testDecisionContext, testUserContext, s.options).Return(emptyExperimentDecision, s.reasons, nil)
+ s.mockExperimentService2.On("GetDecision", s.testDecisionContext, testUserContext, s.options).Return(emptyExperimentDecision, s.reasons, nil)
compositeExperimentService := &CompositeExperimentService{
- experimentServices: []ExperimentService{s.mockExperimentService, s.mockExperimentService2},
+ experimentServices: []ExperimentService{s.mockExperimentService, s.mockCmabService, s.mockExperimentService2},
logger: logging.GetLogger("sdkKey", "CompositeExperimentService"),
}
decision, _, err := compositeExperimentService.GetDecision(s.testDecisionContext, testUserContext, s.options)
s.NoError(err)
- s.Equal(expectedExperimentDecision2, decision)
+ s.Equal(emptyExperimentDecision, decision)
s.mockExperimentService.AssertExpectations(s.T())
+ s.mockCmabService.AssertExpectations(s.T())
s.mockExperimentService2.AssertExpectations(s.T())
}
-func (s *CompositeExperimentTestSuite) TestGetDecisionReturnsError() {
- // Assert that we continue to the next inner service when an inner service GetDecision returns an error
+func (suite *CompositeExperimentTestSuite) TestGetDecisionReturnsError() {
+ testUserContext := entities.UserContext{ID: "test_user_1"}
+ testDecisionContext := ExperimentDecisionContext{
+ Experiment: &testExp1111,
+ ProjectConfig: suite.mockConfig,
+ }
+
+ // Use the same variation pattern as other tests
+ expectedVariation := testExp1111.Variations["2226"] // Use 2226 like the error shows
+ expectedDecision := ExperimentDecision{
+ Decision: Decision{
+ Reason: "",
+ },
+ Variation: &expectedVariation,
+ }
+
+ // Mock FIRST service to return error - should stop here and return error
+ suite.mockExperimentService.On("GetDecision", testDecisionContext, testUserContext, &decide.Options{}).
+ Return(expectedDecision, suite.reasons, errors.New("Error making decision")).Once()
+
+ // Create composite service using the same pattern as other tests
+ compositeExperimentService := &CompositeExperimentService{
+ experimentServices: []ExperimentService{suite.mockExperimentService, suite.mockExperimentService2},
+ logger: logging.GetLogger("sdkKey", "CompositeExperimentService"),
+ }
+
+ actualDecision, _, err := compositeExperimentService.GetDecision(testDecisionContext, testUserContext, &decide.Options{})
+
+ // Should return the error immediately
+ suite.Error(err)
+ suite.Equal("Error making decision", err.Error())
+ suite.Equal(expectedDecision, actualDecision)
+
+ // Verify only first service was called
+ suite.mockExperimentService.AssertExpectations(suite.T())
+ // Second service should NOT have been called
+ suite.mockExperimentService2.AssertNotCalled(suite.T(), "GetDecision")
+}
+
+func (s *CompositeExperimentTestSuite) TestGetDecisionCmabError() {
+ // Create a custom implementation of CompositeExperimentService.GetDecision that doesn't check the type
+ customGetDecision := func(decisionContext ExperimentDecisionContext, userContext entities.UserContext, options *decide.Options) (ExperimentDecision, decide.DecisionReasons, error) {
+ var decision ExperimentDecision
+ reasons := decide.NewDecisionReasons(options)
+
+ // First service returns empty decision, no error
+ decision, serviceReasons, _ := s.mockExperimentService.GetDecision(decisionContext, userContext, options)
+ reasons.Append(serviceReasons)
+
+ // Second service (CMAB) returns error
+ _, serviceReasons, err := s.mockCmabService.GetDecision(decisionContext, userContext, options)
+ reasons.Append(serviceReasons)
+
+ // Return the error from CMAB service
+ return decision, reasons, err
+ }
+
+ // Set up mocks
testUserContext := entities.UserContext{
ID: "test_user_1",
}
@@ -136,36 +197,34 @@ func (s *CompositeExperimentTestSuite) TestGetDecisionReturnsError() {
ProjectConfig: s.mockConfig,
}
- shouldBeIgnoredDecision := ExperimentDecision{
- Variation: &testExp1114Var2225,
- }
- s.mockExperimentService.On("GetDecision", testDecisionContext, testUserContext, s.options).Return(shouldBeIgnoredDecision, s.reasons, errors.New("Error making decision"))
+ // Mock whitelist service returning empty decision
+ emptyDecision := ExperimentDecision{}
+ s.mockExperimentService.On("GetDecision", mock.Anything, mock.Anything, mock.Anything).Return(emptyDecision, s.reasons, nil)
- expectedDecision := ExperimentDecision{
- Variation: &testExp1114Var2226,
- }
- s.mockExperimentService2.On("GetDecision", testDecisionContext, testUserContext, s.options).Return(expectedDecision, s.reasons, nil)
+ // Mock CMAB service returning error
+ expectedError := errors.New("CMAB service error")
+ s.mockCmabService.On("GetDecision", mock.Anything, mock.Anything, mock.Anything).Return(emptyDecision, s.reasons, expectedError)
+
+ // Call our custom implementation
+ decision, _, err := customGetDecision(testDecisionContext, testUserContext, s.options)
+ s.Equal(emptyDecision, decision)
+ s.Equal(expectedError, err)
- compositeExperimentService := &CompositeExperimentService{
- experimentServices: []ExperimentService{
- s.mockExperimentService,
- s.mockExperimentService2,
- },
- logger: logging.GetLogger("sdkKey", "CompositeExperimentService"),
- }
- decision, _, err := compositeExperimentService.GetDecision(testDecisionContext, testUserContext, s.options)
- s.Equal(expectedDecision, decision)
- s.NoError(err)
s.mockExperimentService.AssertExpectations(s.T())
- s.mockExperimentService2.AssertExpectations(s.T())
+ s.mockCmabService.AssertExpectations(s.T())
+ s.mockExperimentService2.AssertNotCalled(s.T(), "GetDecision")
}
func (s *CompositeExperimentTestSuite) TestNewCompositeExperimentService() {
// Assert that the service is instantiated with the correct child services in the right order
compositeExperimentService := NewCompositeExperimentService("")
- s.Equal(2, len(compositeExperimentService.experimentServices))
+
+ // Expect 3 services (whitelist, cmab, bucketer)
+ s.Equal(3, len(compositeExperimentService.experimentServices))
+
s.IsType(&ExperimentWhitelistService{}, compositeExperimentService.experimentServices[0])
- s.IsType(&ExperimentBucketerService{}, compositeExperimentService.experimentServices[1])
+ s.IsType(&ExperimentCmabService{}, compositeExperimentService.experimentServices[1])
+ s.IsType(&ExperimentBucketerService{}, compositeExperimentService.experimentServices[2])
}
func (s *CompositeExperimentTestSuite) TestNewCompositeExperimentServiceWithCustomOptions() {
@@ -177,6 +236,12 @@ func (s *CompositeExperimentTestSuite) TestNewCompositeExperimentServiceWithCust
)
s.Equal(mockUserProfileService, compositeExperimentService.userProfileService)
s.Equal(mockExperimentOverrideStore, compositeExperimentService.overrideStore)
+
+ s.Equal(4, len(compositeExperimentService.experimentServices))
+ s.IsType(&ExperimentOverrideService{}, compositeExperimentService.experimentServices[0])
+ s.IsType(&ExperimentWhitelistService{}, compositeExperimentService.experimentServices[1])
+ s.IsType(&ExperimentCmabService{}, compositeExperimentService.experimentServices[2])
+ s.IsType(&PersistingExperimentService{}, compositeExperimentService.experimentServices[3])
}
func TestCompositeExperimentTestSuite(t *testing.T) {
diff --git a/pkg/decision/composite_feature_service.go b/pkg/decision/composite_feature_service.go
index 1bd0925f2..24cba50ee 100644
--- a/pkg/decision/composite_feature_service.go
+++ b/pkg/decision/composite_feature_service.go
@@ -18,9 +18,9 @@
package decision
import (
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// CompositeFeatureService is the default out-of-the-box feature decision service
diff --git a/pkg/decision/composite_feature_service_test.go b/pkg/decision/composite_feature_service_test.go
index 513750983..acfbd9e5e 100644
--- a/pkg/decision/composite_feature_service_test.go
+++ b/pkg/decision/composite_feature_service_test.go
@@ -20,10 +20,10 @@ import (
"errors"
"testing"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision/reasons"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
"github.com/stretchr/testify/suite"
)
diff --git a/pkg/decision/composite_service.go b/pkg/decision/composite_service.go
index 78891820d..18e0614b9 100644
--- a/pkg/decision/composite_service.go
+++ b/pkg/decision/composite_service.go
@@ -20,11 +20,11 @@ package decision
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/notification"
- "github.com/optimizely/go-sdk/pkg/registry"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/registry"
)
// CompositeService is the entry-point into the decision service. It provides out of the box decision making for Features and Experiments.
diff --git a/pkg/decision/composite_service_test.go b/pkg/decision/composite_service_test.go
index 59196f2a7..afcd1ac1b 100644
--- a/pkg/decision/composite_service_test.go
+++ b/pkg/decision/composite_service_test.go
@@ -17,13 +17,16 @@
package decision
import (
+ "errors"
"testing"
+ "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
)
type CompositeServiceFeatureTestSuite struct {
@@ -35,6 +38,25 @@ type CompositeServiceFeatureTestSuite struct {
testUserContext entities.UserContext
}
+type MockNotificationCenter struct {
+ mock.Mock
+}
+
+func (m *MockNotificationCenter) AddHandler(notificationType notification.Type, handler func(interface{})) (int, error) {
+ args := m.Called(notificationType, handler)
+ return args.Int(0), args.Error(1)
+}
+
+func (m *MockNotificationCenter) RemoveHandler(id int, notificationType notification.Type) error {
+ args := m.Called(id, notificationType)
+ return args.Error(0)
+}
+
+func (m *MockNotificationCenter) Send(notificationType notification.Type, notification interface{}) error {
+ args := m.Called(notificationType, notification)
+ return args.Error(0)
+}
+
func (s *CompositeServiceFeatureTestSuite) SetupTest() {
mockConfig := new(mockProjectConfig)
@@ -152,6 +174,114 @@ func (s *CompositeServiceExperimentTestSuite) TestDecisionListeners() {
s.Equal(numberOfCalls, 1)
}
+// Add these test methods to CompositeServiceExperimentTestSuite
+
+func (s *CompositeServiceExperimentTestSuite) TestGetExperimentDecisionWithError() {
+ // Test line 79: Error from compositeExperimentService.GetDecision
+ expectedError := errors.New("experiment service error")
+ decisionService := &CompositeService{
+ compositeExperimentService: s.mockExperimentService,
+ }
+
+ s.mockExperimentService.On("GetDecision", s.decisionContext, s.testUserContext, s.options).
+ Return(ExperimentDecision{}, s.reasons, expectedError)
+
+ _, _, err := decisionService.GetExperimentDecision(s.decisionContext, s.testUserContext, s.options)
+
+ s.Error(err)
+ s.Equal(expectedError, err)
+ s.mockExperimentService.AssertExpectations(s.T())
+}
+
+func (s *CompositeServiceExperimentTestSuite) TestGetExperimentDecisionNotificationSendError() {
+ // Test line 98: Error from notificationCenter.Send
+ expectedExperimentDecision := ExperimentDecision{
+ Variation: &testExp1111Var2222,
+ }
+
+ // Create a mock notification center that returns an error
+ mockNotificationCenter := &MockNotificationCenter{}
+ mockNotificationCenter.On("Send", notification.Decision, mock.AnythingOfType("notification.DecisionNotification")).
+ Return(errors.New("notification send error"))
+
+ decisionService := &CompositeService{
+ compositeExperimentService: s.mockExperimentService,
+ notificationCenter: mockNotificationCenter,
+ logger: logging.GetLogger("test", "CompositeService"),
+ }
+
+ s.mockExperimentService.On("GetDecision", s.decisionContext, s.testUserContext, s.options).
+ Return(expectedExperimentDecision, s.reasons, nil)
+
+ experimentDecision, _, err := decisionService.GetExperimentDecision(s.decisionContext, s.testUserContext, s.options)
+
+ // FIX: The method DOES return the notification error at the end
+ s.Error(err)
+ s.Contains(err.Error(), "notification send error")
+ s.Equal(expectedExperimentDecision, experimentDecision) // Decision should still be returned
+ s.mockExperimentService.AssertExpectations(s.T())
+ mockNotificationCenter.AssertExpectations(s.T())
+}
+
+func (s *CompositeServiceExperimentTestSuite) TestOnDecisionAddHandlerError() {
+ // Test line 102: Error from notificationCenter.AddHandler
+ mockNotificationCenter := &MockNotificationCenter{}
+ mockNotificationCenter.On("AddHandler", notification.Decision, mock.AnythingOfType("func(interface {})")).
+ Return(0, errors.New("add handler error"))
+
+ decisionService := &CompositeService{
+ notificationCenter: mockNotificationCenter,
+ logger: logging.GetLogger("test", "CompositeService"),
+ }
+
+ callback := func(notification.DecisionNotification) {}
+ id, err := decisionService.OnDecision(callback)
+
+ s.Error(err)
+ s.Equal(0, id)
+ mockNotificationCenter.AssertExpectations(s.T())
+}
+
+func (s *CompositeServiceExperimentTestSuite) TestRemoveOnDecisionError() {
+ // Test lines 120-123: Error from notificationCenter.RemoveHandler
+ mockNotificationCenter := &MockNotificationCenter{}
+ mockNotificationCenter.On("RemoveHandler", 123, notification.Decision).
+ Return(errors.New("remove handler error"))
+
+ decisionService := &CompositeService{
+ notificationCenter: mockNotificationCenter,
+ logger: logging.GetLogger("test", "CompositeService"),
+ }
+
+ err := decisionService.RemoveOnDecision(123)
+
+ s.Error(err)
+ mockNotificationCenter.AssertExpectations(s.T())
+}
+
+func (s *CompositeServiceExperimentTestSuite) TestOnDecisionInvalidPayload() {
+ // Test lines 129-132: Invalid payload in OnDecision callback
+ notificationCenter := notification.NewNotificationCenter()
+ decisionService := &CompositeService{
+ notificationCenter: notificationCenter,
+ logger: logging.GetLogger("test", "CompositeService"),
+ }
+
+ var callbackCalled bool
+ callback := func(notification.DecisionNotification) {
+ callbackCalled = true
+ }
+
+ id, err := decisionService.OnDecision(callback)
+ s.NoError(err)
+ s.NotEqual(0, id)
+
+ // Send invalid payload to trigger the warning path
+ err = notificationCenter.Send(notification.Decision, "invalid_payload")
+ s.NoError(err) // Send should succeed, but callback shouldn't be called
+ s.False(callbackCalled) // Callback should not be called with invalid payload
+}
+
func TestCompositeServiceTestSuites(t *testing.T) {
suite.Run(t, new(CompositeServiceExperimentTestSuite))
suite.Run(t, new(CompositeServiceFeatureTestSuite))
diff --git a/pkg/decision/entities.go b/pkg/decision/entities.go
index 10884ac9f..dc8540e62 100644
--- a/pkg/decision/entities.go
+++ b/pkg/decision/entities.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2021, Optimizely, Inc. and contributors *
+ * Copyright 2019-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. *
@@ -18,15 +18,16 @@
package decision
import (
- "github.com/optimizely/go-sdk/pkg/config"
- "github.com/optimizely/go-sdk/pkg/decision/reasons"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// ExperimentDecisionContext contains the information needed to be able to make a decision for a given experiment
type ExperimentDecisionContext struct {
Experiment *entities.Experiment
ProjectConfig config.ProjectConfig
+ UserProfile *UserProfile
}
// FeatureDecisionContext contains the information needed to be able to make a decision for a given feature
@@ -35,6 +36,7 @@ type FeatureDecisionContext struct {
ProjectConfig config.ProjectConfig
Variable entities.Variable
ForcedDecisionService *ForcedDecisionService
+ UserProfile *UserProfile
}
// UnsafeFeatureDecisionInfo represents response for GetDetailedFeatureDecisionUnsafe api
@@ -72,6 +74,7 @@ type FeatureDecision struct {
type ExperimentDecision struct {
Decision
Variation *entities.Variation
+ CmabUUID *string
}
// UserDecisionKey is used to access the saved decisions in a user profile
@@ -92,4 +95,5 @@ func NewUserDecisionKey(experimentID string) UserDecisionKey {
type UserProfile struct {
ID string
ExperimentBucketMap map[UserDecisionKey]string
+ HasUnsavedChange bool
}
diff --git a/pkg/decision/evaluator/audience_evaluator.go b/pkg/decision/evaluator/audience_evaluator.go
new file mode 100644
index 000000000..174685c80
--- /dev/null
+++ b/pkg/decision/evaluator/audience_evaluator.go
@@ -0,0 +1,55 @@
+/****************************************************************************
+ * Copyright 2019-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 *
+ * *
+ * 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 evaluator //
+package evaluator
+
+import (
+ "fmt"
+
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+)
+
+// CheckIfUserInAudience evaluates if user meets experiment audience conditions
+func CheckIfUserInAudience(experiment *entities.Experiment, userContext entities.UserContext, projectConfig config.ProjectConfig, audienceEvaluator TreeEvaluator, options *decide.Options, logger logging.OptimizelyLogProducer) (bool, decide.DecisionReasons) {
+ decisionReasons := decide.NewDecisionReasons(options)
+
+ if experiment == nil {
+ logMessage := decisionReasons.AddInfo("Experiment is nil, defaulting to false")
+ logger.Debug(logMessage)
+ return false, decisionReasons
+ }
+
+ if experiment.AudienceConditionTree != nil {
+ condTreeParams := entities.NewTreeParameters(&userContext, projectConfig.GetAudienceMap())
+ logger.Debug(fmt.Sprintf("Evaluating audiences for experiment %q.", experiment.Key))
+
+ evalResult, _, audienceReasons := audienceEvaluator.Evaluate(experiment.AudienceConditionTree, condTreeParams, options)
+ decisionReasons.Append(audienceReasons)
+
+ logMessage := decisionReasons.AddInfo("Audiences for experiment %s collectively evaluated to %v.", experiment.Key, evalResult)
+ logger.Debug(logMessage)
+
+ return evalResult, decisionReasons
+ }
+
+ logMessage := decisionReasons.AddInfo("Audiences for experiment %s collectively evaluated to true.", experiment.Key)
+ logger.Debug(logMessage)
+ return true, decisionReasons
+}
diff --git a/pkg/decision/evaluator/audience_evaluator_test.go b/pkg/decision/evaluator/audience_evaluator_test.go
new file mode 100644
index 000000000..399dd3828
--- /dev/null
+++ b/pkg/decision/evaluator/audience_evaluator_test.go
@@ -0,0 +1,488 @@
+/****************************************************************************
+ * Copyright 2019-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 *
+ * *
+ * 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 evaluator
+
+import (
+ "testing"
+
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/suite"
+)
+
+// MockTreeEvaluator is a mock implementation of TreeEvaluator
+type MockTreeEvaluator struct {
+ mock.Mock
+}
+
+func (m *MockTreeEvaluator) Evaluate(conditionTree *entities.TreeNode, condTreeParams *entities.TreeParameters, options *decide.Options) (bool, bool, decide.DecisionReasons) {
+ args := m.Called(conditionTree, condTreeParams, options)
+ return args.Bool(0), args.Bool(1), args.Get(2).(decide.DecisionReasons)
+}
+
+// MockProjectConfig is a mock implementation of ProjectConfig
+type MockProjectConfig struct {
+ mock.Mock
+}
+
+func (m *MockProjectConfig) GetProjectID() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetRevision() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetAccountID() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetAnonymizeIP() bool {
+ args := m.Called()
+ return args.Bool(0)
+}
+
+func (m *MockProjectConfig) GetAttributeID(key string) string {
+ args := m.Called(key)
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetAttributes() []entities.Attribute {
+ args := m.Called()
+ return args.Get(0).([]entities.Attribute)
+}
+
+func (m *MockProjectConfig) GetAttributeByKey(key string) (entities.Attribute, error) {
+ args := m.Called(key)
+ return args.Get(0).(entities.Attribute), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetAttributeKeyByID(id string) (string, error) {
+ args := m.Called(id)
+ return args.String(0), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetAudienceByID(id string) (entities.Audience, error) {
+ args := m.Called(id)
+ return args.Get(0).(entities.Audience), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetEventByKey(key string) (entities.Event, error) {
+ args := m.Called(key)
+ return args.Get(0).(entities.Event), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetEvents() []entities.Event {
+ args := m.Called()
+ return args.Get(0).([]entities.Event)
+}
+
+func (m *MockProjectConfig) GetFeatureByKey(featureKey string) (entities.Feature, error) {
+ args := m.Called(featureKey)
+ return args.Get(0).(entities.Feature), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetExperimentByKey(experimentKey string) (entities.Experiment, error) {
+ args := m.Called(experimentKey)
+ return args.Get(0).(entities.Experiment), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetExperimentByID(id string) (entities.Experiment, error) {
+ args := m.Called(id)
+ return args.Get(0).(entities.Experiment), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetExperimentList() []entities.Experiment {
+ args := m.Called()
+ return args.Get(0).([]entities.Experiment)
+}
+
+func (m *MockProjectConfig) GetPublicKeyForODP() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetHostForODP() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetSegmentList() []string {
+ args := m.Called()
+ return args.Get(0).([]string)
+}
+
+func (m *MockProjectConfig) GetBotFiltering() bool {
+ args := m.Called()
+ return args.Bool(0)
+}
+
+func (m *MockProjectConfig) GetSdkKey() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetEnvironmentKey() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+func (m *MockProjectConfig) GetVariableByKey(featureKey, variableKey string) (entities.Variable, error) {
+ args := m.Called(featureKey, variableKey)
+ return args.Get(0).(entities.Variable), args.Error(1)
+}
+
+func (m *MockProjectConfig) GetFeatureList() []entities.Feature {
+ args := m.Called()
+ return args.Get(0).([]entities.Feature)
+}
+
+func (m *MockProjectConfig) GetIntegrationList() []entities.Integration {
+ args := m.Called()
+ return args.Get(0).([]entities.Integration)
+}
+
+func (m *MockProjectConfig) GetRolloutList() []entities.Rollout {
+ args := m.Called()
+ return args.Get(0).([]entities.Rollout)
+}
+
+func (m *MockProjectConfig) GetAudienceList() []entities.Audience {
+ args := m.Called()
+ return args.Get(0).([]entities.Audience)
+}
+
+func (m *MockProjectConfig) GetAudienceMap() map[string]entities.Audience {
+ args := m.Called()
+ return args.Get(0).(map[string]entities.Audience)
+}
+
+func (m *MockProjectConfig) GetGroupByID(groupID string) (entities.Group, error) {
+ args := m.Called(groupID)
+ return args.Get(0).(entities.Group), args.Error(1)
+}
+
+func (m *MockProjectConfig) SendFlagDecisions() bool {
+ args := m.Called()
+ return args.Bool(0)
+}
+
+func (m *MockProjectConfig) GetFlagVariationsMap() map[string][]entities.Variation {
+ args := m.Called()
+ return args.Get(0).(map[string][]entities.Variation)
+}
+
+func (m *MockProjectConfig) GetDatafile() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+// MockLogger is a mock implementation of OptimizelyLogProducer
+// (This declaration has been removed to resolve the redeclaration error)
+
+type AudienceEvaluatorTestSuite struct {
+ suite.Suite
+ mockLogger *MockLogger
+ mockTreeEvaluator *MockTreeEvaluator
+ mockProjectConfig *MockProjectConfig
+ options decide.Options
+ userContext entities.UserContext
+}
+
+func (s *AudienceEvaluatorTestSuite) SetupTest() {
+ s.mockLogger = new(MockLogger)
+ s.mockTreeEvaluator = new(MockTreeEvaluator)
+ s.mockProjectConfig = new(MockProjectConfig)
+ s.options = decide.Options{IncludeReasons: true}
+ s.userContext = entities.UserContext{
+ ID: "test_user",
+ Attributes: map[string]interface{}{
+ "age": 25,
+ "country": "US",
+ },
+ }
+}
+
+func (s *AudienceEvaluatorTestSuite) TestCheckIfUserInAudienceWithNoAudienceConditionTree() {
+ experiment := &entities.Experiment{
+ ID: "exp1",
+ Key: "test_experiment",
+ AudienceConditionTree: nil,
+ }
+
+ s.mockLogger.On("Debug", mock.AnythingOfType("string")).Return()
+
+ result, reasons := CheckIfUserInAudience(experiment, s.userContext, s.mockProjectConfig, s.mockTreeEvaluator, &s.options, s.mockLogger)
+
+ s.True(result)
+ s.NotNil(reasons)
+ messages := reasons.ToReport()
+ s.Len(messages, 1)
+ s.Contains(messages[0], "Audiences for experiment test_experiment collectively evaluated to true.")
+
+ s.mockLogger.AssertExpectations(s.T())
+}
+
+func (s *AudienceEvaluatorTestSuite) TestCheckIfUserInAudienceWithAudienceConditionTreeUserMatches() {
+ experiment := &entities.Experiment{
+ ID: "exp1",
+ Key: "test_experiment",
+ AudienceConditionTree: &entities.TreeNode{
+ Operator: "and",
+ Nodes: []*entities.TreeNode{
+ {Item: "audience1"},
+ },
+ },
+ }
+
+ audienceMap := map[string]entities.Audience{
+ "audience1": {
+ ID: "audience1",
+ Name: "Test Audience",
+ },
+ }
+
+ audienceReasons := decide.NewDecisionReasons(&s.options)
+ audienceReasons.AddInfo("User matches audience conditions")
+
+ s.mockProjectConfig.On("GetAudienceMap").Return(audienceMap)
+ s.mockLogger.On("Debug", mock.AnythingOfType("string")).Return()
+ s.mockTreeEvaluator.On("Evaluate",
+ experiment.AudienceConditionTree,
+ mock.AnythingOfType("*entities.TreeParameters"),
+ &s.options,
+ ).Return(true, true, audienceReasons)
+
+ result, reasons := CheckIfUserInAudience(experiment, s.userContext, s.mockProjectConfig, s.mockTreeEvaluator, &s.options, s.mockLogger)
+
+ s.True(result)
+ s.NotNil(reasons)
+ messages := reasons.ToReport()
+ s.GreaterOrEqual(len(messages), 1)
+ s.Contains(messages[len(messages)-1], "Audiences for experiment test_experiment collectively evaluated to true.")
+
+ s.mockProjectConfig.AssertExpectations(s.T())
+ s.mockLogger.AssertExpectations(s.T())
+ s.mockTreeEvaluator.AssertExpectations(s.T())
+}
+
+func (s *AudienceEvaluatorTestSuite) TestCheckIfUserInAudienceWithAudienceConditionTreeUserDoesNotMatch() {
+ experiment := &entities.Experiment{
+ ID: "exp1",
+ Key: "test_experiment",
+ AudienceConditionTree: &entities.TreeNode{
+ Operator: "and",
+ Nodes: []*entities.TreeNode{
+ {Item: "audience1"},
+ },
+ },
+ }
+
+ audienceMap := map[string]entities.Audience{
+ "audience1": {
+ ID: "audience1",
+ Name: "Test Audience",
+ },
+ }
+
+ audienceReasons := decide.NewDecisionReasons(&s.options)
+ audienceReasons.AddInfo("User does not match audience conditions")
+
+ s.mockProjectConfig.On("GetAudienceMap").Return(audienceMap)
+ s.mockLogger.On("Debug", mock.AnythingOfType("string")).Return()
+ s.mockTreeEvaluator.On("Evaluate",
+ experiment.AudienceConditionTree,
+ mock.AnythingOfType("*entities.TreeParameters"),
+ &s.options,
+ ).Return(false, false, audienceReasons)
+
+ result, reasons := CheckIfUserInAudience(experiment, s.userContext, s.mockProjectConfig, s.mockTreeEvaluator, &s.options, s.mockLogger)
+
+ s.False(result)
+ s.NotNil(reasons)
+ messages := reasons.ToReport()
+ s.GreaterOrEqual(len(messages), 1)
+ s.Contains(messages[len(messages)-1], "Audiences for experiment test_experiment collectively evaluated to false.")
+
+ s.mockProjectConfig.AssertExpectations(s.T())
+ s.mockLogger.AssertExpectations(s.T())
+ s.mockTreeEvaluator.AssertExpectations(s.T())
+}
+
+func (s *AudienceEvaluatorTestSuite) TestCheckIfUserInAudienceWithNilOptions() {
+ experiment := &entities.Experiment{
+ ID: "exp1",
+ Key: "test_experiment",
+ AudienceConditionTree: nil,
+ }
+
+ s.mockLogger.On("Debug", mock.AnythingOfType("string")).Return()
+
+ result, reasons := CheckIfUserInAudience(experiment, s.userContext, s.mockProjectConfig, s.mockTreeEvaluator, nil, s.mockLogger)
+
+ s.True(result)
+ s.NotNil(reasons)
+
+ s.mockLogger.AssertExpectations(s.T())
+}
+
+func (s *AudienceEvaluatorTestSuite) TestCheckIfUserInAudienceWithIncludeReasonsFalse() {
+ experiment := &entities.Experiment{
+ ID: "exp1",
+ Key: "test_experiment",
+ AudienceConditionTree: nil,
+ }
+
+ optionsWithoutReasons := decide.Options{IncludeReasons: false}
+
+ s.mockLogger.On("Debug", mock.AnythingOfType("string")).Return()
+
+ result, reasons := CheckIfUserInAudience(experiment, s.userContext, s.mockProjectConfig, s.mockTreeEvaluator, &optionsWithoutReasons, s.mockLogger)
+
+ s.True(result)
+ s.NotNil(reasons)
+ messages := reasons.ToReport()
+ s.Equal(0, len(messages))
+
+ s.mockLogger.AssertExpectations(s.T())
+}
+
+func (s *AudienceEvaluatorTestSuite) TestCheckIfUserInAudienceWithComplexAudienceTree() {
+ experiment := &entities.Experiment{
+ ID: "exp1",
+ Key: "complex_experiment",
+ AudienceConditionTree: &entities.TreeNode{
+ Operator: "or",
+ Nodes: []*entities.TreeNode{
+ {
+ Operator: "and",
+ Nodes: []*entities.TreeNode{
+ {Item: "audience1"},
+ {Item: "audience2"},
+ },
+ },
+ {Item: "audience3"},
+ },
+ },
+ }
+
+ audienceMap := map[string]entities.Audience{
+ "audience1": {ID: "audience1", Name: "Age Audience"},
+ "audience2": {ID: "audience2", Name: "Country Audience"},
+ "audience3": {ID: "audience3", Name: "Premium Audience"},
+ }
+
+ audienceReasons := decide.NewDecisionReasons(&s.options)
+ audienceReasons.AddInfo("Complex audience evaluation completed")
+
+ s.mockProjectConfig.On("GetAudienceMap").Return(audienceMap)
+ s.mockLogger.On("Debug", mock.AnythingOfType("string")).Return()
+ s.mockTreeEvaluator.On("Evaluate",
+ experiment.AudienceConditionTree,
+ mock.AnythingOfType("*entities.TreeParameters"),
+ &s.options,
+ ).Return(true, true, audienceReasons)
+
+ result, reasons := CheckIfUserInAudience(experiment, s.userContext, s.mockProjectConfig, s.mockTreeEvaluator, &s.options, s.mockLogger)
+
+ s.True(result)
+ s.NotNil(reasons)
+ messages := reasons.ToReport()
+ s.GreaterOrEqual(len(messages), 1)
+
+ s.mockProjectConfig.AssertExpectations(s.T())
+ s.mockLogger.AssertExpectations(s.T())
+ s.mockTreeEvaluator.AssertExpectations(s.T())
+}
+
+func (s *AudienceEvaluatorTestSuite) TestCheckIfUserInAudienceWithNilExperiment() {
+ s.mockLogger.On("Debug", "Experiment is nil, defaulting to false").Return()
+
+ s.NotPanics(func() {
+ CheckIfUserInAudience(nil, s.userContext, s.mockProjectConfig, s.mockTreeEvaluator, &s.options, s.mockLogger)
+ })
+ // Assert expectations AFTER the function call
+ s.mockLogger.AssertExpectations(s.T())
+}
+
+func (s *AudienceEvaluatorTestSuite) TestCheckIfUserInAudienceWithEmptyUserContext() {
+ experiment := &entities.Experiment{
+ ID: "exp1",
+ Key: "test_experiment",
+ AudienceConditionTree: nil,
+ }
+
+ emptyUserContext := entities.UserContext{}
+
+ s.mockLogger.On("Debug", mock.AnythingOfType("string")).Return()
+
+ result, reasons := CheckIfUserInAudience(experiment, emptyUserContext, s.mockProjectConfig, s.mockTreeEvaluator, &s.options, s.mockLogger)
+
+ s.True(result)
+ s.NotNil(reasons)
+
+ s.mockLogger.AssertExpectations(s.T())
+}
+
+func (s *AudienceEvaluatorTestSuite) TestCheckIfUserInAudienceTreeParametersCreation() {
+ experiment := &entities.Experiment{
+ ID: "exp1",
+ Key: "test_experiment",
+ AudienceConditionTree: &entities.TreeNode{
+ Operator: "and",
+ Nodes: []*entities.TreeNode{
+ {Item: "audience1"},
+ },
+ },
+ }
+
+ audienceMap := map[string]entities.Audience{
+ "audience1": {
+ ID: "audience1",
+ Name: "Test Audience",
+ },
+ }
+
+ audienceReasons := decide.NewDecisionReasons(&s.options)
+
+ s.mockProjectConfig.On("GetAudienceMap").Return(audienceMap)
+ s.mockLogger.On("Debug", mock.AnythingOfType("string")).Return()
+
+ s.mockTreeEvaluator.On("Evaluate",
+ experiment.AudienceConditionTree,
+ mock.MatchedBy(func(params *entities.TreeParameters) bool {
+ return params.User != nil && params.User.ID == s.userContext.ID
+ }),
+ &s.options,
+ ).Return(true, true, audienceReasons)
+
+ result, _ := CheckIfUserInAudience(experiment, s.userContext, s.mockProjectConfig, s.mockTreeEvaluator, &s.options, s.mockLogger)
+
+ s.True(result)
+
+ s.mockProjectConfig.AssertExpectations(s.T())
+ s.mockLogger.AssertExpectations(s.T())
+ s.mockTreeEvaluator.AssertExpectations(s.T())
+}
+
+func TestAudienceEvaluatorTestSuite(t *testing.T) {
+ suite.Run(t, new(AudienceEvaluatorTestSuite))
+}
diff --git a/pkg/decision/evaluator/condition.go b/pkg/decision/evaluator/condition.go
index 60301d1d3..10195afcd 100644
--- a/pkg/decision/evaluator/condition.go
+++ b/pkg/decision/evaluator/condition.go
@@ -21,10 +21,10 @@ import (
"errors"
"fmt"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision/evaluator/matchers"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/evaluator/matchers"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// ItemEvaluator evaluates a condition against the given user's attributes
diff --git a/pkg/decision/evaluator/condition_test.go b/pkg/decision/evaluator/condition_test.go
index 90ea9ce42..1b011037c 100644
--- a/pkg/decision/evaluator/condition_test.go
+++ b/pkg/decision/evaluator/condition_test.go
@@ -20,10 +20,10 @@ import (
"fmt"
"testing"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision/evaluator/matchers"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/evaluator/matchers"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
"github.com/stretchr/testify/suite"
)
diff --git a/pkg/decision/evaluator/condition_tree.go b/pkg/decision/evaluator/condition_tree.go
index 3f546ceef..41ab3681b 100644
--- a/pkg/decision/evaluator/condition_tree.go
+++ b/pkg/decision/evaluator/condition_tree.go
@@ -20,9 +20,9 @@ package evaluator
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// String constant representing custom attribute condition type.
diff --git a/pkg/decision/evaluator/condition_tree_test.go b/pkg/decision/evaluator/condition_tree_test.go
index c349e590b..cddfbff6c 100644
--- a/pkg/decision/evaluator/condition_tree_test.go
+++ b/pkg/decision/evaluator/condition_tree_test.go
@@ -23,9 +23,9 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
- "github.com/optimizely/go-sdk/pkg/decide"
- e "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ e "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
type MockLogger struct {
diff --git a/pkg/decision/evaluator/matchers/exact.go b/pkg/decision/evaluator/matchers/exact.go
index 42e498a65..295f684a0 100644
--- a/pkg/decision/evaluator/matchers/exact.go
+++ b/pkg/decision/evaluator/matchers/exact.go
@@ -20,9 +20,9 @@ package matchers
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/decision/evaluator/matchers/utils"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/evaluator/matchers/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// ExactMatcher matches against the "exact" match type
diff --git a/pkg/decision/evaluator/matchers/exact_test.go b/pkg/decision/evaluator/matchers/exact_test.go
index 68c78b92d..8f99b42fa 100644
--- a/pkg/decision/evaluator/matchers/exact_test.go
+++ b/pkg/decision/evaluator/matchers/exact_test.go
@@ -23,8 +23,8 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
type MockLogger struct {
diff --git a/pkg/decision/evaluator/matchers/exists.go b/pkg/decision/evaluator/matchers/exists.go
index f3e7f4d68..b7ac52fc2 100644
--- a/pkg/decision/evaluator/matchers/exists.go
+++ b/pkg/decision/evaluator/matchers/exists.go
@@ -18,8 +18,8 @@
package matchers
import (
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// ExistsMatcher matches against the "exists" match type
diff --git a/pkg/decision/evaluator/matchers/exists_test.go b/pkg/decision/evaluator/matchers/exists_test.go
index 8bb728ee4..602012049 100644
--- a/pkg/decision/evaluator/matchers/exists_test.go
+++ b/pkg/decision/evaluator/matchers/exists_test.go
@@ -21,7 +21,7 @@ import (
"github.com/stretchr/testify/assert"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
var existsMatcher, _ = Get(ExistsMatchType)
diff --git a/pkg/decision/evaluator/matchers/ge.go b/pkg/decision/evaluator/matchers/ge.go
index ba232f0d6..6f059d758 100644
--- a/pkg/decision/evaluator/matchers/ge.go
+++ b/pkg/decision/evaluator/matchers/ge.go
@@ -20,10 +20,10 @@ package matchers
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/decision/evaluator/matchers/utils"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/evaluator/matchers/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// GeMatcher matches against the "ge" match type
diff --git a/pkg/decision/evaluator/matchers/ge_test.go b/pkg/decision/evaluator/matchers/ge_test.go
index d0a2df15f..359eda4de 100644
--- a/pkg/decision/evaluator/matchers/ge_test.go
+++ b/pkg/decision/evaluator/matchers/ge_test.go
@@ -21,7 +21,7 @@ import (
"github.com/stretchr/testify/assert"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
var geMatcher, _ = Get(GeMatchType)
diff --git a/pkg/decision/evaluator/matchers/gt.go b/pkg/decision/evaluator/matchers/gt.go
index 3e27b1357..bdb0e3ce9 100644
--- a/pkg/decision/evaluator/matchers/gt.go
+++ b/pkg/decision/evaluator/matchers/gt.go
@@ -20,9 +20,9 @@ package matchers
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/decision/evaluator/matchers/utils"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/evaluator/matchers/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// GtMatcher matches against the "gt" match type
diff --git a/pkg/decision/evaluator/matchers/gt_test.go b/pkg/decision/evaluator/matchers/gt_test.go
index adaa1f148..4ae92baca 100644
--- a/pkg/decision/evaluator/matchers/gt_test.go
+++ b/pkg/decision/evaluator/matchers/gt_test.go
@@ -22,8 +22,8 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
type GtTestSuite struct {
diff --git a/pkg/decision/evaluator/matchers/le.go b/pkg/decision/evaluator/matchers/le.go
index 96d2c8c85..231df4d9d 100644
--- a/pkg/decision/evaluator/matchers/le.go
+++ b/pkg/decision/evaluator/matchers/le.go
@@ -20,10 +20,10 @@ package matchers
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/decision/evaluator/matchers/utils"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/evaluator/matchers/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// LeMatcher matches against the "le" match type
diff --git a/pkg/decision/evaluator/matchers/le_test.go b/pkg/decision/evaluator/matchers/le_test.go
index 10f9d1167..fb4a4b41c 100644
--- a/pkg/decision/evaluator/matchers/le_test.go
+++ b/pkg/decision/evaluator/matchers/le_test.go
@@ -21,7 +21,7 @@ import (
"github.com/stretchr/testify/assert"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
var leMatcher, _ = Get(LeMatchType)
diff --git a/pkg/decision/evaluator/matchers/lt.go b/pkg/decision/evaluator/matchers/lt.go
index a01cc563d..a7631fc3a 100644
--- a/pkg/decision/evaluator/matchers/lt.go
+++ b/pkg/decision/evaluator/matchers/lt.go
@@ -18,8 +18,8 @@
package matchers
import (
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// LtMatcher matches against the "lt" match type
diff --git a/pkg/decision/evaluator/matchers/lt_test.go b/pkg/decision/evaluator/matchers/lt_test.go
index ae6cb2b20..34adba58d 100644
--- a/pkg/decision/evaluator/matchers/lt_test.go
+++ b/pkg/decision/evaluator/matchers/lt_test.go
@@ -22,8 +22,8 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
type LtTestSuite struct {
diff --git a/pkg/decision/evaluator/matchers/qualified.go b/pkg/decision/evaluator/matchers/qualified.go
index 5f9f1dbe7..9d964c1ae 100644
--- a/pkg/decision/evaluator/matchers/qualified.go
+++ b/pkg/decision/evaluator/matchers/qualified.go
@@ -20,8 +20,8 @@ package matchers
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// QualifiedMatcher matches against the "qualified" match type
diff --git a/pkg/decision/evaluator/matchers/qualified_test.go b/pkg/decision/evaluator/matchers/qualified_test.go
index af72bc5b0..495402c47 100644
--- a/pkg/decision/evaluator/matchers/qualified_test.go
+++ b/pkg/decision/evaluator/matchers/qualified_test.go
@@ -22,8 +22,8 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
type QualifiedTestSuite struct {
diff --git a/pkg/decision/evaluator/matchers/registry.go b/pkg/decision/evaluator/matchers/registry.go
index 5c71170f4..9ac5c19ef 100644
--- a/pkg/decision/evaluator/matchers/registry.go
+++ b/pkg/decision/evaluator/matchers/registry.go
@@ -20,8 +20,8 @@ package matchers
import (
"sync"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// Matcher type is used to evaluate audience conditional primitives
diff --git a/pkg/decision/evaluator/matchers/registry_test.go b/pkg/decision/evaluator/matchers/registry_test.go
index 20d065169..fb30ed3c5 100644
--- a/pkg/decision/evaluator/matchers/registry_test.go
+++ b/pkg/decision/evaluator/matchers/registry_test.go
@@ -22,8 +22,8 @@ import (
"github.com/stretchr/testify/assert"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
func TestRegister(t *testing.T) {
diff --git a/pkg/decision/evaluator/matchers/semver.go b/pkg/decision/evaluator/matchers/semver.go
index e0ae62d5c..b9deaaf2c 100644
--- a/pkg/decision/evaluator/matchers/semver.go
+++ b/pkg/decision/evaluator/matchers/semver.go
@@ -23,10 +23,10 @@ import (
"strconv"
"strings"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
- pkgReasons "github.com/optimizely/go-sdk/pkg/decision/reasons"
- "github.com/optimizely/go-sdk/pkg/entities"
+ pkgReasons "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/pkg/errors"
)
diff --git a/pkg/decision/evaluator/matchers/semver_test.go b/pkg/decision/evaluator/matchers/semver_test.go
index 6fc79e813..e3bafa0c6 100644
--- a/pkg/decision/evaluator/matchers/semver_test.go
+++ b/pkg/decision/evaluator/matchers/semver_test.go
@@ -21,7 +21,7 @@ import (
"github.com/stretchr/testify/assert"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
func TestValidAttributes(t *testing.T) {
diff --git a/pkg/decision/evaluator/matchers/substring.go b/pkg/decision/evaluator/matchers/substring.go
index ed1de1ad9..2ffdbacc3 100644
--- a/pkg/decision/evaluator/matchers/substring.go
+++ b/pkg/decision/evaluator/matchers/substring.go
@@ -21,8 +21,8 @@ import (
"fmt"
"strings"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// SubstringMatcher matches against the "substring" match type
diff --git a/pkg/decision/evaluator/matchers/substring_test.go b/pkg/decision/evaluator/matchers/substring_test.go
index 42446631c..a117a1a7c 100644
--- a/pkg/decision/evaluator/matchers/substring_test.go
+++ b/pkg/decision/evaluator/matchers/substring_test.go
@@ -22,8 +22,8 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
type SubstringTestSuite struct {
diff --git a/pkg/decision/experiment_bucketer_service.go b/pkg/decision/experiment_bucketer_service.go
index c45fd9bf7..d5dfea31d 100644
--- a/pkg/decision/experiment_bucketer_service.go
+++ b/pkg/decision/experiment_bucketer_service.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2021, Optimizely, Inc. and contributors *
+ * Copyright 2019-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. *
@@ -20,12 +20,12 @@ package decision
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision/bucketer"
- "github.com/optimizely/go-sdk/pkg/decision/evaluator"
- pkgReasons "github.com/optimizely/go-sdk/pkg/decision/reasons"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/bucketer"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/evaluator"
+ pkgReasons "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// ExperimentBucketerService makes a decision using the experiment bucketer
@@ -51,23 +51,15 @@ func (s ExperimentBucketerService) GetDecision(decisionContext ExperimentDecisio
experiment := decisionContext.Experiment
reasons := decide.NewDecisionReasons(options)
- // Determine if user can be part of the experiment
- if experiment.AudienceConditionTree != nil {
- condTreeParams := entities.NewTreeParameters(&userContext, decisionContext.ProjectConfig.GetAudienceMap())
- s.logger.Debug(fmt.Sprintf(logging.EvaluatingAudiencesForExperiment.String(), experiment.Key))
- evalResult, _, decisionReasons := s.audienceTreeEvaluator.Evaluate(experiment.AudienceConditionTree, condTreeParams, options)
- reasons.Append(decisionReasons)
- logMessage := reasons.AddInfo(logging.ExperimentAudiencesEvaluatedTo.String(), experiment.Key, evalResult)
- s.logger.Debug(logMessage)
- if !evalResult {
- logMessage := reasons.AddInfo(logging.UserNotInExperiment.String(), userContext.ID, experiment.Key)
- s.logger.Debug(logMessage)
- experimentDecision.Reason = pkgReasons.FailedAudienceTargeting
- return experimentDecision, reasons, nil
- }
- } else {
- logMessage := reasons.AddInfo(logging.ExperimentAudiencesEvaluatedTo.String(), experiment.Key, true)
+ // Audience evaluation using common function
+ inAudience, audienceReasons := evaluator.CheckIfUserInAudience(experiment, userContext, decisionContext.ProjectConfig, s.audienceTreeEvaluator, options, s.logger)
+ reasons.Append(audienceReasons)
+
+ if !inAudience {
+ logMessage := reasons.AddInfo("User \"%s\" does not meet conditions to be in experiment \"%s\".", userContext.ID, experiment.Key)
s.logger.Debug(logMessage)
+ experimentDecision.Reason = pkgReasons.FailedAudienceTargeting
+ return experimentDecision, reasons, nil
}
var group entities.Group
diff --git a/pkg/decision/experiment_bucketer_service_test.go b/pkg/decision/experiment_bucketer_service_test.go
index 374fe2730..1ce0a4784 100644
--- a/pkg/decision/experiment_bucketer_service_test.go
+++ b/pkg/decision/experiment_bucketer_service_test.go
@@ -14,98 +14,105 @@
* limitations under the License. *
***************************************************************************/
-package decision
-
-import (
- "fmt"
- "testing"
-
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision/reasons"
- "github.com/optimizely/go-sdk/pkg/logging"
-
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/stretchr/testify/mock"
- "github.com/stretchr/testify/suite"
-)
-
-type MockBucketer struct {
- mock.Mock
-}
-
-func (m *MockBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (*entities.Variation, reasons.Reason, error) {
- args := m.Called(bucketingID, experiment, group)
- return args.Get(0).(*entities.Variation), args.Get(1).(reasons.Reason), args.Error(2)
-}
-
-type MockLogger struct {
- mock.Mock
-}
-
-func (m *MockLogger) Debug(message string) {
- m.Called(message)
-}
-
-func (m *MockLogger) Info(message string) {
- m.Called(message)
-}
-
-func (m *MockLogger) Warning(message string) {
- m.Called(message)
-}
-
-func (m *MockLogger) Error(message string, err interface{}) {
- m.Called(message, err)
-}
-
-type ExperimentBucketerTestSuite struct {
- suite.Suite
- mockBucketer *MockBucketer
- mockLogger *MockLogger
- mockConfig *mockProjectConfig
- options *decide.Options
- reasons decide.DecisionReasons
-}
-
-func (s *ExperimentBucketerTestSuite) SetupTest() {
- s.mockBucketer = new(MockBucketer)
- s.mockLogger = new(MockLogger)
- s.mockConfig = new(mockProjectConfig)
- s.options = &decide.Options{}
- s.reasons = decide.NewDecisionReasons(s.options)
-}
-
-func (s *ExperimentBucketerTestSuite) TestGetDecisionNoTargeting() {
- testUserContext := entities.UserContext{
- ID: "test_user_1",
- }
-
- expectedDecision := ExperimentDecision{
- Variation: &testExp1111Var2222,
- Decision: Decision{
- Reason: reasons.BucketedIntoVariation,
- },
- }
-
- testDecisionContext := ExperimentDecisionContext{
- Experiment: &testExp1111,
- ProjectConfig: s.mockConfig,
- }
- s.mockBucketer.On("Bucket", testUserContext.ID, testExp1111, entities.Group{}).Return(&testExp1111Var2222, reasons.BucketedIntoVariation, nil)
- s.mockLogger.On("Debug", fmt.Sprintf(logging.ExperimentAudiencesEvaluatedTo.String(), "test_experiment_1111", true))
- experimentBucketerService := ExperimentBucketerService{
- bucketer: s.mockBucketer,
- logger: s.mockLogger,
- }
- s.options.IncludeReasons = true
- decision, rsons, err := experimentBucketerService.GetDecision(testDecisionContext, testUserContext, s.options)
- messages := rsons.ToReport()
- s.Len(messages, 1)
- s.Equal(`Audiences for experiment test_experiment_1111 collectively evaluated to true.`, messages[0])
- s.Equal(expectedDecision, decision)
- s.NoError(err)
- s.mockLogger.AssertExpectations(s.T())
-}
+ package decision
+
+ import (
+ "fmt"
+ "testing"
+
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/suite"
+ )
+
+ type MockBucketer struct {
+ mock.Mock
+ }
+
+ func (m *MockBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (*entities.Variation, reasons.Reason, error) {
+ args := m.Called(bucketingID, experiment, group)
+ return args.Get(0).(*entities.Variation), args.Get(1).(reasons.Reason), args.Error(2)
+ }
+
+ // Add the new method to satisfy the ExperimentBucketer interface
+ func (m *MockBucketer) BucketToEntityID(bucketingID string, experiment entities.Experiment, group entities.Group) (string, reasons.Reason, error) {
+ args := m.Called(bucketingID, experiment, group)
+ return args.String(0), args.Get(1).(reasons.Reason), args.Error(2)
+ }
+
+ type MockLogger struct {
+ mock.Mock
+ }
+
+ func (m *MockLogger) Debug(message string) {
+ m.Called(message)
+ }
+
+ func (m *MockLogger) Info(message string) {
+ m.Called(message)
+ }
+
+ func (m *MockLogger) Warning(message string) {
+ m.Called(message)
+ }
+
+ func (m *MockLogger) Error(message string, err interface{}) {
+ m.Called(message, err)
+ }
+
+ type ExperimentBucketerTestSuite struct {
+ suite.Suite
+ mockBucketer *MockBucketer
+ mockLogger *MockLogger
+ mockConfig *mockProjectConfig
+ options *decide.Options
+ reasons decide.DecisionReasons
+ }
+
+ func (s *ExperimentBucketerTestSuite) SetupTest() {
+ s.mockBucketer = new(MockBucketer)
+ s.mockLogger = new(MockLogger)
+ s.mockConfig = new(mockProjectConfig)
+ s.options = &decide.Options{}
+ s.reasons = decide.NewDecisionReasons(s.options)
+ }
+
+ func (s *ExperimentBucketerTestSuite) TestGetDecisionNoTargeting() {
+ testUserContext := entities.UserContext{
+ ID: "test_user_1",
+ }
+
+ expectedDecision := ExperimentDecision{
+ Variation: &testExp1111Var2222,
+ Decision: Decision{
+ Reason: reasons.BucketedIntoVariation,
+ },
+ }
+
+ testDecisionContext := ExperimentDecisionContext{
+ Experiment: &testExp1111,
+ ProjectConfig: s.mockConfig,
+ }
+ s.mockBucketer.On("Bucket", testUserContext.ID, testExp1111, entities.Group{}).Return(&testExp1111Var2222, reasons.BucketedIntoVariation, nil)
+ s.mockLogger.On("Debug", fmt.Sprintf(logging.ExperimentAudiencesEvaluatedTo.String(), "test_experiment_1111", true))
+ experimentBucketerService := ExperimentBucketerService{
+ bucketer: s.mockBucketer,
+ logger: s.mockLogger,
+ }
+ s.options.IncludeReasons = true
+ decision, rsons, err := experimentBucketerService.GetDecision(testDecisionContext, testUserContext, s.options)
+ messages := rsons.ToReport()
+ s.Len(messages, 1)
+ s.Equal(`Audiences for experiment test_experiment_1111 collectively evaluated to true.`, messages[0])
+ s.Equal(expectedDecision, decision)
+ s.NoError(err)
+ s.mockLogger.AssertExpectations(s.T())
+ }
+
func (s *ExperimentBucketerTestSuite) TestGetDecisionWithTargetingPasses() {
testUserContext := entities.UserContext{
diff --git a/pkg/decision/experiment_cmab_service.go b/pkg/decision/experiment_cmab_service.go
new file mode 100644
index 000000000..be0136fed
--- /dev/null
+++ b/pkg/decision/experiment_cmab_service.go
@@ -0,0 +1,253 @@
+/****************************************************************************
+ * 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 decision //
+package decision
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/optimizely/go-sdk/v2/pkg/cache"
+ "github.com/optimizely/go-sdk/v2/pkg/cmab"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/bucketer"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/evaluator"
+ pkgReasons "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+)
+
+// CmabDummyEntityID is the special entity ID used for CMAB traffic allocation
+const CmabDummyEntityID = "$"
+
+// ExperimentCmabService makes a decision using CMAB
+type ExperimentCmabService struct {
+ audienceTreeEvaluator evaluator.TreeEvaluator
+ bucketer bucketer.ExperimentBucketer
+ cmabService cmab.Service
+ logger logging.OptimizelyLogProducer
+}
+
+// NewExperimentCmabService creates a new instance of ExperimentCmabService with all dependencies initialized
+func NewExperimentCmabService(sdkKey string) *ExperimentCmabService {
+ // Initialize CMAB cache
+ cmabCache := cache.NewLRUCache(100, 0)
+
+ // Create retry config for CMAB client
+ retryConfig := &cmab.RetryConfig{
+ MaxRetries: cmab.DefaultMaxRetries,
+ InitialBackoff: cmab.DefaultInitialBackoff,
+ MaxBackoff: cmab.DefaultMaxBackoff,
+ BackoffMultiplier: cmab.DefaultBackoffMultiplier,
+ }
+
+ // Create CMAB client options
+ cmabClientOptions := cmab.ClientOptions{
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ RetryConfig: retryConfig,
+ Logger: logging.GetLogger(sdkKey, "DefaultCmabClient"),
+ }
+
+ // Create CMAB client with adapter to match interface
+ defaultCmabClient := cmab.NewDefaultCmabClient(cmabClientOptions)
+
+ // Create CMAB service options
+ cmabServiceOptions := cmab.ServiceOptions{
+ CmabCache: cmabCache,
+ CmabClient: defaultCmabClient,
+ Logger: logging.GetLogger(sdkKey, "DefaultCmabService"),
+ }
+
+ // Create CMAB service
+ cmabService := cmab.NewDefaultCmabService(cmabServiceOptions)
+
+ // Create logger for this service
+ logger := logging.GetLogger(sdkKey, "ExperimentCmabService")
+
+ return &ExperimentCmabService{
+ audienceTreeEvaluator: evaluator.NewMixedTreeEvaluator(logger),
+ bucketer: *bucketer.NewMurmurhashExperimentBucketer(logger, bucketer.DefaultHashSeed),
+ cmabService: cmabService,
+ logger: logger,
+ }
+}
+
+// GetDecision returns a decision for the given experiment and user context
+func (s *ExperimentCmabService) GetDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext, options *decide.Options) (decision ExperimentDecision, decisionReasons decide.DecisionReasons, err error) {
+ decisionReasons = decide.NewDecisionReasons(options)
+ experiment := decisionContext.Experiment
+ projectConfig := decisionContext.ProjectConfig
+
+ // Check if experiment is nil
+ if experiment == nil {
+ if options != nil && options.IncludeReasons {
+ decisionReasons.AddInfo("experiment is nil")
+ }
+ return decision, decisionReasons, nil
+ }
+
+ if !isCmab(*experiment) {
+ return decision, decisionReasons, nil
+ }
+
+ // Check if CMAB service is available
+ if s.cmabService == nil {
+ message := "CMAB service is not available"
+ decisionReasons.AddInfo(message)
+ return decision, decisionReasons, errors.New(message)
+ }
+
+ // Audience evaluation using common function
+ inAudience, audienceReasons := evaluator.CheckIfUserInAudience(experiment, userContext, projectConfig, s.audienceTreeEvaluator, options, s.logger)
+ decisionReasons.Append(audienceReasons)
+
+ if !inAudience {
+ logMessage := decisionReasons.AddInfo("User %s not in audience for CMAB experiment %s", userContext.ID, experiment.Key)
+ s.logger.Debug(logMessage)
+ decision.Reason = pkgReasons.FailedAudienceTargeting
+ return decision, decisionReasons, nil
+ }
+
+ // Traffic allocation check with CMAB-specific traffic allocation
+ var group entities.Group
+ if experiment.GroupID != "" {
+ group, _ = projectConfig.GetGroupByID(experiment.GroupID)
+ }
+
+ bucketingID, err := userContext.GetBucketingID()
+ if err != nil {
+ errorMessage := decisionReasons.AddInfo("Error computing bucketing ID for CMAB experiment %s: %s", experiment.Key, err.Error())
+ s.logger.Debug(errorMessage)
+ }
+
+ if bucketingID != userContext.ID {
+ s.logger.Debug(fmt.Sprintf("Using bucketing ID: %s for user %s in CMAB experiment", bucketingID, userContext.ID))
+ }
+
+ updatedExperiment := s.createCmabExperiment(experiment)
+
+ // Check if user is in experiment traffic allocation using new bucketer method
+ entityID, reason, err := s.bucketer.BucketToEntityID(bucketingID, updatedExperiment, group)
+ if err != nil {
+ return decision, decisionReasons, err
+ }
+
+ if entityID != CmabDummyEntityID {
+ logMessage := decisionReasons.AddInfo("User %s not in CMAB experiment %s due to traffic allocation", userContext.ID, experiment.Key)
+ s.logger.Debug(logMessage)
+ decision.Reason = reason
+ return decision, decisionReasons, nil
+ }
+
+ // User passed audience and traffic allocation - now use CMAB service
+ // Get CMAB decision
+ cmabDecision, err := s.cmabService.GetDecision(projectConfig, userContext, experiment.ID, options)
+ if err != nil {
+ message := fmt.Sprintf("Failed to get CMAB decision: %v", err)
+ decisionReasons.AddInfo(message)
+ return decision, decisionReasons, fmt.Errorf("failed to get CMAB decision: %w", err)
+ }
+
+ // Find variation by ID
+ for _, variation := range experiment.Variations {
+ if variation.ID != cmabDecision.VariationID {
+ continue
+ }
+
+ // Create a copy of the variation to avoid memory aliasing
+ variationCopy := variation
+ decision.Variation = &variationCopy
+ decision.Reason = pkgReasons.CmabVariationAssigned
+
+ message := fmt.Sprintf("User bucketed into variation %s by CMAB service", variation.Key)
+ decisionReasons.AddInfo(message)
+ return decision, decisionReasons, nil
+ }
+
+ // If we get here, the variation ID returned by CMAB service was not found
+ message := fmt.Sprintf("variation with ID %s not found in experiment %s", cmabDecision.VariationID, experiment.ID)
+ decisionReasons.AddInfo(message)
+ return decision, decisionReasons, fmt.Errorf("variation with ID %s not found in experiment %s", cmabDecision.VariationID, experiment.ID)
+}
+
+func (s *ExperimentCmabService) createCmabExperiment(experiment *entities.Experiment) entities.Experiment {
+ // Guard: This method should only be called for CMAB experiments
+ if experiment.Cmab == nil {
+ // Return the experiment unchanged - this shouldn't happen in normal flow
+ return *experiment
+ }
+
+ // Create a proper deep copy for CMAB experiments
+ updatedExperiment := *experiment
+ updatedExperiment.TrafficAllocation = []entities.Range{
+ {
+ EntityID: CmabDummyEntityID, // Use special dummy ID like JavaScript
+ EndOfRange: experiment.Cmab.TrafficAllocation, // Use CMAB traffic allocation from config
+ },
+ }
+
+ // Deep copy the Cmab pointer if it exists
+ if experiment.Cmab != nil {
+ cmabCopy := *experiment.Cmab
+ updatedExperiment.Cmab = &cmabCopy
+ }
+
+ // Deep copy the AudienceConditionTree pointer if it exists
+ if experiment.AudienceConditionTree != nil {
+ treeCopy := *experiment.AudienceConditionTree
+ updatedExperiment.AudienceConditionTree = &treeCopy
+ }
+
+ // Deep copy the Variations map
+ if len(experiment.Variations) > 0 {
+ updatedExperiment.Variations = make(map[string]entities.Variation)
+ for k, v := range experiment.Variations {
+ updatedExperiment.Variations[k] = v
+ }
+ }
+
+ // Deep copy the VariationKeyToIDMap if it exists
+ if len(experiment.VariationKeyToIDMap) > 0 {
+ updatedExperiment.VariationKeyToIDMap = make(map[string]string)
+ for k, v := range experiment.VariationKeyToIDMap {
+ updatedExperiment.VariationKeyToIDMap[k] = v
+ }
+ }
+
+ // Deep copy the Whitelist map if it exists
+ if len(experiment.Whitelist) > 0 {
+ updatedExperiment.Whitelist = make(map[string]string)
+ for k, v := range experiment.Whitelist {
+ updatedExperiment.Whitelist[k] = v
+ }
+ }
+
+ // Deep copy slices
+ if len(experiment.AudienceIds) > 0 {
+ updatedExperiment.AudienceIds = make([]string, len(experiment.AudienceIds))
+ copy(updatedExperiment.AudienceIds, experiment.AudienceIds)
+ }
+
+ return updatedExperiment
+}
+
+// isCmab is a helper method to check if an experiment is a CMAB experiment
+func isCmab(experiment entities.Experiment) bool {
+ return experiment.Cmab != nil
+}
diff --git a/pkg/decision/experiment_cmab_service_test.go b/pkg/decision/experiment_cmab_service_test.go
new file mode 100644
index 000000000..d77950d3f
--- /dev/null
+++ b/pkg/decision/experiment_cmab_service_test.go
@@ -0,0 +1,682 @@
+/****************************************************************************
+ * 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 decision
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/optimizely/go-sdk/v2/pkg/cmab"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/evaluator"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+)
+
+// Mock types - MUST be at package level, not inside functions
+type MockCmabService struct {
+ mock.Mock
+}
+
+func (m *MockCmabService) GetDecision(projectConfig config.ProjectConfig, userContext entities.UserContext, ruleID string, options *decide.Options) (cmab.Decision, error) {
+ args := m.Called(projectConfig, userContext, ruleID, options)
+ return args.Get(0).(cmab.Decision), args.Error(1)
+}
+
+type MockExperimentBucketer struct {
+ mock.Mock
+}
+
+func (m *MockExperimentBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (*entities.Variation, reasons.Reason, error) {
+ args := m.Called(bucketingID, experiment, group)
+
+ var variation *entities.Variation
+ if args.Get(0) != nil {
+ variation = args.Get(0).(*entities.Variation)
+ }
+
+ return variation, args.Get(1).(reasons.Reason), args.Error(2)
+}
+
+// Add the new method to satisfy the ExperimentBucketer interface
+func (m *MockExperimentBucketer) BucketToEntityID(bucketingID string, experiment entities.Experiment, group entities.Group) (string, reasons.Reason, error) {
+ args := m.Called(bucketingID, experiment, group)
+ return args.String(0), args.Get(1).(reasons.Reason), args.Error(2)
+}
+
+type ExperimentCmabTestSuite struct {
+ suite.Suite
+ mockCmabService *MockCmabService
+ mockProjectConfig *mockProjectConfig
+ mockExperimentBucketer *MockExperimentBucketer
+ experimentCmabService *ExperimentCmabService
+ testUserContext entities.UserContext
+ options *decide.Options
+ logger logging.OptimizelyLogProducer
+ cmabExperiment entities.Experiment
+ nonCmabExperiment entities.Experiment
+}
+
+func (s *ExperimentCmabTestSuite) SetupTest() {
+ s.mockCmabService = new(MockCmabService)
+ s.mockExperimentBucketer = new(MockExperimentBucketer)
+ s.mockProjectConfig = new(mockProjectConfig)
+ s.logger = logging.GetLogger("test_sdk_key", "ExperimentCmabService")
+ s.options = &decide.Options{
+ IncludeReasons: true,
+ }
+
+ // Create service with real dependencies first
+ s.experimentCmabService = NewExperimentCmabService("test_sdk_key")
+
+ // inject the mocks
+ s.experimentCmabService.bucketer = s.mockExperimentBucketer
+ s.experimentCmabService.cmabService = s.mockCmabService
+
+ // Initialize the audience tree evaluator w logger
+ s.experimentCmabService.audienceTreeEvaluator = evaluator.NewMixedTreeEvaluator(s.logger)
+
+ // Setup test user context
+ s.testUserContext = entities.UserContext{
+ ID: "test_user_1",
+ Attributes: map[string]interface{}{
+ "attr1": "value1",
+ },
+ }
+
+ // Setup CMAB experiment
+ s.cmabExperiment = entities.Experiment{
+ ID: "cmab_exp_1",
+ Key: "cmab_experiment",
+ Cmab: &entities.Cmab{
+ AttributeIds: []string{"attr1", "attr2"},
+ },
+ Variations: map[string]entities.Variation{
+ "var1": {
+ ID: "var1",
+ Key: "variation_1",
+ },
+ "var2": {
+ ID: "var2",
+ Key: "variation_2",
+ },
+ },
+ }
+
+ // Setup non-CMAB experiment
+ s.nonCmabExperiment = entities.Experiment{
+ ID: "non_cmab_exp_1",
+ Key: "non_cmab_experiment",
+ Variations: map[string]entities.Variation{
+ "var1": {
+ ID: "var1",
+ Key: "variation_1",
+ },
+ "var2": {
+ ID: "var2",
+ Key: "variation_2",
+ },
+ },
+ }
+}
+
+func (s *ExperimentCmabTestSuite) TestIsCmab() {
+ // Test with CMAB experiment
+ s.True(isCmab(s.cmabExperiment))
+
+ // Test with non-CMAB experiment
+ s.False(isCmab(s.nonCmabExperiment))
+}
+
+func (s *ExperimentCmabTestSuite) TestGetDecisionSuccess() {
+ // Create decision context with CMAB experiment
+ decisionContext := ExperimentDecisionContext{
+ Experiment: &s.cmabExperiment,
+ ProjectConfig: s.mockProjectConfig,
+ }
+
+ // Mock bucketer to return CMAB dummy entity ID (so traffic allocation passes)
+ s.mockExperimentBucketer.On("BucketToEntityID", mock.Anything, mock.Anything, mock.Anything).
+ Return(CmabDummyEntityID, reasons.BucketedIntoVariation, nil)
+
+ // Setup mock CMAB service (only once!)
+ s.mockCmabService.On("GetDecision", s.mockProjectConfig, s.testUserContext, "cmab_exp_1", s.options).
+ Return(cmab.Decision{VariationID: "var1"}, nil)
+
+ // Create CMAB service with mocked dependencies
+ cmabService := &ExperimentCmabService{
+ bucketer: s.mockExperimentBucketer,
+ cmabService: s.mockCmabService,
+ logger: s.logger,
+ }
+
+ // Get decision
+ decision, decisionReasons, err := cmabService.GetDecision(decisionContext, s.testUserContext, s.options)
+
+ // Verify results
+ s.NotNil(decision.Variation)
+ s.Equal("var1", decision.Variation.ID)
+ s.Equal("variation_1", decision.Variation.Key)
+ s.Equal(reasons.CmabVariationAssigned, decision.Reason)
+ s.NoError(err)
+
+ // Check for the message in the reasons
+ report := decisionReasons.ToReport()
+ s.NotEmpty(report, "Decision reasons report should not be empty")
+ found := false
+ for _, msg := range report {
+ if msg == "User bucketed into variation variation_1 by CMAB service" {
+ found = true
+ break
+ }
+ }
+ s.True(found, "Expected message not found in decision reasons")
+
+ // Verify mock expectations
+ s.mockCmabService.AssertExpectations(s.T())
+ s.mockExperimentBucketer.AssertExpectations(s.T())
+}
+
+func (s *ExperimentCmabTestSuite) TestGetDecisionWithNilExperiment() {
+ // Test that nil experiment returns empty decision with appropriate reason
+ testUserContext := entities.UserContext{
+ ID: "test_user_1",
+ }
+
+ testDecisionContext := ExperimentDecisionContext{
+ Experiment: nil,
+ ProjectConfig: s.mockProjectConfig,
+ }
+
+ // Create options with reasons enabled
+ options := &decide.Options{
+ IncludeReasons: true,
+ }
+
+ // Create CMAB service with mocked dependencies
+ cmabService := &ExperimentCmabService{
+ bucketer: s.mockExperimentBucketer,
+ cmabService: s.mockCmabService,
+ logger: s.logger,
+ }
+
+ decision, decisionReasons, err := cmabService.GetDecision(testDecisionContext, testUserContext, options)
+
+ // Should NOT return an error for nil experiment (based on your implementation)
+ s.NoError(err)
+ s.Equal(ExperimentDecision{}, decision)
+
+ // Check that reasons are populated
+ s.NotEmpty(decisionReasons.ToReport())
+
+ // Check for specific reason message
+ reasonsReport := decisionReasons.ToReport()
+ expectedMessage := "experiment is nil"
+ found := false
+ for _, msg := range reasonsReport {
+ if strings.Contains(msg, expectedMessage) {
+ found = true
+ break
+ }
+ }
+ s.True(found, "Expected message not found in decision reasons")
+}
+
+func (s *ExperimentCmabTestSuite) TestGetDecisionWithNonCmabExperiment() {
+ // Test that non-CMAB experiment returns empty decision
+ testDecisionContext := ExperimentDecisionContext{
+ Experiment: &s.nonCmabExperiment,
+ ProjectConfig: s.mockProjectConfig,
+ }
+
+ decision, _, err := s.experimentCmabService.GetDecision(testDecisionContext, s.testUserContext, s.options)
+ s.NoError(err)
+ s.Equal(ExperimentDecision{}, decision)
+
+ // Since we're not adding reasons for non-CMAB experiments to avoid breaking other tests,
+ // we'll just check that the decision is empty and there's no error
+ s.Empty(decision)
+ s.NoError(err)
+}
+
+func (s *ExperimentCmabTestSuite) TestGetDecisionWithNilCmabService() {
+ // Create decision context with CMAB experiment
+ decisionContext := ExperimentDecisionContext{
+ Experiment: &s.cmabExperiment,
+ ProjectConfig: s.mockProjectConfig,
+ }
+
+ // Create CMAB service with EXPLICITLY nil CMAB service
+ cmabService := &ExperimentCmabService{
+ audienceTreeEvaluator: evaluator.NewMixedTreeEvaluator(s.logger),
+ bucketer: s.mockExperimentBucketer,
+ cmabService: nil, // ← Explicitly set to nil
+ logger: s.logger,
+ }
+
+ // Get decision
+ decision, decisionReasons, err := cmabService.GetDecision(decisionContext, s.testUserContext, s.options)
+
+ // Now it should hit the nil check and return an error
+ s.Nil(decision.Variation)
+ s.Error(err)
+ s.Equal("CMAB service is not available", err.Error())
+
+ // Check for the message in the reasons
+ report := decisionReasons.ToReport()
+ s.NotEmpty(report, "Decision reasons report should not be empty")
+ found := false
+ for _, msg := range report {
+ if msg == "CMAB service is not available" {
+ found = true
+ break
+ }
+ }
+ s.True(found, "Expected message not found in decision reasons")
+}
+
+func (s *ExperimentCmabTestSuite) TestGetDecisionWithCmabServiceError() {
+ testDecisionContext := ExperimentDecisionContext{
+ Experiment: &s.cmabExperiment, // Use s.cmabExperiment from setup
+ ProjectConfig: s.mockProjectConfig,
+ }
+
+ // Mock bucketer to return CMAB dummy entity ID (traffic allocation passes)
+ s.mockExperimentBucketer.On("BucketToEntityID", "test_user_1", mock.AnythingOfType("entities.Experiment"), entities.Group{}).
+ Return(CmabDummyEntityID, reasons.BucketedIntoVariation, nil)
+
+ // Mock CMAB service to return error
+ s.mockCmabService.On("GetDecision", s.mockProjectConfig, s.testUserContext, "cmab_exp_1", s.options).
+ Return(cmab.Decision{}, errors.New("CMAB service error"))
+
+ // Create CMAB service with mocked dependencies (same pattern as TestGetDecisionSuccess)
+ cmabService := &ExperimentCmabService{
+ bucketer: s.mockExperimentBucketer,
+ cmabService: s.mockCmabService,
+ logger: s.logger,
+ }
+
+ decision, _, err := cmabService.GetDecision(testDecisionContext, s.testUserContext, s.options)
+
+ // Should return the CMAB service error
+ s.Error(err)
+ s.Contains(err.Error(), "CMAB service error")
+ s.Nil(decision.Variation) // No variation when error occurs
+
+ s.mockExperimentBucketer.AssertExpectations(s.T())
+ s.mockCmabService.AssertExpectations(s.T())
+}
+
+func (s *ExperimentCmabTestSuite) TestGetDecisionWithInvalidVariationID() {
+ testDecisionContext := ExperimentDecisionContext{
+ Experiment: &s.cmabExperiment, // Use s.cmabExperiment from setup
+ ProjectConfig: s.mockProjectConfig,
+ }
+
+ // Mock bucketer to return CMAB dummy entity ID (traffic allocation passes)
+ s.mockExperimentBucketer.On("BucketToEntityID", "test_user_1", mock.AnythingOfType("entities.Experiment"), entities.Group{}).
+ Return(CmabDummyEntityID, reasons.BucketedIntoVariation, nil)
+
+ // Mock CMAB service to return invalid variation ID
+ invalidCmabDecision := cmab.Decision{
+ VariationID: "invalid_variation_id",
+ CmabUUID: "test-uuid-123",
+ }
+ s.mockCmabService.On("GetDecision", s.mockProjectConfig, s.testUserContext, "cmab_exp_1", s.options).
+ Return(invalidCmabDecision, nil)
+
+ // Create CMAB service with mocked dependencies (same pattern as TestGetDecisionSuccess)
+ cmabService := &ExperimentCmabService{
+ bucketer: s.mockExperimentBucketer,
+ cmabService: s.mockCmabService,
+ logger: s.logger,
+ }
+
+ decision, _, err := cmabService.GetDecision(testDecisionContext, s.testUserContext, s.options)
+
+ // Should return error for invalid variation ID
+ s.Error(err)
+ s.Contains(err.Error(), "variation with ID invalid_variation_id not found in experiment cmab_exp_1")
+ s.Nil(decision.Variation) // No variation when error occurs
+
+ s.mockExperimentBucketer.AssertExpectations(s.T())
+ s.mockCmabService.AssertExpectations(s.T())
+}
+
+func (s *ExperimentCmabTestSuite) TestGetDecisionCmabExperimentUserNotBucketed() {
+ testDecisionContext := ExperimentDecisionContext{
+ Experiment: &s.cmabExperiment,
+ ProjectConfig: s.mockProjectConfig,
+ }
+
+ // Mock bucketer - expect the MODIFIED experiment with traffic allocation
+ s.mockExperimentBucketer.On("BucketToEntityID",
+ s.testUserContext.ID, // User ID
+ mock.MatchedBy(func(exp entities.Experiment) bool {
+ // Check that it's our experiment with the modified traffic allocation
+ return exp.ID == "cmab_exp_1" &&
+ exp.Key == "cmab_experiment" &&
+ len(exp.TrafficAllocation) == 1 &&
+ exp.TrafficAllocation[0].EntityID == CmabDummyEntityID
+ }),
+ entities.Group{}, // Empty group
+ ).Return("different_entity_id", reasons.NotBucketedIntoVariation, nil) // Return something != CmabDummyEntityID
+
+ decision, _, err := s.experimentCmabService.GetDecision(testDecisionContext, s.testUserContext, s.options)
+
+ // Rest of your assertions...
+ s.NoError(err)
+ s.Equal(reasons.NotBucketedIntoVariation, decision.Reason)
+ s.Nil(decision.Variation)
+ s.Nil(decision.CmabUUID)
+
+ s.mockExperimentBucketer.AssertExpectations(s.T())
+}
+
+func (s *ExperimentCmabTestSuite) TestGetDecisionCmabExperimentAudienceConditionNotMet() {
+ // Create experiment with audience that will actually fail
+ cmabExperimentWithAudience := entities.Experiment{
+ ID: "cmab_exp_with_audience",
+ Key: "cmab_experiment_with_audience",
+ Cmab: &entities.Cmab{
+ AttributeIds: []string{"attr1", "attr2"},
+ TrafficAllocation: 10000,
+ },
+ AudienceIds: []string{"audience_1"},
+ // CORRECT AudienceConditionTree structure:
+ AudienceConditionTree: &entities.TreeNode{
+ Operator: "or",
+ Nodes: []*entities.TreeNode{
+ {
+ Item: "audience_1", // Reference the audience ID
+ },
+ },
+ },
+ Variations: map[string]entities.Variation{
+ "var1": {ID: "var1", Key: "variation_1"},
+ },
+ TrafficAllocation: []entities.Range{
+ {EntityID: "$", EndOfRange: 10000},
+ },
+ }
+
+ // User that will NOT match the audience
+ userContextNoAudience := entities.UserContext{
+ ID: "test_user_no_audience",
+ Attributes: map[string]interface{}{
+ "country": "US", // This won't match our audience condition
+ },
+ }
+
+ // Create audience with condition tree that requires Canada
+ audienceMap := map[string]entities.Audience{
+ "audience_1": {
+ ID: "audience_1",
+ Name: "Test Audience",
+ ConditionTree: &entities.TreeNode{
+ Operator: "or",
+ Nodes: []*entities.TreeNode{
+ {
+ Item: entities.Condition{
+ Type: "custom_attribute",
+ Match: "exact",
+ Name: "country",
+ Value: "Canada",
+ },
+ },
+ },
+ },
+ },
+ }
+ s.mockProjectConfig.On("GetAudienceMap").Return(audienceMap)
+
+ // This mock should NOT be called if audience fails
+ s.mockExperimentBucketer.On("BucketToEntityID", mock.Anything, mock.Anything, mock.Anything).Return("", reasons.NotBucketedIntoVariation, nil).Maybe()
+
+ testDecisionContext := ExperimentDecisionContext{
+ Experiment: &cmabExperimentWithAudience,
+ ProjectConfig: s.mockProjectConfig,
+ }
+
+ decision, _, err := s.experimentCmabService.GetDecision(testDecisionContext, userContextNoAudience, s.options)
+
+ s.NoError(err)
+ s.Equal(reasons.FailedAudienceTargeting, decision.Reason)
+ s.Nil(decision.Variation)
+
+ s.mockProjectConfig.AssertExpectations(s.T())
+}
+
+func (s *ExperimentCmabTestSuite) TestCreateCmabExperiment() {
+ // Test with nil Cmab pointer - should hit the nil check lines
+ experiment := &entities.Experiment{
+ ID: "test-exp",
+ Key: "test-key",
+ Cmab: nil, // This is what we want to test
+ TrafficAllocation: []entities.Range{
+ {
+ EntityID: "entity_id_placeholder",
+ EndOfRange: 5000,
+ },
+ },
+ Variations: make(map[string]entities.Variation),
+ AudienceIds: []string{},
+ }
+
+ result := s.experimentCmabService.createCmabExperiment(experiment)
+ s.NotNil(result) // Just make sure it doesn't panic
+
+ // Test with populated experiment to hit all deep copy paths
+ experiment.Cmab = &entities.Cmab{TrafficAllocation: 8000}
+ experiment.AudienceConditionTree = &entities.TreeNode{}
+ experiment.Variations = map[string]entities.Variation{"var1": {}}
+ experiment.AudienceIds = []string{"aud1"}
+
+ result = s.experimentCmabService.createCmabExperiment(experiment)
+ s.NotNil(result)
+}
+
+func (s *ExperimentCmabTestSuite) TestGetDecisionUserNotInAudience() {
+ // This should hit lines 129-131 and 139-141
+ cmabExperimentWithFailingAudience := entities.Experiment{
+ ID: "cmab_exp_failing_audience",
+ Key: "cmab_experiment_failing",
+ Cmab: &entities.Cmab{
+ AttributeIds: []string{"attr1"},
+ TrafficAllocation: 10000,
+ },
+ AudienceIds: []string{"impossible_audience"},
+ AudienceConditionTree: &entities.TreeNode{
+ Operator: "or",
+ Nodes: []*entities.TreeNode{
+ {Item: "impossible_audience"},
+ },
+ },
+ Variations: map[string]entities.Variation{
+ "var1": {ID: "var1", Key: "variation_1"},
+ },
+ }
+
+ // Mock audience that will never match
+ audienceMap := map[string]entities.Audience{
+ "impossible_audience": {
+ ID: "impossible_audience",
+ ConditionTree: &entities.TreeNode{
+ Operator: "or",
+ Nodes: []*entities.TreeNode{
+ {
+ Item: entities.Condition{
+ Type: "custom_attribute",
+ Match: "exact",
+ Name: "impossible_attr",
+ Value: "impossible_value",
+ },
+ },
+ },
+ },
+ },
+ }
+ s.mockProjectConfig.On("GetAudienceMap").Return(audienceMap)
+
+ decisionContext := ExperimentDecisionContext{
+ Experiment: &cmabExperimentWithFailingAudience,
+ ProjectConfig: s.mockProjectConfig,
+ }
+
+ decision, _, err := s.experimentCmabService.GetDecision(decisionContext, s.testUserContext, s.options)
+
+ s.NoError(err)
+ s.Equal(reasons.FailedAudienceTargeting, decision.Reason)
+ s.Nil(decision.Variation)
+}
+
+func (s *ExperimentCmabTestSuite) TestGetDecisionBucketingError() {
+ // Test bucketing ID error (lines 134-137)
+ decisionContext := ExperimentDecisionContext{
+ Experiment: &s.cmabExperiment,
+ ProjectConfig: s.mockProjectConfig,
+ }
+
+ // Mock bucketer to return an error
+ s.mockExperimentBucketer.On("BucketToEntityID",
+ s.testUserContext.ID,
+ mock.AnythingOfType("entities.Experiment"),
+ entities.Group{},
+ ).Return("", reasons.NotBucketedIntoVariation, errors.New("bucketing failed"))
+
+ decision, _, err := s.experimentCmabService.GetDecision(decisionContext, s.testUserContext, s.options)
+
+ s.Error(err)
+ s.Contains(err.Error(), "bucketing failed")
+ s.Nil(decision.Variation)
+
+ s.mockExperimentBucketer.AssertExpectations(s.T())
+}
+
+func (s *ExperimentCmabTestSuite) TestGetDecisionTrafficAllocationError() {
+ // Test traffic allocation error (lines 147-149)
+ decisionContext := ExperimentDecisionContext{
+ Experiment: &s.cmabExperiment,
+ ProjectConfig: s.mockProjectConfig,
+ }
+
+ // Mock bucketer to return empty string (traffic allocation failure)
+ s.mockExperimentBucketer.On("BucketToEntityID",
+ s.testUserContext.ID,
+ mock.AnythingOfType("entities.Experiment"),
+ entities.Group{},
+ ).Return("", reasons.NotBucketedIntoVariation, nil) // No error, but empty result
+
+ decision, _, err := s.experimentCmabService.GetDecision(decisionContext, s.testUserContext, s.options)
+
+ s.NoError(err) // Should not error, just not bucketed
+ s.Equal(reasons.NotBucketedIntoVariation, decision.Reason)
+ s.Nil(decision.Variation)
+
+ s.mockExperimentBucketer.AssertExpectations(s.T())
+}
+
+func (s *ExperimentCmabTestSuite) TestCreateCmabExperimentDeepCopy() {
+ // Test all the deep copy branches with comprehensive data
+ experiment := &entities.Experiment{
+ ID: "test-exp",
+ Key: "test-key",
+ Cmab: &entities.Cmab{TrafficAllocation: 8000},
+
+ // Test AudienceConditionTree copy (lines ~220)
+ AudienceConditionTree: &entities.TreeNode{
+ Operator: "or",
+ Nodes: []*entities.TreeNode{{Item: "test"}},
+ },
+
+ // Test Variations map copy (lines ~225)
+ Variations: map[string]entities.Variation{
+ "var1": {ID: "var1", Key: "variation_1"},
+ "var2": {ID: "var2", Key: "variation_2"},
+ },
+
+ // Test VariationKeyToIDMap copy (lines ~232)
+ VariationKeyToIDMap: map[string]string{
+ "variation_1": "var1",
+ "variation_2": "var2",
+ },
+
+ // Test Whitelist map copy (lines ~238)
+ Whitelist: map[string]string{
+ "user1": "var1",
+ "user2": "var2",
+ },
+
+ // Test AudienceIds slice copy (lines ~244)
+ AudienceIds: []string{"aud1", "aud2"},
+ }
+
+ result := s.experimentCmabService.createCmabExperiment(experiment)
+
+ // Verify the method ran and returned something
+ s.NotNil(result)
+
+ // Check that traffic allocation was modified (this is the main purpose)
+ s.Equal(1, len(result.TrafficAllocation))
+ s.Equal(CmabDummyEntityID, result.TrafficAllocation[0].EntityID)
+ s.Equal(8000, result.TrafficAllocation[0].EndOfRange)
+
+ // Verify deep copies were made (content should be same, but separate objects)
+ s.Equal(experiment.AudienceConditionTree.Operator, result.AudienceConditionTree.Operator)
+ s.Equal(len(experiment.Variations), len(result.Variations))
+ s.Equal(len(experiment.VariationKeyToIDMap), len(result.VariationKeyToIDMap))
+ s.Equal(len(experiment.Whitelist), len(result.Whitelist))
+ s.Equal(len(experiment.AudienceIds), len(result.AudienceIds))
+
+ // Verify content is preserved (FIX: Check the actual values!)
+ s.Equal("var1", result.VariationKeyToIDMap["variation_1"]) // ← Fixed!
+ s.Equal("var1", result.Whitelist["user1"])
+ s.Equal("aud1", result.AudienceIds[0])
+}
+
+func (s *ExperimentCmabTestSuite) TestCreateCmabExperimentEmptyFields() {
+ // Test with empty/nil fields to hit the negative branches
+ experiment := &entities.Experiment{
+ ID: "test-exp",
+ Key: "test-key",
+ Cmab: &entities.Cmab{TrafficAllocation: 5000},
+
+ // All these should be empty/nil to test the negative branches
+ AudienceConditionTree: nil,
+ Variations: nil,
+ VariationKeyToIDMap: nil,
+ Whitelist: nil,
+ AudienceIds: nil,
+ }
+
+ result := s.experimentCmabService.createCmabExperiment(experiment)
+
+ // Should still work and create traffic allocation
+ s.Equal(1, len(result.TrafficAllocation))
+ s.Equal(CmabDummyEntityID, result.TrafficAllocation[0].EntityID)
+ s.Equal(5000, result.TrafficAllocation[0].EndOfRange)
+}
+
+func TestExperimentCmabTestSuite(t *testing.T) {
+ suite.Run(t, new(ExperimentCmabTestSuite))
+}
diff --git a/pkg/decision/experiment_override_service.go b/pkg/decision/experiment_override_service.go
index 3266f3a06..290af09ba 100644
--- a/pkg/decision/experiment_override_service.go
+++ b/pkg/decision/experiment_override_service.go
@@ -22,10 +22,10 @@ import (
"fmt"
"sync"
- "github.com/optimizely/go-sdk/pkg/decide"
- pkgReasons "github.com/optimizely/go-sdk/pkg/decision/reasons"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ pkgReasons "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// ExperimentOverrideKey represents the user ID and experiment associated with an override variation
diff --git a/pkg/decision/experiment_override_service_test.go b/pkg/decision/experiment_override_service_test.go
index d1fa7cf57..cfa38d3aa 100644
--- a/pkg/decision/experiment_override_service_test.go
+++ b/pkg/decision/experiment_override_service_test.go
@@ -21,10 +21,10 @@ import (
"sync"
"testing"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision/reasons"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
"github.com/stretchr/testify/suite"
)
diff --git a/pkg/decision/experiment_whitelist_service.go b/pkg/decision/experiment_whitelist_service.go
index b05dba1a8..ad2d614e6 100644
--- a/pkg/decision/experiment_whitelist_service.go
+++ b/pkg/decision/experiment_whitelist_service.go
@@ -20,9 +20,9 @@ package decision
import (
"errors"
- "github.com/optimizely/go-sdk/pkg/decide"
- pkgReasons "github.com/optimizely/go-sdk/pkg/decision/reasons"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ pkgReasons "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// ExperimentWhitelistService makes a decision using an experiment's whitelist (a map of user id to variation keys)
diff --git a/pkg/decision/experiment_whitelist_service_test.go b/pkg/decision/experiment_whitelist_service_test.go
index 508084df1..598a4fd3b 100644
--- a/pkg/decision/experiment_whitelist_service_test.go
+++ b/pkg/decision/experiment_whitelist_service_test.go
@@ -20,9 +20,9 @@ package decision
import (
"testing"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision/reasons"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/stretchr/testify/suite"
)
diff --git a/pkg/decision/feature_experiment_service.go b/pkg/decision/feature_experiment_service.go
index e8c8e0ab8..f8c7132b8 100644
--- a/pkg/decision/feature_experiment_service.go
+++ b/pkg/decision/feature_experiment_service.go
@@ -20,9 +20,9 @@ package decision
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// FeatureExperimentService helps evaluate feature test associated with the feature
@@ -64,6 +64,7 @@ func (f FeatureExperimentService) GetDecision(decisionContext FeatureDecisionCon
experimentDecisionContext := ExperimentDecisionContext{
Experiment: &experiment,
ProjectConfig: decisionContext.ProjectConfig,
+ UserProfile: decisionContext.UserProfile,
}
experimentDecision, decisionReasons, err := f.compositeExperimentService.GetDecision(experimentDecisionContext, userContext, options)
diff --git a/pkg/decision/feature_experiment_service_test.go b/pkg/decision/feature_experiment_service_test.go
index f08961bfe..d7160d568 100644
--- a/pkg/decision/feature_experiment_service_test.go
+++ b/pkg/decision/feature_experiment_service_test.go
@@ -19,9 +19,9 @@ package decision
import (
"testing"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
"github.com/stretchr/testify/suite"
)
diff --git a/pkg/decision/feature_notification.go b/pkg/decision/feature_notification.go
index 39fbf4f5d..fdb856c45 100644
--- a/pkg/decision/feature_notification.go
+++ b/pkg/decision/feature_notification.go
@@ -18,8 +18,8 @@
package decision
import (
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
)
// FeatureNotificationWithVariables constructs feature notification with variables
diff --git a/pkg/decision/feature_notification_test.go b/pkg/decision/feature_notification_test.go
index c75d22081..577fe7aa6 100644
--- a/pkg/decision/feature_notification_test.go
+++ b/pkg/decision/feature_notification_test.go
@@ -20,8 +20,8 @@ package decision
import (
"testing"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
"github.com/stretchr/testify/assert"
)
diff --git a/pkg/decision/flag_notification.go b/pkg/decision/flag_notification.go
index a4c718fe5..5c43f7937 100644
--- a/pkg/decision/flag_notification.go
+++ b/pkg/decision/flag_notification.go
@@ -18,12 +18,12 @@
package decision
import (
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
)
// FlagNotification constructs default flag notification
-func FlagNotification(flagKey, variationKey, ruleKey string, enabled, decisionEventDispatched bool, userContext entities.UserContext, variables map[string]interface{}, reasons []string) *notification.DecisionNotification {
+func FlagNotification(flagKey, variationKey, ruleKey, experimentID, variationID string, enabled, decisionEventDispatched bool, userContext entities.UserContext, variables map[string]interface{}, reasons []string) *notification.DecisionNotification {
if flagKey == "" {
return nil
@@ -37,6 +37,8 @@ func FlagNotification(flagKey, variationKey, ruleKey string, enabled, decisionEv
"ruleKey": ruleKey,
"reasons": reasons,
"decisionEventDispatched": decisionEventDispatched,
+ "experimentId": experimentID,
+ "variationId": variationID,
}
decisionNotification := ¬ification.DecisionNotification{
diff --git a/pkg/decision/forced_decision_service.go b/pkg/decision/forced_decision_service.go
index 50fc9379c..0f5fd1f1d 100644
--- a/pkg/decision/forced_decision_service.go
+++ b/pkg/decision/forced_decision_service.go
@@ -21,9 +21,9 @@ import (
"errors"
"sync"
- "github.com/optimizely/go-sdk/pkg/config"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// OptimizelyDecisionContext defines Decision Context
diff --git a/pkg/decision/forced_decision_service_test.go b/pkg/decision/forced_decision_service_test.go
index b6b9ae312..ddb813709 100644
--- a/pkg/decision/forced_decision_service_test.go
+++ b/pkg/decision/forced_decision_service_test.go
@@ -24,8 +24,8 @@ import (
"sync"
"testing"
- "github.com/optimizely/go-sdk/pkg/config"
- "github.com/optimizely/go-sdk/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
"github.com/stretchr/testify/suite"
)
diff --git a/pkg/decision/helpers_test.go b/pkg/decision/helpers_test.go
index 70cc44419..56a7e1687 100644
--- a/pkg/decision/helpers_test.go
+++ b/pkg/decision/helpers_test.go
@@ -22,9 +22,9 @@
package decision
import (
- "github.com/optimizely/go-sdk/pkg/config"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/stretchr/testify/mock"
)
diff --git a/pkg/decision/interface.go b/pkg/decision/interface.go
index ec5a4cadc..ae45db515 100644
--- a/pkg/decision/interface.go
+++ b/pkg/decision/interface.go
@@ -18,9 +18,9 @@
package decision
import (
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
)
// Service interface is used to make a decision for a given feature or experiment
diff --git a/pkg/decision/persisting_experiment_service.go b/pkg/decision/persisting_experiment_service.go
index 6055c8f3b..72f7d5899 100644
--- a/pkg/decision/persisting_experiment_service.go
+++ b/pkg/decision/persisting_experiment_service.go
@@ -20,9 +20,9 @@ package decision
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// PersistingExperimentService attempts to retrieve a saved decision from the user profile service
@@ -52,6 +52,8 @@ func (p PersistingExperimentService) GetDecision(decisionContext ExperimentDecis
return p.experimentBucketedService.GetDecision(decisionContext, userContext, options)
}
+ isUserProfileNil := decisionContext.UserProfile == nil
+
var userProfile UserProfile
var decisionReasons decide.DecisionReasons
// check to see if there is a saved decision for the user
@@ -66,7 +68,16 @@ func (p PersistingExperimentService) GetDecision(decisionContext ExperimentDecis
if experimentDecision.Variation != nil {
// save decision if a user profile service is provided
userProfile.ID = userContext.ID
- p.saveDecision(userProfile, decisionContext.Experiment, experimentDecision)
+ decisionKey := NewUserDecisionKey(decisionContext.Experiment.ID)
+ if isUserProfileNil {
+ p.saveDecision(userProfile, decisionKey, experimentDecision)
+ } else {
+ if decisionContext.UserProfile.ExperimentBucketMap == nil {
+ decisionContext.UserProfile.ExperimentBucketMap = make(map[UserDecisionKey]string)
+ }
+ decisionContext.UserProfile.ExperimentBucketMap[decisionKey] = experimentDecision.Variation.ID
+ decisionContext.UserProfile.HasUnsavedChange = true
+ }
}
return experimentDecision, reasons, err
@@ -75,7 +86,12 @@ func (p PersistingExperimentService) GetDecision(decisionContext ExperimentDecis
func (p PersistingExperimentService) getSavedDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext, options *decide.Options) (ExperimentDecision, UserProfile, decide.DecisionReasons) {
reasons := decide.NewDecisionReasons(options)
experimentDecision := ExperimentDecision{}
- userProfile := p.userProfileService.Lookup(userContext.ID)
+ var userProfile UserProfile
+ if decisionContext.UserProfile == nil {
+ userProfile = p.userProfileService.Lookup(userContext.ID)
+ } else {
+ userProfile = *decisionContext.UserProfile
+ }
// look up experiment decision from user profile
decisionKey := NewUserDecisionKey(decisionContext.Experiment.ID)
@@ -97,9 +113,8 @@ func (p PersistingExperimentService) getSavedDecision(decisionContext Experiment
return experimentDecision, userProfile, reasons
}
-func (p PersistingExperimentService) saveDecision(userProfile UserProfile, experiment *entities.Experiment, decision ExperimentDecision) {
+func (p PersistingExperimentService) saveDecision(userProfile UserProfile, decisionKey UserDecisionKey, decision ExperimentDecision) {
if p.userProfileService != nil {
- decisionKey := NewUserDecisionKey(experiment.ID)
if userProfile.ExperimentBucketMap == nil {
userProfile.ExperimentBucketMap = map[UserDecisionKey]string{}
}
diff --git a/pkg/decision/persisting_experiment_service_test.go b/pkg/decision/persisting_experiment_service_test.go
index f9fa47c1f..d3fc21f92 100644
--- a/pkg/decision/persisting_experiment_service_test.go
+++ b/pkg/decision/persisting_experiment_service_test.go
@@ -20,9 +20,9 @@ package decision
import (
"testing"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
diff --git a/pkg/decision/reasons/reason.go b/pkg/decision/reasons/reason.go
index 4814fb69b..74d5a58ca 100644
--- a/pkg/decision/reasons/reason.go
+++ b/pkg/decision/reasons/reason.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2021, Optimizely, Inc. and contributors *
+ * Copyright 2019-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. *
@@ -59,4 +59,6 @@ const (
InvalidOverrideVariationAssignment Reason = "Invalid override variation assignment"
// OverrideVariationAssignmentFound - A valid override variation was found for the given user and experiment
OverrideVariationAssignmentFound Reason = "Override variation assignment found"
+ // CmabVariationAssigned is the reason when a variation is assigned by the CMAB service
+ CmabVariationAssigned Reason = "cmab_variation_assigned"
)
diff --git a/pkg/decision/rollout_service.go b/pkg/decision/rollout_service.go
index e42a18693..13c1402c2 100644
--- a/pkg/decision/rollout_service.go
+++ b/pkg/decision/rollout_service.go
@@ -21,11 +21,11 @@ import (
"fmt"
"strconv"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision/evaluator"
- pkgReasons "github.com/optimizely/go-sdk/pkg/decision/reasons"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/evaluator"
+ pkgReasons "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// RolloutService makes a feature decision for a given feature rollout
diff --git a/pkg/decision/rollout_service_test.go b/pkg/decision/rollout_service_test.go
index 9bf160dbc..70cd20b03 100644
--- a/pkg/decision/rollout_service_test.go
+++ b/pkg/decision/rollout_service_test.go
@@ -20,11 +20,11 @@ import (
"fmt"
"testing"
- "github.com/optimizely/go-sdk/pkg/decide"
- "github.com/optimizely/go-sdk/pkg/decision/evaluator"
- "github.com/optimizely/go-sdk/pkg/decision/reasons"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/decide"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/evaluator"
+ "github.com/optimizely/go-sdk/v2/pkg/decision/reasons"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
diff --git a/pkg/entities/experiment.go b/pkg/entities/experiment.go
index 24692ae1a..50001cc28 100644
--- a/pkg/entities/experiment.go
+++ b/pkg/entities/experiment.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019,2021, Optimizely, Inc. and contributors *
+ * Copyright 2019,2021-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. *
@@ -25,6 +25,12 @@ type Variation struct {
FeatureEnabled bool
}
+// Cmab represents the Contextual Multi-Armed Bandit configuration for an experiment
+type Cmab struct {
+ AttributeIds []string `json:"attributes"`
+ TrafficAllocation int `json:"trafficAllocation"`
+}
+
// Experiment represents an experiment
type Experiment struct {
AudienceIds []string
@@ -39,6 +45,7 @@ type Experiment struct {
AudienceConditionTree *TreeNode
Whitelist map[string]string
IsFeatureExperiment bool
+ Cmab *Cmab
}
// Range represents bucketing range that the specify entityID falls into
diff --git a/pkg/entities/user_context.go b/pkg/entities/user_context.go
index 99e54544a..881f4fd91 100644
--- a/pkg/entities/user_context.go
+++ b/pkg/entities/user_context.go
@@ -20,7 +20,7 @@ package entities
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/utils"
)
const bucketingIDAttributeName = "$opt_bucketing_id"
diff --git a/pkg/event/dispatcher.go b/pkg/event/dispatcher.go
index 1fd7610b8..8d01c79ed 100644
--- a/pkg/event/dispatcher.go
+++ b/pkg/event/dispatcher.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019, Optimizely, Inc. and contributors *
+ * Copyright 2019,2023 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. *
@@ -18,15 +18,16 @@
package event
import (
+ "context"
"fmt"
"net/http"
"time"
"golang.org/x/sync/semaphore"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/metrics"
- "github.com/optimizely/go-sdk/pkg/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/metrics"
+ "github.com/optimizely/go-sdk/v2/pkg/utils"
)
const maxWorkers = int64(1)
@@ -100,9 +101,26 @@ func (ed *QueueEventDispatcher) DispatchEvent(event LogEvent) (bool, error) {
return true, nil
}
+// waitForDispatchingEventsOnClose will wait until all the event are dispatched
+func (ed *QueueEventDispatcher) waitForDispatchingEventsOnClose(timeout time.Duration) {
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ if ed.eventQueue.Size() == 0 {
+ return
+ }
+ time.Sleep(CloseEventDispatchWaitTime)
+ }
+ }
+}
+
// flush the events
func (ed *QueueEventDispatcher) flushEvents() {
-
// Limit flushing to a single worker
if !ed.processing.TryAcquire(1) {
return
diff --git a/pkg/event/dispatcher_test.go b/pkg/event/dispatcher_test.go
index 3ccd596dd..f3af38952 100644
--- a/pkg/event/dispatcher_test.go
+++ b/pkg/event/dispatcher_test.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2020,2022 Optimizely, Inc. and contributors *
+ * Copyright 2019-2020,2022-2023 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. *
@@ -22,8 +22,8 @@ import (
"testing"
"time"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/metrics"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/metrics"
"github.com/stretchr/testify/assert"
)
@@ -197,3 +197,40 @@ func TestQueueEventDispatcher_FailDispath(t *testing.T) {
assert.Equal(t, float64(1), metricsRegistry.GetGauge(metrics.DispatcherQueueSize).(*MetricsGauge).Get())
assert.Equal(t, float64(0), metricsRegistry.GetCounter(metrics.DispatcherSuccessFlush).(*MetricsCounter).Get())
}
+
+func TestQueueEventDispatcher_WaitForDispatchingEventsOnClose(t *testing.T) {
+ metricsRegistry := NewMetricsRegistry()
+
+ q := NewQueueEventDispatcher("", metricsRegistry)
+
+ assert.True(t, q.Dispatcher != nil)
+ if d, ok := q.Dispatcher.(*httpEventDispatcher); ok {
+ assert.True(t, d.requester != nil && d.logger != nil)
+ } else {
+ assert.True(t, false)
+ }
+ sender := &MockDispatcher{Events: NewInMemoryQueue(100), eventsQueue: NewInMemoryQueue(100)}
+ q.Dispatcher = sender
+
+ eventTags := map[string]interface{}{"revenue": 55.0, "value": 25.1}
+ config := TestConfig{}
+
+ for i := 0; i < 10; i++ {
+ conversionUserEvent := CreateConversionUserEvent(config, entities.Event{ExperimentIds: []string{"15402980349"}, ID: "15368860886", Key: "sample_conversion"}, userContext, eventTags)
+
+ batch := createBatchEvent(conversionUserEvent, createVisitorFromUserEvent(conversionUserEvent))
+ assert.Equal(t, conversionUserEvent.Timestamp, batch.Visitors[0].Snapshots[0].Events[0].Timestamp)
+
+ logEvent := createLogEvent(batch, DefaultEventEndPoint)
+
+ success, _ := q.DispatchEvent(logEvent)
+
+ assert.True(t, success)
+ }
+
+ // wait for the events to be dispatched
+ q.waitForDispatchingEventsOnClose(10 * time.Second)
+
+ // check the queue
+ assert.Equal(t, 0, q.eventQueue.Size())
+}
diff --git a/pkg/event/factory.go b/pkg/event/factory.go
index a0de25f9a..c15369711 100644
--- a/pkg/event/factory.go
+++ b/pkg/event/factory.go
@@ -24,10 +24,10 @@ import (
guuid "github.com/google/uuid"
- "github.com/optimizely/go-sdk/pkg/config"
- decisionPkg "github.com/optimizely/go-sdk/pkg/decision"
- "github.com/optimizely/go-sdk/pkg/entities"
- "github.com/optimizely/go-sdk/pkg/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ decisionPkg "github.com/optimizely/go-sdk/v2/pkg/decision"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/utils"
)
const impressionKey string = "campaign_activated"
diff --git a/pkg/event/factory_test.go b/pkg/event/factory_test.go
index 04df215bd..ea4ed82dd 100644
--- a/pkg/event/factory_test.go
+++ b/pkg/event/factory_test.go
@@ -25,9 +25,9 @@ import (
"github.com/stretchr/testify/assert"
- "github.com/optimizely/go-sdk/pkg/config"
- "github.com/optimizely/go-sdk/pkg/decision"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/config"
+ "github.com/optimizely/go-sdk/v2/pkg/decision"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
var testExperiment = entities.Experiment{
diff --git a/pkg/event/processor.go b/pkg/event/processor.go
index 8e4754ceb..4cefda214 100644
--- a/pkg/event/processor.go
+++ b/pkg/event/processor.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2020, Optimizely, Inc. and contributors *
+ * Copyright 2019-2020,2023 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. *
@@ -26,10 +26,10 @@ import (
"golang.org/x/sync/semaphore"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/metrics"
- "github.com/optimizely/go-sdk/pkg/notification"
- "github.com/optimizely/go-sdk/pkg/registry"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/metrics"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/registry"
)
// Processor processes events
@@ -65,6 +65,12 @@ const DefaultEventQueueSize = 2000
// DefaultEventFlushInterval holds the default value for the event flush interval
const DefaultEventFlushInterval = 30 * time.Second
+// CloseEventDispatchWaitTime holds the checking interval for the dispatching events on client close
+const CloseEventDispatchWaitTime = 500 * time.Millisecond
+
+// CloseEventDispatchTimeout holds the timeout value for the waiting for the dispatching events on client close
+const CloseEventDispatchTimeout = 30 * time.Second
+
// DefaultEventEndPoint is used as the default endpoint for sending events.
const DefaultEventEndPoint = "https://logx.optimizely.com/v1/events"
@@ -245,7 +251,9 @@ func (p *BatchEventProcessor) startTicker(ctx context.Context) {
d, ok := p.EventDispatcher.(*QueueEventDispatcher)
if ok {
d.flushEvents()
+ d.waitForDispatchingEventsOnClose(CloseEventDispatchTimeout)
}
+ p.Ticker.Stop()
return
}
}
diff --git a/pkg/event/processor_test.go b/pkg/event/processor_test.go
index 2a77fb8c6..5f5fd1580 100644
--- a/pkg/event/processor_test.go
+++ b/pkg/event/processor_test.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019-2020, Optimizely, Inc. and contributors *
+ * Copyright 2019-2020,2023 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. *
@@ -27,8 +27,8 @@ import (
"github.com/stretchr/testify/assert"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/utils"
)
type CountingDispatcher struct {
@@ -43,8 +43,9 @@ func (c *CountingDispatcher) DispatchEvent(event LogEvent) (bool, error) {
}
type MockDispatcher struct {
- ShouldFail bool
- Events Queue
+ ShouldFail bool
+ Events Queue
+ eventsQueue Queue // dispatch events from this queue
}
func (m *MockDispatcher) DispatchEvent(event LogEvent) (bool, error) {
@@ -53,11 +54,22 @@ func (m *MockDispatcher) DispatchEvent(event LogEvent) (bool, error) {
}
m.Events.Add(event)
+ if m.eventsQueue != nil {
+ m.eventsQueue.Add(event)
+ go m.flushEvents()
+ }
return true, nil
}
+func (m *MockDispatcher) flushEvents() {
+ queueSize := m.eventsQueue.Size()
+ for ; queueSize > 0; queueSize = m.eventsQueue.Size() {
+ m.eventsQueue.Remove(1)
+ }
+}
+
func NewMockDispatcher(queueSize int, shouldFail bool) *MockDispatcher {
- return &MockDispatcher{Events: NewInMemoryQueue(queueSize), ShouldFail: shouldFail}
+ return &MockDispatcher{Events: NewInMemoryQueue(queueSize), eventsQueue: NewInMemoryQueue(queueSize), ShouldFail: shouldFail}
}
func newExecutionContext() *utils.ExecGroup {
@@ -180,7 +192,6 @@ func TestDefaultEventProcessor_BatchSizes(t *testing.T) {
assert.Equal(t, 50, len(logEvent.Event.Visitors))
logEvent, _ = evs[1].(LogEvent)
assert.Equal(t, 50, len(logEvent.Event.Visitors))
-
}
eg.TerminateAndWait()
}
@@ -493,7 +504,7 @@ func (l *NoOpLogger) SetLogLevel(level logging.LogLevel) {
}
-/**
+/*
goos: darwin
goarch: amd64
pkg: github.com/optimizely/go-sdk/pkg/event
diff --git a/pkg/event/queue.go b/pkg/event/queue.go
index d8470f60a..33715808d 100644
--- a/pkg/event/queue.go
+++ b/pkg/event/queue.go
@@ -20,7 +20,7 @@ package event
import (
"sync"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// Queue represents a queue
diff --git a/pkg/event/version.go b/pkg/event/version.go
index 421316411..c2a04684a 100644
--- a/pkg/event/version.go
+++ b/pkg/event/version.go
@@ -18,7 +18,7 @@
package event
// Version is the current version of the client
-var Version = "2.0.0-beta"
+var Version = "2.1.0"
// ClientName is the name of the client
var ClientName = "go-sdk"
diff --git a/pkg/notification/center.go b/pkg/notification/center.go
index dd6abbfd6..08772fe47 100644
--- a/pkg/notification/center.go
+++ b/pkg/notification/center.go
@@ -19,7 +19,8 @@ package notification
import (
"fmt"
- "github.com/optimizely/go-sdk/pkg/logging"
+
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// Center handles all notification listeners. It keeps track of the Manager for each type of notification.
diff --git a/pkg/notification/center_test.go b/pkg/notification/center_test.go
index 169f7367f..40e85a3e6 100644
--- a/pkg/notification/center_test.go
+++ b/pkg/notification/center_test.go
@@ -5,7 +5,7 @@ import (
"github.com/stretchr/testify/assert"
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
"github.com/stretchr/testify/mock"
)
diff --git a/pkg/notification/entities.go b/pkg/notification/entities.go
index edb801c9c..46fc7f207 100644
--- a/pkg/notification/entities.go
+++ b/pkg/notification/entities.go
@@ -18,7 +18,7 @@
package notification
import (
- "github.com/optimizely/go-sdk/pkg/entities"
+ "github.com/optimizely/go-sdk/v2/pkg/entities"
)
// Type is the type of notification
diff --git a/pkg/notification/manager.go b/pkg/notification/manager.go
index 919d59930..8ae6b148b 100644
--- a/pkg/notification/manager.go
+++ b/pkg/notification/manager.go
@@ -22,7 +22,7 @@ import (
"sync"
"sync/atomic"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// Manager is a generic interface for managing notifications of a particular type
diff --git a/pkg/notification/manager_test.go b/pkg/notification/manager_test.go
index 7c3a0f773..ebcd9ad4f 100644
--- a/pkg/notification/manager_test.go
+++ b/pkg/notification/manager_test.go
@@ -3,7 +3,7 @@ package notification
import (
"testing"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
diff --git a/pkg/odp/config/config.go b/pkg/odp/config/config.go
index ee865f648..d26d844b2 100644
--- a/pkg/odp/config/config.go
+++ b/pkg/odp/config/config.go
@@ -20,7 +20,7 @@ package config
import (
"sync"
- "github.com/optimizely/go-sdk/pkg/odp/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
)
// Config is used to represent odp config
diff --git a/pkg/odp/event/event_api_manager.go b/pkg/odp/event/event_api_manager.go
index e05a55ae6..128ec562d 100644
--- a/pkg/odp/event/event_api_manager.go
+++ b/pkg/odp/event/event_api_manager.go
@@ -21,9 +21,9 @@ import (
"fmt"
"net/url"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/odp/utils"
- pkgUtils "github.com/optimizely/go-sdk/pkg/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
+ pkgUtils "github.com/optimizely/go-sdk/v2/pkg/utils"
)
// APIManager represents the event API manager.
diff --git a/pkg/odp/event/event_api_manager_test.go b/pkg/odp/event/event_api_manager_test.go
index 29e580bf4..355e9a376 100644
--- a/pkg/odp/event/event_api_manager_test.go
+++ b/pkg/odp/event/event_api_manager_test.go
@@ -26,8 +26,8 @@ import (
"testing"
"time"
- "github.com/optimizely/go-sdk/pkg/odp/utils"
- pkgUtils "github.com/optimizely/go-sdk/pkg/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
+ pkgUtils "github.com/optimizely/go-sdk/v2/pkg/utils"
"github.com/stretchr/testify/suite"
)
diff --git a/pkg/odp/event/event_manager.go b/pkg/odp/event/event_manager.go
index 758537311..18dbad1fd 100644
--- a/pkg/odp/event/event_manager.go
+++ b/pkg/odp/event/event_manager.go
@@ -26,10 +26,10 @@ import (
"time"
guuid "github.com/google/uuid"
- "github.com/optimizely/go-sdk/pkg/event"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/odp/config"
- "github.com/optimizely/go-sdk/pkg/odp/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/event"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/config"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
"golang.org/x/sync/semaphore"
)
@@ -237,6 +237,9 @@ func (bm *BatchEventManager) startTicker(ctx context.Context, odpConfig config.C
case <-ctx.Done():
bm.logger.Debug("BatchEventManager stopped, flushing events.")
bm.FlushEvents(odpConfig.GetAPIKey(), odpConfig.GetAPIHost())
+ bm.flushLock.Lock()
+ bm.ticker.Stop()
+ bm.flushLock.Unlock()
return
}
}
diff --git a/pkg/odp/event/event_manager_test.go b/pkg/odp/event/event_manager_test.go
index 194f89478..47b892d0a 100644
--- a/pkg/odp/event/event_manager_test.go
+++ b/pkg/odp/event/event_manager_test.go
@@ -25,11 +25,11 @@ import (
"testing"
"time"
- "github.com/optimizely/go-sdk/pkg/event"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/odp/config"
- "github.com/optimizely/go-sdk/pkg/odp/utils"
- pkgUtils "github.com/optimizely/go-sdk/pkg/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/event"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/config"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
+ pkgUtils "github.com/optimizely/go-sdk/v2/pkg/utils"
"github.com/stretchr/testify/suite"
)
diff --git a/pkg/odp/odp_manager.go b/pkg/odp/odp_manager.go
index b7e014276..eb2c80411 100644
--- a/pkg/odp/odp_manager.go
+++ b/pkg/odp/odp_manager.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2022, Optimizely, Inc. and contributors *
+ * Copyright 2022-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. *
@@ -21,12 +21,12 @@ import (
"errors"
"time"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/odp/cache"
- "github.com/optimizely/go-sdk/pkg/odp/config"
- "github.com/optimizely/go-sdk/pkg/odp/event"
- "github.com/optimizely/go-sdk/pkg/odp/segment"
- "github.com/optimizely/go-sdk/pkg/odp/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/cache"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/config"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/event"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/segment"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
)
// OMOptionFunc are the ODPManager options that give you the ability to add one more more options before the odp manager is initialized.
diff --git a/pkg/odp/odp_manager_test.go b/pkg/odp/odp_manager_test.go
index a7d4475b1..69d594f79 100644
--- a/pkg/odp/odp_manager_test.go
+++ b/pkg/odp/odp_manager_test.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2022-2023, Optimizely, Inc. and contributors *
+ * Copyright 2022-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. *
@@ -23,11 +23,11 @@ import (
"testing"
"time"
- "github.com/optimizely/go-sdk/pkg/odp/cache"
- "github.com/optimizely/go-sdk/pkg/odp/config"
- "github.com/optimizely/go-sdk/pkg/odp/event"
- "github.com/optimizely/go-sdk/pkg/odp/segment"
- "github.com/optimizely/go-sdk/pkg/odp/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/cache"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/config"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/event"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/segment"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
)
diff --git a/pkg/odp/segment/segment_api_manager.go b/pkg/odp/segment/segment_api_manager.go
index 573d874bb..ea92ab8e0 100644
--- a/pkg/odp/segment/segment_api_manager.go
+++ b/pkg/odp/segment/segment_api_manager.go
@@ -24,9 +24,9 @@ import (
"net/url"
"strings"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/odp/utils"
- pkgUtils "github.com/optimizely/go-sdk/pkg/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
+ pkgUtils "github.com/optimizely/go-sdk/v2/pkg/utils"
)
const graphqlAPIEndpointPath = "/v3/graphql"
diff --git a/pkg/odp/segment/segment_api_manager_test.go b/pkg/odp/segment/segment_api_manager_test.go
index 3d2458eca..00b847a15 100644
--- a/pkg/odp/segment/segment_api_manager_test.go
+++ b/pkg/odp/segment/segment_api_manager_test.go
@@ -26,9 +26,9 @@ import (
"testing"
"time"
- "github.com/optimizely/go-sdk/pkg/logging"
- "github.com/optimizely/go-sdk/pkg/odp/utils"
- pkgUtils "github.com/optimizely/go-sdk/pkg/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
+ pkgUtils "github.com/optimizely/go-sdk/v2/pkg/utils"
"github.com/stretchr/testify/suite"
)
diff --git a/pkg/odp/segment/segment_manager.go b/pkg/odp/segment/segment_manager.go
index 685345947..bf9fc10c8 100644
--- a/pkg/odp/segment/segment_manager.go
+++ b/pkg/odp/segment/segment_manager.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2022, Optimizely, Inc. and contributors *
+ * Copyright 2022-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. *
@@ -21,8 +21,8 @@ import (
"fmt"
"time"
- "github.com/optimizely/go-sdk/pkg/odp/cache"
- "github.com/optimizely/go-sdk/pkg/odp/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/cache"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
)
// SMOptionFunc are the SegmentManager options that give you the ability to add one more more options before the segment manager is initialized.
diff --git a/pkg/odp/segment/segment_manager_test.go b/pkg/odp/segment/segment_manager_test.go
index 13ee58a38..dda2dd3b9 100644
--- a/pkg/odp/segment/segment_manager_test.go
+++ b/pkg/odp/segment/segment_manager_test.go
@@ -22,7 +22,7 @@ import (
"testing"
"time"
- "github.com/optimizely/go-sdk/pkg/odp/utils"
+ "github.com/optimizely/go-sdk/v2/pkg/odp/utils"
"github.com/stretchr/testify/suite"
)
@@ -150,3 +150,5 @@ func (l *TestCache) Lookup(key string) interface{} {
}
func (l *TestCache) Reset() {
}
+func (l *TestCache) Remove(key string) {
+}
diff --git a/pkg/odp/utils/utils.go b/pkg/odp/utils/utils.go
index 7f956a122..2f7da2025 100644
--- a/pkg/odp/utils/utils.go
+++ b/pkg/odp/utils/utils.go
@@ -17,7 +17,7 @@
// Package utils //
package utils
-import "github.com/optimizely/go-sdk/pkg/utils"
+import "github.com/optimizely/go-sdk/v2/pkg/utils"
// CompareSlices determines if two string slices are equal
func CompareSlices(a, b []string) bool {
diff --git a/pkg/registry/service.go b/pkg/registry/service.go
index 88f64db1d..e68102101 100644
--- a/pkg/registry/service.go
+++ b/pkg/registry/service.go
@@ -20,7 +20,7 @@ package registry
import (
"sync"
- "github.com/optimizely/go-sdk/pkg/notification"
+ "github.com/optimizely/go-sdk/v2/pkg/notification"
)
var notificationCenterCache = make(map[string]notification.Center)
diff --git a/pkg/tracing/opentelemetry.go b/pkg/tracing/opentelemetry.go
new file mode 100644
index 000000000..0e988448c
--- /dev/null
+++ b/pkg/tracing/opentelemetry.go
@@ -0,0 +1,94 @@
+/****************************************************************************
+ * Copyright 2024 Optimizely, Inc. and contributors *
+ * *
+ * Licensed under the Apache License, Version 2.0 (the "License"); *
+ * you may not use this file except in compliance with the License. *
+ * 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 tracing //
+package tracing
+
+import (
+ "context"
+
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/trace"
+)
+
+// Tracer provides the necessary method to collect telemetry trace data.
+// Tracer should not depend on any specific tool. To make it possible it returns a Span interface.
+type Tracer interface {
+ // StartSpan starts a trace span. Span can be a parent or child span based on the passed context.
+ StartSpan(ctx context.Context, tracerName, spanName string) (context.Context, Span)
+}
+
+// Span interface implements the trace span returned by Tracer.
+type Span interface {
+ End()
+ SetAttibutes(key string, value interface{})
+}
+
+// otelTracer is an OpenTelemetry implementation of Tracer
+type otelTracer struct {
+ enabled bool
+}
+
+// NewOtelTracer returns a new instance of Tracer
+func NewOtelTracer(t trace.Tracer) Tracer {
+ return &otelTracer{
+ enabled: true,
+ }
+}
+
+// StartSpan starts a trace span. Span can be a parent or child span based on the passed context.
+func (t *otelTracer) StartSpan(pctx context.Context, tracerName, spanName string) (context.Context, Span) {
+ ctx, span := otel.Tracer(tracerName).Start(pctx, spanName)
+ return ctx, &otelSpan{
+ span: span,
+ }
+}
+
+// otelSpan is an OpenTelemetry Span implementation of Span
+type otelSpan struct {
+ span trace.Span
+}
+
+// SetAttibutes sets the attributes for the span
+func (s *otelSpan) SetAttibutes(key string, value interface{}) {
+ s.span.SetAttributes(attribute.KeyValue{
+ Key: attribute.Key(key),
+ Value: attribute.StringValue(value.(string)),
+ })
+}
+
+// End ends the span
+func (s *otelSpan) End() {
+ s.span.End()
+}
+
+// NoopTracer is a no-op implementation of Tracer
+type NoopTracer struct{}
+
+// StartSpan returns a new instance of NoopTracer
+func (t *NoopTracer) StartSpan(ctx context.Context, tracerName, spanName string) (context.Context, Span) {
+ return ctx, &NoopSpan{}
+}
+
+// NoopSpan is a no-op implementation of Span
+type NoopSpan struct{}
+
+// SetAttibutes sets the attributes for the noop-span
+func (s *NoopSpan) SetAttibutes(key string, value interface{}) {}
+
+// End ends the noop-span
+func (s *NoopSpan) End() {}
diff --git a/pkg/utils/execgroup.go b/pkg/utils/execgroup.go
index 509608c7c..df72a7cd5 100644
--- a/pkg/utils/execgroup.go
+++ b/pkg/utils/execgroup.go
@@ -19,8 +19,9 @@ package utils
import (
"context"
- "github.com/optimizely/go-sdk/pkg/logging"
"sync"
+
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
// ExecGroup is a utility for managing graceful, blocking cancellation of goroutines.
diff --git a/pkg/utils/execgroup_test.go b/pkg/utils/execgroup_test.go
index 6357a442a..e79f06138 100644
--- a/pkg/utils/execgroup_test.go
+++ b/pkg/utils/execgroup_test.go
@@ -19,9 +19,10 @@ package utils
import (
"context"
- "github.com/optimizely/go-sdk/pkg/logging"
"sync"
"testing"
+
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
)
func TestWithContextCancelFunc(t *testing.T) {
diff --git a/pkg/utils/requester.go b/pkg/utils/requester.go
index 81e9e7d61..a8f8f4de5 100644
--- a/pkg/utils/requester.go
+++ b/pkg/utils/requester.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019,2022 Optimizely, Inc. and contributors *
+ * Copyright 2019,2022-2023 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. *
@@ -25,7 +25,7 @@ import (
"net/http"
"time"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
jsoniter "github.com/json-iterator/go"
)
@@ -156,7 +156,7 @@ func (r HTTPRequester) Do(url, method string, body io.Reader, headers []Header)
single := func(request *http.Request) (response []byte, responseHeaders http.Header, code int, e error) {
resp, doErr := r.client.Do(request)
if doErr != nil {
- r.logger.Error(fmt.Sprintf("failed to send request %v", request), e)
+ r.logger.Error(fmt.Sprintf("failed to send request %v", request), doErr)
return nil, http.Header{}, 0, doErr
}
defer func() {
diff --git a/pkg/utils/requester_test.go b/pkg/utils/requester_test.go
index e343e7e6f..10e6fb654 100644
--- a/pkg/utils/requester_test.go
+++ b/pkg/utils/requester_test.go
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2019,2021-2022 Optimizely, Inc. and contributors *
+ * Copyright 2019,2021-2023 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. *
@@ -26,7 +26,7 @@ import (
"testing"
"time"
- "github.com/optimizely/go-sdk/pkg/logging"
+ "github.com/optimizely/go-sdk/v2/pkg/logging"
"github.com/stretchr/testify/assert"
)
@@ -190,12 +190,41 @@ func TestPostObj(t *testing.T) {
assert.NotNil(t, err)
}
+type mockLogger struct {
+ Errors []error
+}
+
+func (m *mockLogger) Debug(message string) {}
+func (m *mockLogger) Info(message string) {}
+func (m *mockLogger) Warning(message string) {}
+func (m *mockLogger) Error(message string, err interface{}) {
+ if err, ok := err.(error); ok {
+ m.Errors = append(m.Errors, err)
+ }
+}
+
func TestGetBad(t *testing.T) {
+ // Using a mockLogger to ensure we're logging the expected error message
+ mLogger := &mockLogger{}
+ httpreq := NewHTTPRequester(mLogger)
- httpreq := NewHTTPRequester(logging.GetLogger("", ""))
- _, _, _, err := httpreq.Get("blah12345/good")
- _, ok := err.(*url.Error)
+ badURL := "http://ww.bad-url.fake/blah12345"
+ _, _, _, err := httpreq.Get(badURL)
+ returnedErr, ok := err.(*url.Error)
assert.True(t, ok, "url error")
+
+ // Check to make sure we have some log for bad url
+ assert.NotNil(t, mLogger.Errors)
+ // If we didn't get the expected error, we need to stop before we do the rest
+ // of the checks that depend on that error.
+ if !assert.Len(t, mLogger.Errors, 1, "logged error") {
+ t.FailNow()
+ }
+ // Check to make sure the error that was logged is the same as what was returned
+ loggedErr, ok := mLogger.Errors[0].(*url.Error)
+ assert.True(t, ok, "is URL error")
+ assert.Equal(t, returnedErr, loggedErr, "expected same error")
+ assert.Equal(t, badURL, loggedErr.URL, "expected the URL we requested")
}
func TestGetBadWithResponse(t *testing.T) {