Skip to content

[FSSDK-11589] Add go-sdk logic to support agent for cmab #412

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 45 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
54101be
Fix CMAB error handling to properly propagate error reasons in Decisi…
Mat001 Jun 27, 2025
d0c090a
add go-sdk logic to support agent for cmab
Mat001 Jun 27, 2025
40aef5e
cleanup debug statements
Mat001 Jun 27, 2025
6c419fe
add cmab cache options to getAllOptions
Mat001 Jun 27, 2025
5dcef46
fix failing fsc tests
Mat001 Jun 27, 2025
1ba0e3c
add cmab errors file
Mat001 Jun 27, 2025
c0ac22c
adjust lowercase
Mat001 Jun 27, 2025
c8b55e0
add test
Mat001 Jun 28, 2025
8305a90
fix error message propagation in resons
Mat001 Jul 15, 2025
45d51cf
add error handling to feature experiment servvice
Mat001 Jul 15, 2025
1e92f00
Add more error handling to feature exper and composite feature service
Mat001 Jul 15, 2025
7bcfe8a
nil back to err
Mat001 Jul 15, 2025
d2181fb
add reasons message to composite feature service GetDecision
Mat001 Jul 15, 2025
a76accc
use AddError for reasons
Mat001 Jul 15, 2025
2c913ba
Trigger PR check
Mat001 Jul 16, 2025
9d57add
merge in mpirnovar-fsc-failures-fix-fssdk-11649
Mat001 Jul 17, 2025
a1f4b66
fix cyclomatic complexity by refactoring client.go code
Mat001 Jul 17, 2025
7b76378
fix lint error
Mat001 Jul 17, 2025
6a8bae4
fix lint
Mat001 Jul 17, 2025
bfe1626
Trigger PR check
Mat001 Jul 16, 2025
3221bab
remove implicit error handling - PR feedback
Mat001 Jul 22, 2025
ac9d34b
[FSSDK-11649] Fix FSC failed tests for CMAB (#411)
Mat001 Jul 23, 2025
44be7aa
add go-sdk logic to support agent for cmab
Mat001 Jun 27, 2025
c64e543
fix failing fsc tests
Mat001 Jun 27, 2025
6b5ec11
adjust lowercase
Mat001 Jun 27, 2025
d959b1a
fix error message propagation in resons
Mat001 Jul 15, 2025
c14e466
add error handling to feature experiment servvice
Mat001 Jul 15, 2025
20967ec
Add more error handling to feature exper and composite feature service
Mat001 Jul 15, 2025
3f513c8
Trigger PR check
Mat001 Jul 16, 2025
7b66d8e
fix cyclomatic complexity by refactoring client.go code
Mat001 Jul 17, 2025
344a438
Trigger PR check
Mat001 Jul 16, 2025
068b036
remove implicit error handling - PR feedback
Mat001 Jul 22, 2025
fe88f6a
Update license year
Mat001 Jul 23, 2025
5cba2f8
Force GitHub refresh
Mat001 Jul 23, 2025
3b26094
Merge branch 'master' into mpirnovar-cmab-gosdk-agent-fssdk-11589
Mat001 Jul 23, 2025
d99fa90
change nill to err in feat exper service
Mat001 Jul 23, 2025
e17908d
fix tests
Mat001 Jul 24, 2025
9e40a65
add two tests
Mat001 Jul 24, 2025
31e2782
Force GitHub refresh
Mat001 Jul 24, 2025
d7bcec5
Add tests to address coveralls
Mat001 Jul 24, 2025
06a47dd
add couple more tests
Mat001 Jul 25, 2025
2763ee9
few more tests
Mat001 Jul 25, 2025
d5d321c
add test for TrGetCmabDecision
Mat001 Jul 25, 2025
7caa2d0
fix formatting
Mat001 Jul 25, 2025
2baee7e
add optional CmabUUID field to OptimizelyDecision for CMAB support
Mat001 Jul 25, 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
135 changes: 103 additions & 32 deletions 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 @@ -28,6 +28,7 @@ import (

"github.com/hashicorp/go-multierror"

"github.com/optimizely/go-sdk/v2/pkg/cmab"
"github.com/optimizely/go-sdk/v2/pkg/config"
"github.com/optimizely/go-sdk/v2/pkg/decide"
"github.com/optimizely/go-sdk/v2/pkg/decision"
Expand Down Expand Up @@ -112,6 +113,7 @@ type OptimizelyClient struct {
logger logging.OptimizelyLogProducer
defaultDecideOptions *decide.Options
tracer tracing.Tracer
cmabService cmab.Service
}

// CreateUserContext creates a context of the user for which decision APIs will be called.
Expand Down Expand Up @@ -173,75 +175,143 @@ func (o *OptimizelyClient) decide(userContext *OptimizelyUserContext, key string
Attributes: userContext.GetUserAttributes(),
QualifiedSegments: userContext.GetQualifiedSegments(),
}
var variationKey string
var eventSent, flagEnabled bool

allOptions := o.getAllOptions(options)
decisionReasons := decide.NewDecisionReasons(&allOptions)
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() {
// regular decision
featureDecision, reasons, err = o.DecisionService.GetFeatureDecision(decisionContext, usrContext, &allOptions)
decisionReasons.Append(reasons)
}
var decisionReasonsList decide.DecisionReasons // Fix shadowing - renamed from "reasons"

// Try CMAB decision first
useCMAB := o.tryGetCMABDecision(feature, projectConfig, usrContext, &allOptions, decisionReasons, &featureDecision)

// Fall back to other decision types if CMAB didn't work
if !useCMAB {
// To avoid cyclo-complexity warning - forced decision logic
findForcedDecision := func() bool {
if userContext.forcedDecisionService != nil {
var variation *entities.Variation
var forcedErr error
variation, decisionReasonsList, forcedErr = userContext.forcedDecisionService.FindValidatedForcedDecision(projectConfig, decision.OptimizelyDecisionContext{FlagKey: key, RuleKey: ""}, &allOptions) // Fix shadowing by using assignment instead of declaration
decisionReasons.Append(decisionReasonsList)
if forcedErr != nil {
return false
}
featureDecision = decision.FeatureDecision{Decision: decision.Decision{Reason: pkgReasons.ForcedDecisionFound}, Variation: variation, Source: decision.FeatureTest}
return true
}
return false
}

// check forced-decisions first
// Passing empty rule-key because checking mapping with flagKey only
if userContext.forcedDecisionService != nil {
var variation *entities.Variation
variation, reasons, err = userContext.forcedDecisionService.FindValidatedForcedDecision(projectConfig, decision.OptimizelyDecisionContext{FlagKey: key, RuleKey: ""}, &allOptions)
decisionReasons.Append(reasons)
if err != nil {
// To avoid cyclo-complexity warning - regular decision logic
findRegularDecision := func() {
// regular decision
featureDecision, decisionReasonsList, err = o.DecisionService.GetFeatureDecision(decisionContext, usrContext, &allOptions)
decisionReasons.Append(decisionReasonsList)
}

if !findForcedDecision() {
findRegularDecision()
} else {
featureDecision = decision.FeatureDecision{Decision: decision.Decision{Reason: pkgReasons.ForcedDecisionFound}, Variation: variation, Source: decision.FeatureTest}
}
} else {
findRegularDecision()
}

if err != nil {
return o.handleDecisionServiceError(err, key, *userContext)
}

return o.buildDecisionResponse(featureDecision, feature, key, userContext, &allOptions, decisionReasons, decisionContext)
}

// tryGetCMABDecision attempts to get a CMAB decision for the feature
func (o *OptimizelyClient) tryGetCMABDecision(feature entities.Feature, projectConfig config.ProjectConfig, usrContext entities.UserContext, options *decide.Options, decisionReasons decide.DecisionReasons, featureDecision *decision.FeatureDecision) bool {
if o.cmabService == nil {
return false
}

for _, experimentID := range feature.ExperimentIDs {
experiment, expErr := projectConfig.GetExperimentByID(experimentID) // Fix shadowing

// Handle CMAB error properly - check for errors BEFORE using the experiment
if expErr == nil && experiment.Cmab != nil {
cmabDecision, cmabErr := o.cmabService.GetDecision(projectConfig, usrContext, experiment.ID, options)

// Handle CMAB service errors gracefully - log and continue to next experiment
if cmabErr != nil {
o.logger.Warning(fmt.Sprintf("CMAB decision failed for experiment %s: %v", experiment.ID, cmabErr))
continue
}

// Validate CMAB response - ensure variation exists before using it
if selectedVariation, exists := experiment.Variations[cmabDecision.VariationID]; exists {
*featureDecision = decision.FeatureDecision{
Decision: decision.Decision{Reason: "CMAB decision"},
Variation: &selectedVariation,
Experiment: experiment,
Source: decision.FeatureTest,
CmabUUID: &cmabDecision.CmabUUID, // Include CMAB UUID for tracking
}
decisionReasons.AddInfo("Used CMAB service for decision")
return true
}
// Log invalid variation ID returned by CMAB service
o.logger.Warning(fmt.Sprintf("CMAB returned invalid variation ID %s for experiment %s", cmabDecision.VariationID, experiment.ID))
}
}
return false
}

// buildDecisionResponse constructs the final OptimizelyDecision response
func (o *OptimizelyClient) buildDecisionResponse(featureDecision decision.FeatureDecision, feature entities.Feature, key string, userContext *OptimizelyUserContext, options *decide.Options, decisionReasons decide.DecisionReasons, decisionContext decision.FeatureDecisionContext) OptimizelyDecision {
var variationKey string
var eventSent, flagEnabled bool
var experimentID, variationID string

if featureDecision.Variation != nil {
variationKey = featureDecision.Variation.Key
flagEnabled = featureDecision.Variation.FeatureEnabled
experimentID = featureDecision.Experiment.ID
variationID = featureDecision.Variation.ID
}

if !allOptions.DisableDecisionEvent {
usrContext := entities.UserContext{
ID: userContext.GetUserID(),
Attributes: userContext.GetUserAttributes(),
QualifiedSegments: userContext.GetQualifiedSegments(),
}

// Send impression event
if !options.DisableDecisionEvent {
if ue, ok := event.CreateImpressionUserEvent(decisionContext.ProjectConfig, featureDecision.Experiment,
featureDecision.Variation, usrContext, key, featureDecision.Experiment.Key, featureDecision.Source, flagEnabled, featureDecision.CmabUUID); ok {
o.EventProcessor.ProcessEvent(ue)
eventSent = true
}
}

// Get variable map
variableMap := map[string]interface{}{}
if !allOptions.ExcludeVariables {
if !options.ExcludeVariables {
var reasons decide.DecisionReasons
variableMap, reasons = o.getDecisionVariableMap(feature, featureDecision.Variation, flagEnabled)
decisionReasons.Append(reasons)
}
optimizelyJSON := optimizelyjson.NewOptimizelyJSONfromMap(variableMap)
reasonsToReport := decisionReasons.ToReport()
ruleKey := featureDecision.Experiment.Key

// Send notification
if o.notificationCenter != nil {
reasonsToReport := decisionReasons.ToReport()
ruleKey := featureDecision.Experiment.Key
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)
optimizelyJSON := optimizelyjson.NewOptimizelyJSONfromMap(variableMap)
reasonsToReport := decisionReasons.ToReport()
ruleKey := featureDecision.Experiment.Key

return NewOptimizelyDecision(variationKey, ruleKey, key, flagEnabled, optimizelyJSON, *userContext, reasonsToReport, featureDecision.CmabUUID)
}

func (o *OptimizelyClient) decideForKeys(userContext OptimizelyUserContext, keys []string, options *decide.Options) map[string]OptimizelyDecision {
Expand Down Expand Up @@ -469,7 +539,7 @@ func (o *OptimizelyClient) Activate(experimentKey string, userContext entities.U
}

// IsFeatureEnabled returns true if the feature is enabled for the given user. If the user is part of a feature test
// then an impression event will be queued up to be sent to the Optimizely log endpoint for results processing.
// then an impression event will be queued up to the Optimizely log endpoint for results processing.
func (o *OptimizelyClient) IsFeatureEnabled(featureKey string, userContext entities.UserContext) (result bool, err error) {

defer func() {
Expand Down Expand Up @@ -1072,7 +1142,7 @@ func (o *OptimizelyClient) getFeatureDecision(featureKey, variableKey string, us
featureDecision, _, err = o.DecisionService.GetFeatureDecision(decisionContext, userContext, options)
if err != nil {
o.logger.Warning(fmt.Sprintf(`Received error while making a decision for feature %q: %s`, featureKey, err))
return decisionContext, featureDecision, nil
return decisionContext, featureDecision, err
}

return decisionContext, featureDecision, nil
Expand Down Expand Up @@ -1264,5 +1334,6 @@ func (o *OptimizelyClient) handleDecisionServiceError(err error, key string, use
Enabled: false,
Variables: optimizelyjson.NewOptimizelyJSONfromMap(map[string]interface{}{}),
Reasons: []string{err.Error()},
CmabUUID: nil,
}
}
Loading