Skip to content

[DRAFT FSSDK-XXXXX] Matjaz Internal Test PR of cmab for go-sdk #397

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

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
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
74 changes: 74 additions & 0 deletions examples/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,77 @@ import (
"github.com/optimizely/go-sdk/v2/pkg/logging"
)

// /************* CMAB Example ********************/

// func cmabExample() {
// sdkKey := "RZKHh5HhUExLvpeieGZnD"
// logging.SetLogLevel(logging.LogLevelDebug)

// fmt.Println("\n/************* CMAB Example ********************/")

// // Initialize client with CMAB support
// optimizelyFactory := &client.OptimizelyFactory{
// SDKKey: sdkKey,
// }

// optimizelyClient, err := optimizelyFactory.Client()
// if err != nil {
// fmt.Printf("Error instantiating client: %s\n", err)
// return
// }
// defer optimizelyClient.Close()

// // Create user context with attributes that might influence CMAB decisions
// userContext := optimizelyClient.CreateUserContext("user123", map[string]interface{}{
// "age": 28,
// "location": "San Francisco",
// "device": "mobile",
// })

// // Get CMAB decision with reasons included
// cmabDecision, err := optimizelyClient.GetCMABDecision("cmab-rule-123", userContext, client.IncludeReasons)
// if err != nil {
// fmt.Printf("Error getting CMAB decision: %s\n", err)
// return
// }

// // Display decision details
// fmt.Printf("CMAB Decision for rule %s and user %s:\n", cmabDecision.RuleID, cmabDecision.UserID)
// fmt.Printf(" Variant ID: %s\n", cmabDecision.VariantID)
// fmt.Printf(" Attributes: %v\n", cmabDecision.Attributes)

// if len(cmabDecision.Reasons) > 0 {
// fmt.Println(" Reasons:")
// for _, reason := range cmabDecision.Reasons {
// fmt.Printf(" - %s\n", reason)
// }
// }

// // Demonstrate cache usage
// fmt.Println("\nGetting second decision (should use cache):")
// secondDecision, _ := optimizelyClient.GetCMABDecision("cmab-rule-123", userContext, client.IncludeReasons)
// if len(secondDecision.Reasons) > 0 {
// for _, reason := range secondDecision.Reasons {
// fmt.Printf(" - %s\n", reason)
// }
// }

// // Demonstrate cache invalidation
// fmt.Println("\nInvalidating user cache and getting new decision:")
// _ = optimizelyClient.InvalidateUserCMABCache(userContext.GetUserID())
// thirdDecision, _ := optimizelyClient.GetCMABDecision("cmab-rule-123", userContext, client.IncludeReasons)
// if len(thirdDecision.Reasons) > 0 {
// for _, reason := range thirdDecision.Reasons {
// fmt.Printf(" - %s\n", reason)
// }
// }

// // Demonstrate full cache reset
// fmt.Println("\nResetting entire CMAB cache:")
// _ = optimizelyClient.ResetCMABCache()
// fmt.Println("Cache reset complete")
// }

func main() {
sdkKey := "RZKHh5HhUExLvpeieGZnD"
logging.SetLogLevel(logging.LogLevelDebug)
Expand Down Expand Up @@ -95,4 +166,7 @@ func main() {
)

optimizelyClient.Close()

// /************* Contextual Multi-Armed Bandit Example (CMAB) ********************/
// cmabExample()
}
132 changes: 131 additions & 1 deletion pkg/client/client.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/****************************************************************************
* Copyright 2019-2024, 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. *
Expand Down Expand Up @@ -97,6 +97,12 @@ const (
SpanNameGetOptimizelyConfig = "GetOptimizelyConfig"
// SpanNameGetDecisionVariableMap is the name of the span used by the Optimizely SDK for tracing getDecisionVariableMap call
SpanNameGetDecisionVariableMap = "getDecisionVariableMap"
// SpanNameGetCMABDecision is the name of the span used by the Optimizely SDK for tracing GetCMABDecision call
SpanNameGetCMABDecision = "GetCMABDecision"
// SpanNameResetCMABCache is the name of the span used by the Optimizely SDK for tracing ResetCMABCache call
SpanNameResetCMABCache = "ResetCMABCache"
// SpanNameInvalidateUserCMABCache is the name of the span used by the Optimizely SDK for tracing InvalidateUserCMABCache call
SpanNameInvalidateUserCMABCache = "InvalidateUserCMABCache"
)

// OptimizelyClient is the entry point to the Optimizely SDK
Expand All @@ -112,6 +118,7 @@ type OptimizelyClient struct {
logger logging.OptimizelyLogProducer
defaultDecideOptions *decide.Options
tracer tracing.Tracer
cmabService decision.CmabService
}

// CreateUserContext creates a context of the user for which decision APIs will be called.
Expand Down Expand Up @@ -1244,3 +1251,126 @@ 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())
}

// GetCMABDecision returns a variation for a CMAB experiment
func (o *OptimizelyClient) GetCMABDecision(ruleID string, userContext OptimizelyUserContext, options ...decide.OptimizelyDecideOptions) (result *decision.CmabDecision, err error) {
defer func() {
if r := recover(); r != nil {
switch t := r.(type) {
case error:
err = t
case string:
err = errors.New(t)
default:
err = errors.New("unexpected error")
}
errorMessage := "GetCMABDecision call, optimizely SDK is panicking with the error:"
o.logger.Error(errorMessage, err)
o.logger.Debug(string(debug.Stack()))
}
}()

_, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameGetCMABDecision)
defer span.End()

projectConfig, err := o.getProjectConfig()
if err != nil {
o.logger.Error("Optimizely instance is not valid, failing GetCMABDecision call.", err)
return nil, err
}

// Convert decide options to CmabDecisionOptions
cmabOptions := &decision.CmabDecisionOptions{
IncludeReasons: false,
}

for _, opt := range options {
switch opt {
case decide.IgnoreCMABCache:
cmabOptions.IgnoreCmabCache = true
case decide.IncludeReasons:
cmabOptions.IncludeReasons = true
}
}

// Create user context for CMAB service
cmabUserContext := entities.UserContext{
ID: userContext.GetUserID(),
Attributes: userContext.GetUserAttributes(),
}

// Call CMAB service - use the config.ProjectConfig interface
cmabDecision, err := o.cmabService.GetDecision(projectConfig, cmabUserContext, ruleID, cmabOptions)
if err != nil {
o.logger.Error(fmt.Sprintf("Error getting CMAB decision: %v", err), nil)
return nil, err
}

return &cmabDecision, nil
}

// ResetCMABCache resets the entire CMAB cache
func (o *OptimizelyClient) ResetCMABCache() error {
var err error
defer func() {
if r := recover(); r != nil {
switch t := r.(type) {
case error:
err = t
case string:
err = errors.New(t)
default:
err = errors.New("unexpected error")
}
errorMessage := "ResetCMABCache call, optimizely SDK is panicking with the error:"
o.logger.Error(errorMessage, err)
o.logger.Debug(string(debug.Stack()))
}
}()

_, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameResetCMABCache)
defer span.End()

if _, err = o.getProjectConfig(); err != nil {
o.logger.Error("Optimizely instance is not valid, failing ResetCMABCache call.", err)
return err
}

o.logger.Debug("Resetting CMAB cache")

// Call the interface method instead of accessing fields directly
return o.cmabService.ResetCache()
}

// InvalidateUserCMABCache invalidates cache entries for a specific user
func (o *OptimizelyClient) InvalidateUserCMABCache(userID string) error {
var err error
defer func() {
if r := recover(); r != nil {
switch t := r.(type) {
case error:
err = t
case string:
err = errors.New(t)
default:
err = errors.New("unexpected error")
}
errorMessage := "InvalidateUserCMABCache call, optimizely SDK is panicking with the error:"
o.logger.Error(errorMessage, err)
o.logger.Debug(string(debug.Stack()))
}
}()

_, span := o.tracer.StartSpan(o.ctx, DefaultTracerName, SpanNameInvalidateUserCMABCache)
defer span.End()

if _, err = o.getProjectConfig(); err != nil {
o.logger.Error("Optimizely instance is not valid, failing InvalidateUserCMABCache call.", err)
return err
}

o.logger.Debug(fmt.Sprintf("Invalidating CMAB cache for user %s", userID))

// Call the interface method instead of accessing fields directly
return o.cmabService.InvalidateUserCache(userID)
}
22 changes: 21 additions & 1 deletion pkg/client/factory.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/****************************************************************************
* Copyright 2019-2020,2022-2024 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 Down Expand Up @@ -59,6 +59,9 @@ type OptimizelyFactory struct {
segmentsCacheTimeout time.Duration
odpDisabled bool
odpManager odp.Manager

// CMAB
cmabService decision.CmabService
}

// OptionFunc is used to provide custom client configuration to the OptimizelyFactory.
Expand Down Expand Up @@ -173,6 +176,16 @@ func (f *OptimizelyFactory) Client(clientOptions ...OptionFunc) (*OptimizelyClie
eg.Go(batchProcessor.Start)
}

// Initialize CMAB service if not provided
// Initialize CMAB service
if f.cmabService != nil {
appClient.cmabService = f.cmabService
} else {
appClient.cmabService = decision.NewDefaultCmabService(
decision.WithCmabLogger(logging.GetLogger(f.SDKKey, "CmabService")),
)
}

// Initialize and Start odp manager if possible
// Needed a separate functions for this to avoid cyclo-complexity warning
f.initializeOdpManager(appClient)
Expand Down Expand Up @@ -410,3 +423,10 @@ func convertDecideOptions(options []decide.OptimizelyDecideOptions) *decide.Opti
}
return &finalOptions
}

// WithCmabService sets the CMAB service on a client.
func WithCmabService(cmabService decision.CmabService) OptionFunc {
return func(f *OptimizelyFactory) {
f.cmabService = cmabService
}
}
4 changes: 3 additions & 1 deletion pkg/decide/decide_options.go
Original file line number Diff line number Diff line change
@@ -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. *
Expand Down Expand Up @@ -33,6 +33,8 @@ 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"
)

// Options defines options for controlling flag decisions.
Expand Down
54 changes: 54 additions & 0 deletions pkg/decision/cmab.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/****************************************************************************
* 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. *
***************************************************************************/

// pkg/decision/cmab.go
package decision

import (
"github.com/optimizely/go-sdk/v2/pkg/config"
"github.com/optimizely/go-sdk/v2/pkg/entities"
)

// Contains options for CMAB decision requests
type CmabDecisionOptions struct {
IncludeReasons bool
IgnoreCmabCache bool
}

// Represents a decision from the CMAB service
type CmabDecision struct {
VariationID string
CmabUUID string
Reasons []string
}

// Represents a cached CMAB decision
type CmabCacheValue struct {
Decision CmabDecision
Created int64
}

// Defines the interface for CMAB decision service
type CmabService interface {
GetDecision(projectConfig config.ProjectConfig, userContext entities.UserContext, ruleID string, options *CmabDecisionOptions) (CmabDecision, error)
ResetCache() error
InvalidateUserCache(userID string) error
}

// Defines the interface for CMAB API client
type CmabClient interface {
FetchDecision(ruleID string, userID string, attributes map[string]interface{}) (CmabDecision, error)
}
Loading
Loading