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 29 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
29 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
725834d
restore retry config
Mat001 Aug 11, 2025
8e212ab
fix Prisma crypto dependency alert
Mat001 Aug 12, 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
25 changes: 24 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,26 @@ 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
{
"predictionEndpoint": "https://prediction.cmab.optimizely.com/predict/%s",
"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
33 changes: 31 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,43 @@ 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 endpoint, ok := cmab["predictionEndpoint"].(string); ok {
conf.CMAB.PredictionEndpoint = endpoint
}
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 {
conf.CMAB.Cache = cache
}
if retryConfig, ok := cmab["retryConfig"].(map[string]interface{}); ok {
conf.CMAB.RetryConfig = retryConfig
}
}

// Check for individual map sections
if cmabCache := v.GetStringMap("cmab.cache"); len(cmabCache) > 0 {
conf.CMAB.Cache = cmabCache
}

if cmabRetryConfig := v.GetStringMap("cmab.retryConfig"); len(cmabRetryConfig) > 0 {
conf.CMAB.RetryConfig = cmabRetryConfig
}

return conf
}

Expand Down
227 changes: 226 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,164 @@ 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(" PredictionEndpoint: %s\n", cmab.PredictionEndpoint)
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, "https://custom-cmab-endpoint.example.com", cmab.PredictionEndpoint)
assert.Equal(t, 15*time.Second, cmab.RequestTimeout)

// Check if cache map is initialized
cacheMap := cmab.Cache
if cacheMap == nil {
t.Fatal("Cache map is nil")
}

// Debug cache type
cacheTypeValue := cacheMap["type"]
fmt.Printf("Cache type: %v (%T)\n", cacheTypeValue, cacheTypeValue)
assert.Equal(t, "redis", cacheTypeValue)

// Debug cache size
cacheSizeValue := cacheMap["size"]
fmt.Printf("Cache size: %v (%T)\n", cacheSizeValue, cacheSizeValue)
sizeValue, ok := cacheSizeValue.(float64)
assert.True(t, ok, "Cache size should be float64")
assert.Equal(t, float64(2000), sizeValue)

// Cache TTL
cacheTTLValue := cacheMap["ttl"]
fmt.Printf("Cache TTL: %v (%T)\n", cacheTTLValue, cacheTTLValue)
assert.Equal(t, "45m", cacheTTLValue)

// Redis settings
redisValue := cacheMap["redis"]
fmt.Printf("Redis: %v (%T)\n", redisValue, redisValue)
redisMap, ok := redisValue.(map[string]interface{})
assert.True(t, ok, "Redis should be a map")

if !ok {
t.Fatal("Redis is not a map")
}

redisHost := redisMap["host"]
fmt.Printf("Redis host: %v (%T)\n", redisHost, redisHost)
assert.Equal(t, "redis.example.com:6379", redisHost)

redisPassword := redisMap["password"]
fmt.Printf("Redis password: %v (%T)\n", redisPassword, redisPassword)
assert.Equal(t, "password123", redisPassword)

redisDBValue := redisMap["database"]
fmt.Printf("Redis DB: %v (%T)\n", redisDBValue, redisDBValue)
dbValue, ok := redisDBValue.(float64)
assert.True(t, ok, "Redis DB should be float64")
assert.Equal(t, float64(2), dbValue)

// Retry settings
retryMap := cmab.RetryConfig
if retryMap == nil {
t.Fatal("RetryConfig map is nil")
}

// Max retries
maxRetriesValue := retryMap["maxRetries"]
fmt.Printf("maxRetries: %v (%T)\n", maxRetriesValue, maxRetriesValue)
maxRetries, ok := maxRetriesValue.(float64)
assert.True(t, ok, "maxRetries should be float64")
assert.Equal(t, float64(5), maxRetries)

// Check other retry settings
fmt.Printf("initialBackoff: %v (%T)\n", retryMap["initialBackoff"], retryMap["initialBackoff"])
assert.Equal(t, "200ms", retryMap["initialBackoff"])

fmt.Printf("maxBackoff: %v (%T)\n", retryMap["maxBackoff"], retryMap["maxBackoff"])
assert.Equal(t, "30s", retryMap["maxBackoff"])

fmt.Printf("backoffMultiplier: %v (%T)\n", retryMap["backoffMultiplier"], retryMap["backoffMultiplier"])
assert.Equal(t, 3.0, retryMap["backoffMultiplier"])
}

func TestCMABEnvDebug(t *testing.T) {
_ = os.Setenv("OPTIMIZELY_CMAB", `{
"enabled": true,
"predictionEndpoint": "https://custom-cmab-endpoint.example.com",
"requestTimeout": "15s",
"cache": {
"type": "redis",
"size": 2000,
"ttl": "45m",
"redis": {
"host": "redis.example.com:6379",
"password": "password123",
"database": 2
}
},
"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(" PredictionEndpoint: %s\n", conf.CMAB.PredictionEndpoint)
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", `{"enabled": true, "predictionEndpoint": "https://base-endpoint.example.com"}`)
_ = 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)

// Base assertions
assert.Equal(t, "https://base-endpoint.example.com", conf.CMAB.PredictionEndpoint)

// Cache assertions
assert.Equal(t, "redis", conf.CMAB.Cache["type"])
assert.Equal(t, float64(3000), conf.CMAB.Cache["size"])

// RetryConfig assertions
assert.Equal(t, float64(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 +552,28 @@ 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", `{
"enabled": true,
"predictionEndpoint": "https://custom-cmab-endpoint.example.com",
"requestTimeout": "15s",
"cache": {
"type": "redis",
"size": 2000,
"ttl": "45m",
"redis": {
"host": "redis.example.com:6379",
"password": "password123",
"database": 2
}
},
"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 +589,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 +690,45 @@ 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","redis":{"host":"json-redis.example.com:6379","password":"json-password","database":4}}`)

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
cacheMap := actual.CMAB.Cache
assert.Equal(t, "redis", cacheMap["type"])

// Account for JSON unmarshaling to float64
size, ok := cacheMap["size"].(float64)
assert.True(t, ok, "Size should be a float64")
assert.Equal(t, float64(5000), size)

assert.Equal(t, "3h", cacheMap["ttl"])

redisMap, ok := cacheMap["redis"].(map[string]interface{})
assert.True(t, ok, "Redis config should be a map")
assert.Equal(t, "json-redis.example.com:6379", redisMap["host"])
assert.Equal(t, "json-password", redisMap["password"])

db, ok := redisMap["database"].(float64)
assert.True(t, ok, "Database should be a float64")
assert.Equal(t, float64(4), db)
}
Loading