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 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 28 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,40 @@ 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 {
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
216 changes: 215 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,155 @@ 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 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", `{
"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(" 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, 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 +543,26 @@ 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",
"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 +578,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 +679,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)
}
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
Loading