diff --git a/pkg/decision/composite_experiment_service.go b/pkg/decision/composite_experiment_service.go index d9069dfb..786ff45c 100644 --- a/pkg/decision/composite_experiment_service.go +++ b/pkg/decision/composite_experiment_service.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2020, 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. * @@ -40,11 +40,19 @@ func WithOverrideStore(overrideStore ExperimentOverrideStore) CESOptionFunc { } } +// WithCmabService adds a CMAB service +func WithCmabService(cmabService CmabService) CESOptionFunc { + return func(f *CompositeExperimentService) { + f.cmabService = cmabService + } +} + // CompositeExperimentService bridges together the various experiment decision services that ship by default with the SDK type CompositeExperimentService struct { experimentServices []ExperimentService overrideStore ExperimentOverrideStore userProfileService UserProfileService + cmabService CmabService logger logging.OptimizelyLogProducer } @@ -53,7 +61,8 @@ func NewCompositeExperimentService(sdkKey string, options ...CESOptionFunc) *Com // These decision services are applied in order: // 1. Overrides (if supplied) // 2. Whitelist - // 3. Bucketing (with User profile integration if supplied) + // 3. CMAB (if experiment is a CMAB experiment) + // 4. Bucketing (with User profile integration if supplied) compositeExperimentService := &CompositeExperimentService{logger: logging.GetLogger(sdkKey, "CompositeExperimentService")} for _, opt := range options { opt(compositeExperimentService) @@ -68,6 +77,12 @@ func NewCompositeExperimentService(sdkKey string, options ...CESOptionFunc) *Com experimentServices = append([]ExperimentService{overrideService}, experimentServices...) } + // Add CMAB service if available + if compositeExperimentService.cmabService != nil { + cmabService := NewExperimentCmabService(compositeExperimentService.cmabService, logging.GetLogger(sdkKey, "ExperimentCmabService")) + experimentServices = append(experimentServices, cmabService) + } + experimentBucketerService := NewExperimentBucketerService(logging.GetLogger(sdkKey, "ExperimentBucketerService")) if compositeExperimentService.userProfileService != nil { persistingExperimentService := NewPersistingExperimentService(compositeExperimentService.userProfileService, experimentBucketerService, logging.GetLogger(sdkKey, "PersistingExperimentService")) diff --git a/pkg/decision/entities.go b/pkg/decision/entities.go index bebfe5f4..31376b90 100644 --- a/pkg/decision/entities.go +++ b/pkg/decision/entities.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2021, 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. * @@ -55,6 +55,8 @@ const ( Rollout Source = "rollout" // FeatureTest - the decision came from a feature test FeatureTest Source = "feature-test" + // Cmab - the decision came from a CMAB service + Cmab Source = "cmab" ) // Decision contains base information about a decision diff --git a/pkg/decision/experiment_cmab_service.go b/pkg/decision/experiment_cmab_service.go new file mode 100644 index 00000000..343fc694 --- /dev/null +++ b/pkg/decision/experiment_cmab_service.go @@ -0,0 +1,97 @@ +/**************************************************************************** + * 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 decision // +package decision + +import ( + "errors" + "fmt" + + "github.com/optimizely/go-sdk/v2/pkg/decide" + "github.com/optimizely/go-sdk/v2/pkg/decision/reasons" + "github.com/optimizely/go-sdk/v2/pkg/entities" + "github.com/optimizely/go-sdk/v2/pkg/logging" +) + +// ExperimentCmabService makes decisions for CMAB experiments +type ExperimentCmabService struct { + cmabService CmabService + logger logging.OptimizelyLogProducer +} + +// NewExperimentCmabService creates a new instance of ExperimentCmabService +func NewExperimentCmabService(cmabService CmabService, logger logging.OptimizelyLogProducer) *ExperimentCmabService { + return &ExperimentCmabService{ + cmabService: cmabService, + logger: logger, + } +} + +// GetDecision returns a decision for the given experiment and user context +func (s *ExperimentCmabService) GetDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext, options *decide.Options) (decision ExperimentDecision, decisionReasons decide.DecisionReasons, err error) { + decisionReasons = decide.NewDecisionReasons(options) + experiment := decisionContext.Experiment + projectConfig := decisionContext.ProjectConfig + + // Check if experiment is nil or not a CMAB experiment + if experiment == nil || !isCmab(*experiment) { + message := "Not a CMAB experiment, skipping CMAB decision service" + decisionReasons.AddInfo(message) + return decision, decisionReasons, nil + } + + // Check if CMAB service is available + if s.cmabService == nil { + message := "CMAB service is not available" + decisionReasons.AddInfo(message) + return decision, decisionReasons, errors.New(message) + } + + // Get CMAB decision + cmabDecision, err := s.cmabService.GetDecision(projectConfig, userContext, experiment.ID, options) + if err != nil { + message := fmt.Sprintf("Failed to get CMAB decision: %v", err) + decisionReasons.AddInfo(message) + return decision, decisionReasons, fmt.Errorf("failed to get CMAB decision: %w", err) + } + + // Find variation by ID + for _, variation := range experiment.Variations { + if variation.ID != cmabDecision.VariationID { + continue + } + + // Create a copy of the variation to avoid memory aliasing + variationCopy := variation + decision.Variation = &variationCopy + decision.Reason = reasons.CmabVariationAssigned + message := fmt.Sprintf("User bucketed into variation %s by CMAB service", variation.Key) + decisionReasons.AddInfo(message) + return decision, decisionReasons, nil + } + + // If we get here, the variation ID returned by CMAB service was not found + message := fmt.Sprintf("variation with ID %s not found in experiment %s", cmabDecision.VariationID, experiment.ID) + decisionReasons.AddInfo(message) + return decision, decisionReasons, fmt.Errorf("variation with ID %s not found in experiment %s", cmabDecision.VariationID, experiment.ID) + +} + +// isCmab is a helper method to check if an experiment is a CMAB experiment +func isCmab(experiment entities.Experiment) bool { + return experiment.Cmab != nil +} diff --git a/pkg/decision/experiment_cmab_service_test.go b/pkg/decision/experiment_cmab_service_test.go new file mode 100644 index 00000000..ddb01a59 --- /dev/null +++ b/pkg/decision/experiment_cmab_service_test.go @@ -0,0 +1,329 @@ +/**************************************************************************** + * 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 decision + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/optimizely/go-sdk/v2/pkg/config" + "github.com/optimizely/go-sdk/v2/pkg/decide" + "github.com/optimizely/go-sdk/v2/pkg/decision/reasons" + "github.com/optimizely/go-sdk/v2/pkg/entities" + "github.com/optimizely/go-sdk/v2/pkg/logging" +) + +type ExperimentCmabTestSuite struct { + suite.Suite + mockCmabService *MockCmabService + mockProjectConfig *mockProjectConfig + testUserContext entities.UserContext + options *decide.Options + logger logging.OptimizelyLogProducer + cmabExperiment entities.Experiment + nonCmabExperiment entities.Experiment +} + +func (s *ExperimentCmabTestSuite) SetupTest() { + s.mockCmabService = new(MockCmabService) + s.mockProjectConfig = new(mockProjectConfig) + s.logger = logging.GetLogger("test_sdk_key", "ExperimentCmabService") + s.options = &decide.Options{ + IncludeReasons: true, // Enable reasons + } + + // Setup test user context + s.testUserContext = entities.UserContext{ + ID: "test_user_1", + Attributes: map[string]interface{}{ + "attr1": "value1", + }, + } + + // Setup CMAB experiment + s.cmabExperiment = entities.Experiment{ + ID: "cmab_exp_1", + Key: "cmab_experiment", + Cmab: &entities.Cmab{ + AttributeIds: []string{"attr1", "attr2"}, + }, + Variations: map[string]entities.Variation{ + "var1": { + ID: "var1", + Key: "variation_1", + }, + "var2": { + ID: "var2", + Key: "variation_2", + }, + }, + } + + // Setup non-CMAB experiment + s.nonCmabExperiment = entities.Experiment{ + ID: "non_cmab_exp_1", + Key: "non_cmab_experiment", + Variations: map[string]entities.Variation{ + "var1": { + ID: "var1", + Key: "variation_1", + }, + "var2": { + ID: "var2", + Key: "variation_2", + }, + }, + } +} + +func (s *ExperimentCmabTestSuite) TestIsCmab() { + // Test with CMAB experiment + s.True(isCmab(s.cmabExperiment)) + + // Test with non-CMAB experiment + s.False(isCmab(s.nonCmabExperiment)) +} + +func (s *ExperimentCmabTestSuite) TestGetDecisionWithNilExperiment() { + // Create decision context with nil experiment + decisionContext := ExperimentDecisionContext{ + Experiment: nil, + ProjectConfig: s.mockProjectConfig, + } + + // Create CMAB service + cmabService := NewExperimentCmabService(s.mockCmabService, s.logger) + + // Get decision + decision, decisionReasons, err := cmabService.GetDecision(decisionContext, s.testUserContext, s.options) + + // Verify results + s.Nil(decision.Variation) + s.NoError(err) + + // Check for the message in the reasons + report := decisionReasons.ToReport() + s.NotEmpty(report, "Decision reasons report should not be empty") + found := false + for _, msg := range report { + if msg == "Not a CMAB experiment, skipping CMAB decision service" { + found = true + break + } + } + s.True(found, "Expected message not found in decision reasons") + + // Verify mock expectations + s.mockCmabService.AssertNotCalled(s.T(), "GetDecision") +} + +func (s *ExperimentCmabTestSuite) TestGetDecisionWithNonCmabExperiment() { + // Create decision context with non-CMAB experiment + decisionContext := ExperimentDecisionContext{ + Experiment: &s.nonCmabExperiment, + ProjectConfig: s.mockProjectConfig, + } + + // Create CMAB service + cmabService := NewExperimentCmabService(s.mockCmabService, s.logger) + + // Get decision + decision, decisionReasons, err := cmabService.GetDecision(decisionContext, s.testUserContext, s.options) + + // Verify results + s.Nil(decision.Variation) + s.NoError(err) + + // Check for the message in the reasons + report := decisionReasons.ToReport() + s.NotEmpty(report, "Decision reasons report should not be empty") + found := false + for _, msg := range report { + if msg == "Not a CMAB experiment, skipping CMAB decision service" { + found = true + break + } + } + s.True(found, "Expected message not found in decision reasons") + + // Verify mock expectations + s.mockCmabService.AssertNotCalled(s.T(), "GetDecision") +} + +func (s *ExperimentCmabTestSuite) TestGetDecisionWithNilCmabService() { + // Create decision context with CMAB experiment + decisionContext := ExperimentDecisionContext{ + Experiment: &s.cmabExperiment, + ProjectConfig: s.mockProjectConfig, + } + + // Create CMAB service with nil CMAB service + cmabService := NewExperimentCmabService(nil, s.logger) + + // Get decision + decision, decisionReasons, err := cmabService.GetDecision(decisionContext, s.testUserContext, s.options) + + // Verify results + s.Nil(decision.Variation) + s.Error(err) + s.Equal("CMAB service is not available", err.Error()) + + // Check for the message in the reasons + report := decisionReasons.ToReport() + s.NotEmpty(report, "Decision reasons report should not be empty") + found := false + for _, msg := range report { + if msg == "CMAB service is not available" { + found = true + break + } + } + s.True(found, "Expected message not found in decision reasons") +} + +func (s *ExperimentCmabTestSuite) TestGetDecisionWithCmabServiceError() { + // Create decision context with CMAB experiment + decisionContext := ExperimentDecisionContext{ + Experiment: &s.cmabExperiment, + ProjectConfig: s.mockProjectConfig, + } + + // Setup mock CMAB service to return error + s.mockCmabService.On("GetDecision", s.mockProjectConfig, s.testUserContext, "cmab_exp_1", s.options). + Return(CmabDecision{}, errors.New("CMAB service error")) + + // Create CMAB service + cmabService := NewExperimentCmabService(s.mockCmabService, s.logger) + + // Get decision + decision, decisionReasons, err := cmabService.GetDecision(decisionContext, s.testUserContext, s.options) + + // Verify results + s.Nil(decision.Variation) + s.Error(err) + s.Equal("failed to get CMAB decision: CMAB service error", err.Error()) + + // Check for the message in the reasons + report := decisionReasons.ToReport() + s.NotEmpty(report, "Decision reasons report should not be empty") + found := false + for _, msg := range report { + if msg == "Failed to get CMAB decision: CMAB service error" { + found = true + break + } + } + s.True(found, "Expected message not found in decision reasons") + + // Verify mock expectations + s.mockCmabService.AssertExpectations(s.T()) +} + +func (s *ExperimentCmabTestSuite) TestGetDecisionWithInvalidVariationID() { + // Create decision context with CMAB experiment + decisionContext := ExperimentDecisionContext{ + Experiment: &s.cmabExperiment, + ProjectConfig: s.mockProjectConfig, + } + + // Setup mock CMAB service to return invalid variation ID + s.mockCmabService.On("GetDecision", s.mockProjectConfig, s.testUserContext, "cmab_exp_1", s.options). + Return(CmabDecision{VariationID: "invalid_var_id"}, nil) + + // Create CMAB service + cmabService := NewExperimentCmabService(s.mockCmabService, s.logger) + + // Get decision + decision, decisionReasons, err := cmabService.GetDecision(decisionContext, s.testUserContext, s.options) + + // Verify results + s.Nil(decision.Variation) + s.Error(err) + s.Equal("variation with ID invalid_var_id not found in experiment cmab_exp_1", err.Error()) + + // Check for the message in the reasons + report := decisionReasons.ToReport() + s.NotEmpty(report, "Decision reasons report should not be empty") + found := false + for _, msg := range report { + if msg == "variation with ID invalid_var_id not found in experiment cmab_exp_1" { + found = true + break + } + } + s.True(found, "Expected message not found in decision reasons") + + // Verify mock expectations + s.mockCmabService.AssertExpectations(s.T()) +} + +func (s *ExperimentCmabTestSuite) TestGetDecisionSuccess() { + // Create decision context with CMAB experiment + decisionContext := ExperimentDecisionContext{ + Experiment: &s.cmabExperiment, + ProjectConfig: s.mockProjectConfig, + } + + // Setup mock CMAB service to return valid variation ID + s.mockCmabService.On("GetDecision", s.mockProjectConfig, s.testUserContext, "cmab_exp_1", s.options). + Return(CmabDecision{VariationID: "var1"}, nil) + + // Create CMAB service + cmabService := NewExperimentCmabService(s.mockCmabService, s.logger) + + // Get decision + decision, decisionReasons, err := cmabService.GetDecision(decisionContext, s.testUserContext, s.options) + + // Verify results + s.NotNil(decision.Variation) + s.Equal("var1", decision.Variation.ID) + s.Equal("variation_1", decision.Variation.Key) + s.Equal(reasons.CmabVariationAssigned, decision.Reason) + s.NoError(err) + + // Check for the message in the reasons + report := decisionReasons.ToReport() + s.NotEmpty(report, "Decision reasons report should not be empty") + found := false + for _, msg := range report { + if msg == "User bucketed into variation variation_1 by CMAB service" { + found = true + break + } + } + s.True(found, "Expected message not found in decision reasons") + + // Verify mock expectations + s.mockCmabService.AssertExpectations(s.T()) +} + +// Mock CMAB service for testing +type MockCmabService struct { + mock.Mock +} + +func (m *MockCmabService) GetDecision(projectConfig config.ProjectConfig, userContext entities.UserContext, ruleID string, options *decide.Options) (CmabDecision, error) { + args := m.Called(projectConfig, userContext, ruleID, options) + return args.Get(0).(CmabDecision), args.Error(1) +} + +func TestExperimentCmabTestSuite(t *testing.T) { + suite.Run(t, new(ExperimentCmabTestSuite)) +} diff --git a/pkg/decision/reasons/reason.go b/pkg/decision/reasons/reason.go index 4814fb69..74d5a58c 100644 --- a/pkg/decision/reasons/reason.go +++ b/pkg/decision/reasons/reason.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2021, 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. * @@ -59,4 +59,6 @@ const ( InvalidOverrideVariationAssignment Reason = "Invalid override variation assignment" // OverrideVariationAssignmentFound - A valid override variation was found for the given user and experiment OverrideVariationAssignmentFound Reason = "Override variation assignment found" + // CmabVariationAssigned is the reason when a variation is assigned by the CMAB service + CmabVariationAssigned Reason = "cmab_variation_assigned" )