Skip to content

[FSSDK-11587] Implement CMAB config #439

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 27 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5187d1d
add cmab config
Mat001 Jun 20, 2025
c256379
remove enabled config, remove 3 ENV vars for cmab, only use a single one
Mat001 Jun 20, 2025
f3430cf
cleanup readme
Mat001 Jun 20, 2025
67382b0
add cmab logic to agent
Mat001 Jun 25, 2025
f20c537
add cmab config
Mat001 Jun 20, 2025
04815d9
remove enabled config, remove 3 ENV vars for cmab, only use a single one
Mat001 Jun 20, 2025
a892d8a
cleanup readme
Mat001 Jun 20, 2025
f3f334d
add cmab logic to agent
Mat001 Jun 25, 2025
f760d41
Merge branch 'master' into mpirnovar-cmab-config-fssdk-11587
Mat001 Jul 17, 2025
7adae03
Use go-sdk branch mpirnovar-cmab-gosdk-agent-fssdk-11589 for CMAB sup…
Mat001 Jul 25, 2025
b0d5626
removed comment
Mat001 Jul 25, 2025
789914e
fix formatting
Mat001 Jul 25, 2025
fbea582
surface cmabUUID in Decide API response
Mat001 Jul 25, 2025
f2be578
Add support for returning the cmabUUID field in Decide API responses …
Mat001 Jul 26, 2025
95249f0
remove duplicate CmabUUID
Mat001 Jul 26, 2025
70f98d8
Add configurable CMAB prediction endpoint for FSC testing
Mat001 Jul 26, 2025
9988eee
Force GitHub refresh
Mat001 Jul 27, 2025
79b5da0
add prediction endpoint handling to main.go
Mat001 Jul 27, 2025
7151c5b
Update agent to use CMAB configuration approach
Mat001 Jul 28, 2025
bd5d113
fix tests
Mat001 Jul 28, 2025
7f34019
Force GitHub refresh
Mat001 Jul 28, 2025
f508b73
configure ENV var OPTIMIZELY_CMAB_PREDICTIONENDPOINT to allow fsc tes…
Mat001 Jul 29, 2025
a6db9b5
remove %s, already in the endpoint in fsc
Mat001 Jul 29, 2025
9cc1e9d
Add client reset functionality for FSC CMAB test isolation
Mat001 Jul 29, 2025
ec06047
Trigger PR check
Mat001 Jul 30, 2025
8a3e27b
fix formatting issues
Mat001 Jul 30, 2025
7efc05e
Refactored CMAB configuration from unstructured map[string]interface{…
Mat001 Jul 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,10 @@ Below is a comprehensive list of available configuration properties.
| log.level | OPTIMIZELY_LOG_LEVEL | The log [level](https://github.com/rs/zerolog#leveled-logging) for the agent. Default: info |
| log.pretty | OPTIMIZELY_LOG_PRETTY | Flag used to set colorized console output as opposed to structured json logs. Default: false |
| name | OPTIMIZELY_NAME | Agent name. Default: optimizely |
| sdkKeys | OPTIMIZELY_SDKKEYS | Comma delimited list of SDK keys used to initialize on startup |
| sdkKeys | OPTIMIZELY_SDKKEYS | Comma delimited list of SDK keys used to initialize on startup |
| cmab | OPTIMIZELY_CMAB | Complete JSON configuration for CMAB. Format: see example below |
| cmab.cache | OPTIMIZELY_CMAB_CACHE | JSON configuration for just the CMAB cache section. Format: see example below |
| cmab.retryConfig | OPTIMIZELY_CMAB_RETRYCONFIG | JSON configuration for just the CMAB retry settings. Format: see example below |
| server.allowedHosts | OPTIMIZELY_SERVER_ALLOWEDHOSTS | List of allowed request host values. Requests whose host value does not match either the configured server.host, or one of these, will be rejected with a 404 response. To match all subdomains, you can use a leading dot (for example `.example.com` matches `my.example.com`, `hello.world.example.com`, etc.). You can use the value `.` to disable allowed host checking, allowing requests with any host. Request host is determined in the following priority order: 1. X-Forwarded-Host header value, 2. Forwarded header host= directive value, 3. Host property of request (see Host under https://pkg.go.dev/net/http#Request). Note: don't include port in these hosts values - port is stripped from the request host before comparing against these. |
| server.batchRequests.maxConcurrency | OPTIMIZELY_SERVER_BATCHREQUESTS_MAXCONCURRENCY | Number of requests running in parallel. Default: 10 |
| server.batchRequests.operationsLimit | OPTIMIZELY_SERVER_BATCHREQUESTS_OPERATIONSLIMIT | Number of allowed operations. ( will flag an error if the number of operations exeeds this parameter) Default: 500 |
Expand All @@ -142,6 +145,25 @@ Below is a comprehensive list of available configuration properties.
| webhook.projects.<_projectId_>.secret | N/A | Webhook secret used to validate webhook requests originating from the respective projectId |
| webhook.projects.<_projectId_>.skipSignatureCheck | N/A | Boolean to indicate whether the signature should be validated. TODO remove in favor of empty secret. |

### CMAB Configuration Example

```json
{
"requestTimeout": "5s",
"cache": {
"type": "memory",
"size": 2000,
"ttl": "45m"
},
"retryConfig": {
"maxRetries": 3,
"initialBackoff": "100ms",
"maxBackoff": "10s",
"backoffMultiplier": 2.0
}
}
```

More information about configuring Agent can be found in the [Advanced Configuration Notes](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/advanced-configuration).

### API
Expand Down
84 changes: 82 additions & 2 deletions cmd/optimizely/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"runtime"
"strings"
"syscall"
"time"

"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
Expand Down Expand Up @@ -98,15 +99,94 @@ func loadConfig(v *viper.Viper) *config.AgentConfig {
}

// Check if JSON string was set using OPTIMIZELY_CLIENT_USERPROFILESERVICE environment variable
if userProfileService := v.GetStringMap("client.userprofileservice"); userProfileService != nil {
if userProfileService := v.GetStringMap("client.userprofileservice"); len(userProfileService) > 0 {
conf.Client.UserProfileService = userProfileService
}

// Check if JSON string was set using OPTIMIZELY_CLIENT_ODP_SEGMENTSCACHE environment variable
if odpSegmentsCache := v.GetStringMap("client.odp.segmentsCache"); odpSegmentsCache != nil {
if odpSegmentsCache := v.GetStringMap("client.odp.segmentsCache"); len(odpSegmentsCache) > 0 {
conf.Client.ODP.SegmentsCache = odpSegmentsCache
}

// Handle CMAB configuration using the same approach as UserProfileService
// Check for complete CMAB configuration first
if cmab := v.GetStringMap("cmab"); len(cmab) > 0 {
if timeout, ok := cmab["requestTimeout"].(string); ok {
if duration, err := time.ParseDuration(timeout); err == nil {
conf.CMAB.RequestTimeout = duration
}
}
if cache, ok := cmab["cache"].(map[string]interface{}); ok {
if cacheType, ok := cache["type"].(string); ok {
conf.CMAB.Cache.Type = cacheType
}
if cacheSize, ok := cache["size"].(float64); ok {
conf.CMAB.Cache.Size = int(cacheSize)
}
if cacheTTL, ok := cache["ttl"].(string); ok {
if duration, err := time.ParseDuration(cacheTTL); err == nil {
conf.CMAB.Cache.TTL = duration
}
}
}
if retryConfig, ok := cmab["retryConfig"].(map[string]interface{}); ok {
if maxRetries, ok := retryConfig["maxRetries"].(float64); ok {
conf.CMAB.RetryConfig.MaxRetries = int(maxRetries)
}
if initialBackoff, ok := retryConfig["initialBackoff"].(string); ok {
if duration, err := time.ParseDuration(initialBackoff); err == nil {
conf.CMAB.RetryConfig.InitialBackoff = duration
}
}
if maxBackoff, ok := retryConfig["maxBackoff"].(string); ok {
if duration, err := time.ParseDuration(maxBackoff); err == nil {
conf.CMAB.RetryConfig.MaxBackoff = duration
}
}
if backoffMultiplier, ok := retryConfig["backoffMultiplier"].(float64); ok {
conf.CMAB.RetryConfig.BackoffMultiplier = backoffMultiplier
}
}
}

// Check for individual map sections
if cmabCache := v.GetStringMap("cmab.cache"); len(cmabCache) > 0 {
if cacheType, ok := cmabCache["type"].(string); ok {
conf.CMAB.Cache.Type = cacheType
}
if cacheSize, ok := cmabCache["size"].(int); ok {
conf.CMAB.Cache.Size = cacheSize
} else if cacheSize, ok := cmabCache["size"].(float64); ok {
conf.CMAB.Cache.Size = int(cacheSize)
}
if cacheTTL, ok := cmabCache["ttl"].(string); ok {
if duration, err := time.ParseDuration(cacheTTL); err == nil {
conf.CMAB.Cache.TTL = duration
}
}
}

if cmabRetryConfig := v.GetStringMap("cmab.retryConfig"); len(cmabRetryConfig) > 0 {
if maxRetries, ok := cmabRetryConfig["maxRetries"].(int); ok {
conf.CMAB.RetryConfig.MaxRetries = maxRetries
} else if maxRetries, ok := cmabRetryConfig["maxRetries"].(float64); ok {
conf.CMAB.RetryConfig.MaxRetries = int(maxRetries)
}
if initialBackoff, ok := cmabRetryConfig["initialBackoff"].(string); ok {
if duration, err := time.ParseDuration(initialBackoff); err == nil {
conf.CMAB.RetryConfig.InitialBackoff = duration
}
}
if maxBackoff, ok := cmabRetryConfig["maxBackoff"].(string); ok {
if duration, err := time.ParseDuration(maxBackoff); err == nil {
conf.CMAB.RetryConfig.MaxBackoff = duration
}
}
if backoffMultiplier, ok := cmabRetryConfig["backoffMultiplier"].(float64); ok {
conf.CMAB.RetryConfig.BackoffMultiplier = backoffMultiplier
}
}

return conf
}

Expand Down
135 changes: 134 additions & 1 deletion cmd/optimizely/main_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/****************************************************************************
* Copyright 2019-2020,2022-2023, 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. *
Expand All @@ -17,7 +17,9 @@
package main

import (
"fmt"
"os"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -178,6 +180,93 @@ func assertWebhook(t *testing.T, actual config.WebhookConfig) {
assert.False(t, actual.Projects[20000].SkipSignatureCheck)
}

func assertCMAB(t *testing.T, cmab config.CMABConfig) {
fmt.Println("In assertCMAB, received CMAB config:")
fmt.Printf(" RequestTimeout: %v\n", cmab.RequestTimeout)
fmt.Printf(" Cache: %#v\n", cmab.Cache)
fmt.Printf(" RetryConfig: %#v\n", cmab.RetryConfig)

// Base assertions
assert.Equal(t, 15*time.Second, cmab.RequestTimeout)

// Check cache configuration
cache := cmab.Cache
assert.Equal(t, "redis", cache.Type)
assert.Equal(t, 2000, cache.Size)
assert.Equal(t, 45*time.Minute, cache.TTL)

// Check retry configuration
retry := cmab.RetryConfig
assert.Equal(t, 5, retry.MaxRetries)
assert.Equal(t, 200*time.Millisecond, retry.InitialBackoff)
assert.Equal(t, 30*time.Second, retry.MaxBackoff)
assert.Equal(t, 3.0, retry.BackoffMultiplier)
}

func TestCMABEnvDebug(t *testing.T) {
_ = os.Setenv("OPTIMIZELY_CMAB", `{
"requestTimeout": "15s",
"cache": {
"type": "redis",
"size": 2000,
"ttl": "45m"
},
"retryConfig": {
"maxRetries": 5,
"initialBackoff": "200ms",
"maxBackoff": "30s",
"backoffMultiplier": 3.0
}
}`)

// Load config using Viper
v := viper.New()
v.SetEnvPrefix("optimizely")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()

// Create config
assert.NoError(t, initConfig(v))
conf := loadConfig(v)

// Debug: Print the parsed config
fmt.Println("Parsed CMAB config from JSON env var:")
fmt.Printf(" RequestTimeout: %v\n", conf.CMAB.RequestTimeout)
fmt.Printf(" Cache: %+v\n", conf.CMAB.Cache)
fmt.Printf(" RetryConfig: %+v\n", conf.CMAB.RetryConfig)

// Call assertCMAB
assertCMAB(t, conf.CMAB)
}

func TestCMABPartialConfig(t *testing.T) {
// Clean any existing environment variables
os.Unsetenv("OPTIMIZELY_CMAB")
os.Unsetenv("OPTIMIZELY_CMAB_CACHE")
os.Unsetenv("OPTIMIZELY_CMAB_RETRYCONFIG")

// Set partial configuration through CMAB_CACHE and CMAB_RETRYCONFIG
_ = os.Setenv("OPTIMIZELY_CMAB_CACHE", `{"type": "redis", "size": 3000}`)
_ = os.Setenv("OPTIMIZELY_CMAB_RETRYCONFIG", `{"maxRetries": 10}`)

// Load config
v := viper.New()
assert.NoError(t, initConfig(v))
conf := loadConfig(v)

// Cache assertions
assert.Equal(t, "redis", conf.CMAB.Cache.Type)
assert.Equal(t, 3000, conf.CMAB.Cache.Size)

// RetryConfig assertions
assert.Equal(t, 10, conf.CMAB.RetryConfig.MaxRetries)

// Clean up
os.Unsetenv("OPTIMIZELY_CMAB")
os.Unsetenv("OPTIMIZELY_CMAB_CACHE")
os.Unsetenv("OPTIMIZELY_CMAB_RETRYCONFIG")
}

func TestViperYaml(t *testing.T) {
v := viper.New()
v.Set("config.filename", "./testdata/default.yaml")
Expand Down Expand Up @@ -392,6 +481,21 @@ func TestViperEnv(t *testing.T) {
_ = os.Setenv("OPTIMIZELY_WEBHOOK_PROJECTS_20000_SDKKEYS", "xxx,yyy,zzz")
_ = os.Setenv("OPTIMIZELY_WEBHOOK_PROJECTS_20000_SKIPSIGNATURECHECK", "false")

_ = os.Setenv("OPTIMIZELY_CMAB", `{
"requestTimeout": "15s",
"cache": {
"type": "redis",
"size": 2000,
"ttl": "45m"
},
"retryConfig": {
"maxRetries": 5,
"initialBackoff": "200ms",
"maxBackoff": "30s",
"backoffMultiplier": 3.0
}
}`)

_ = os.Setenv("OPTIMIZELY_RUNTIME_BLOCKPROFILERATE", "1")
_ = os.Setenv("OPTIMIZELY_RUNTIME_MUTEXPROFILEFRACTION", "2")

Expand All @@ -407,6 +511,7 @@ func TestViperEnv(t *testing.T) {
assertAPI(t, actual.API)
//assertWebhook(t, actual.Webhook) // Maps don't appear to be supported
assertRuntime(t, actual.Runtime)
assertCMAB(t, actual.CMAB)
}

func TestLoggingWithIncludeSdkKey(t *testing.T) {
Expand Down Expand Up @@ -507,3 +612,31 @@ func Test_initTracing(t *testing.T) {
})
}
}

func TestCMABComplexJSON(t *testing.T) {
// Clean any existing environment variables for CMAB
os.Unsetenv("OPTIMIZELY_CMAB_CACHE_TYPE")
os.Unsetenv("OPTIMIZELY_CMAB_CACHE_SIZE")
os.Unsetenv("OPTIMIZELY_CMAB_CACHE_TTL")
os.Unsetenv("OPTIMIZELY_CMAB_CACHE_REDIS_HOST")
os.Unsetenv("OPTIMIZELY_CMAB_CACHE_REDIS_PASSWORD")
os.Unsetenv("OPTIMIZELY_CMAB_CACHE_REDIS_DATABASE")

// Set complex JSON environment variable for CMAB cache
_ = os.Setenv("OPTIMIZELY_CMAB_CACHE", `{"type":"redis","size":5000,"ttl":"3h"}`)

defer func() {
// Clean up
os.Unsetenv("OPTIMIZELY_CMAB_CACHE")
}()

v := viper.New()
assert.NoError(t, initConfig(v))
actual := loadConfig(v)

// Test cache settings from JSON environment variable
cache := actual.CMAB.Cache
assert.Equal(t, "redis", cache.Type)
assert.Equal(t, 5000, cache.Size)
assert.Equal(t, 3*time.Hour, cache.TTL)
}
25 changes: 25 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,28 @@ synchronization:
datafile:
enable: false
default: "redis"

##
## cmab: Contextual Multi-Armed Bandit configuration
##
cmab:
## timeout for CMAB API requests
requestTimeout: 10s
## CMAB cache configuration
cache:
## cache type (memory or redis)
type: "memory"
## maximum number of entries for in-memory cache
size: 1000
## time-to-live for cached decisions
ttl: 30m
## retry configuration for CMAB API requests
retryConfig:
## maximum number of retry attempts
maxRetries: 3
## initial backoff duration
initialBackoff: 100ms
## maximum backoff duration
maxBackoff: 10s
## multiplier for exponential backoff
backoffMultiplier: 2.0
Loading