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 19 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
142 changes: 112 additions & 30 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
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,74 +175,142 @@ 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")
}
}

optimizelyJSON := optimizelyjson.NewOptimizelyJSONfromMap(variableMap)
reasonsToReport := decisionReasons.ToReport()
ruleKey := featureDecision.Experiment.Key

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

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 @@ -1199,6 +1269,9 @@ func (o *OptimizelyClient) getAllOptions(options *decide.Options) decide.Options
ExcludeVariables: o.defaultDecideOptions.ExcludeVariables || options.ExcludeVariables,
IgnoreUserProfileService: o.defaultDecideOptions.IgnoreUserProfileService || options.IgnoreUserProfileService,
IncludeReasons: o.defaultDecideOptions.IncludeReasons || options.IncludeReasons,
IgnoreCMABCache: o.defaultDecideOptions.IgnoreCMABCache || options.IgnoreCMABCache,
ResetCMABCache: o.defaultDecideOptions.ResetCMABCache || options.ResetCMABCache,
InvalidateUserCMABCache: o.defaultDecideOptions.InvalidateUserCMABCache || options.InvalidateUserCMABCache,
}
}

Expand Down Expand Up @@ -1252,5 +1325,14 @@ func isNil(v interface{}) bool {
func (o *OptimizelyClient) handleDecisionServiceError(err error, key string, userContext OptimizelyUserContext) OptimizelyDecision {
o.logger.Warning(fmt.Sprintf(`Received error while making a decision for feature %q: %s`, key, err))

return NewErrorDecision(key, userContext, err)
// Return the error decision with the correct format for decision fields
return OptimizelyDecision{
FlagKey: key,
UserContext: userContext,
VariationKey: "",
RuleKey: "",
Enabled: false,
Variables: optimizelyjson.NewOptimizelyJSONfromMap(map[string]interface{}{}),
Reasons: []string{err.Error()},
}
}
30 changes: 30 additions & 0 deletions pkg/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3186,6 +3186,36 @@ func (s *ClientTestSuiteTrackNotification) TestRemoveOnTrackThrowsErrorWhenRemov
mockNotificationCenter.AssertExpectations(s.T())
}

func TestOptimizelyClient_handleDecisionServiceError(t *testing.T) {
// Create the client
client := &OptimizelyClient{
logger: logging.GetLogger("", ""),
}

// Create a CMAB error
cmabErrorMessage := "Failed to fetch CMAB data for experiment exp_1."
cmabError := fmt.Errorf(cmabErrorMessage)

// Create a user context - needs to match the signature expected by handleDecisionServiceError
testUserContext := OptimizelyUserContext{
UserID: "test_user",
Attributes: map[string]interface{}{},
}

// Call the error handler directly
decision := client.handleDecisionServiceError(cmabError, "test_flag", testUserContext)

// Verify the decision is correctly formatted
assert.False(t, decision.Enabled)
assert.Equal(t, "", decision.VariationKey) // Should be empty string, not nil
assert.Equal(t, "", decision.RuleKey) // Should be empty string, not nil
assert.Contains(t, decision.Reasons, cmabErrorMessage)

// Check that reasons contains exactly the expected message
assert.Equal(t, 1, len(decision.Reasons), "Reasons array should have exactly one item")
assert.Equal(t, cmabErrorMessage, decision.Reasons[0], "Error message should be added verbatim")
}

func TestClientTestSuiteAB(t *testing.T) {
suite.Run(t, new(ClientTestSuiteAB))
}
Expand Down
13 changes: 13 additions & 0 deletions pkg/client/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"errors"
"time"

"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 @@ -53,6 +54,7 @@ type OptimizelyFactory struct {
overrideStore decision.ExperimentOverrideStore
userProfileService decision.UserProfileService
notificationCenter notification.Center
cmabService cmab.Service

// ODP
segmentsCacheSize int
Expand Down Expand Up @@ -173,6 +175,10 @@ func (f *OptimizelyFactory) Client(clientOptions ...OptionFunc) (*OptimizelyClie
eg.Go(batchProcessor.Start)
}

if f.cmabService != nil {
appClient.cmabService = f.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 @@ -320,6 +326,13 @@ func WithTracer(tracer tracing.Tracer) OptionFunc {
}
}

// WithCmabService sets the CMAB service on the client
func WithCmabService(cmabService cmab.Service) OptionFunc {
return func(f *OptimizelyFactory) {
f.cmabService = cmabService
}
}

// StaticClient returns a client initialized with a static project config.
func (f *OptimizelyFactory) StaticClient() (optlyClient *OptimizelyClient, err error) {

Expand Down
22 changes: 22 additions & 0 deletions pkg/cmab/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/****************************************************************************
* 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. *
***************************************************************************/

// Package cmab to define cmab errors//
package cmab

// CmabFetchFailed is the error message format for CMAB fetch failures
// Format required for FSC test compatibility - capitalized and with period
const CmabFetchFailed = "Failed to fetch CMAB data for experiment %s." //nolint:ST1005 // Required exact format for FSC test compatibility
11 changes: 8 additions & 3 deletions pkg/cmab/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package cmab

import (
"encoding/json"
"errors"
"fmt"
"strconv"

Expand Down Expand Up @@ -137,8 +138,9 @@ func (s *DefaultCmabService) GetDecision(
// Fetch new decision
decision, err := s.fetchDecision(ruleID, userContext.ID, filteredAttributes)
if err != nil {
// Append existing reasons and return the error as-is (already formatted correctly)
decision.Reasons = append(reasons, decision.Reasons...)
return decision, fmt.Errorf("CMAB API error: %w", err)
return decision, err
}

// Cache the decision
Expand Down Expand Up @@ -168,8 +170,11 @@ func (s *DefaultCmabService) fetchDecision(

variationID, err := s.cmabClient.FetchDecision(ruleID, userID, attributes, cmabUUID)
if err != nil {
reasons = append(reasons, "Failed to fetch CMAB decision")
return Decision{Reasons: reasons}, fmt.Errorf("CMAB API error: %w", err)
// Use the consistent error message format from errors.go
reason := fmt.Sprintf(CmabFetchFailed, ruleID)
reasons = append(reasons, reason)
// Use same format for Go error - FSC compatibility takes precedence
return Decision{Reasons: reasons}, errors.New(reason) //nolint:ST1005 // Required exact format for FSC test compatibility
}

reasons = append(reasons, "Successfully fetched CMAB decision")
Expand Down
Loading