diff --git a/pkg/cmab/service_test.go b/pkg/cmab/service_test.go index 7c13edf8..4675d773 100644 --- a/pkg/cmab/service_test.go +++ b/pkg/cmab/service_test.go @@ -230,6 +230,11 @@ func (m *MockProjectConfig) GetRegion() string { return args.String(0) } +func (m *MockProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Holdout { + args := m.Called(featureKey) + return args.Get(0).([]entities.Holdout) +} + type CmabServiceTestSuite struct { suite.Suite mockClient *MockCmabClient diff --git a/pkg/config/datafileprojectconfig/config.go b/pkg/config/datafileprojectconfig/config.go index 759b79ea..0c53400f 100644 --- a/pkg/config/datafileprojectconfig/config.go +++ b/pkg/config/datafileprojectconfig/config.go @@ -281,6 +281,13 @@ func (c DatafileProjectConfig) GetRegion() string { return c.region } +// GetHoldoutsForFlag returns all holdouts applicable to the given feature flag +// TODO: Implementation will be added in holdout parsing PR +func (c DatafileProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Holdout { + // Stub implementation - will be replaced with actual holdout logic + return []entities.Holdout{} +} + // NewDatafileProjectConfig initializes a new datafile from a json byte array using the default JSON datafile parser func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogProducer) (*DatafileProjectConfig, error) { datafile, err := Parse(jsonDatafile) diff --git a/pkg/config/interface.go b/pkg/config/interface.go index 04ee0d05..6d45c14a 100644 --- a/pkg/config/interface.go +++ b/pkg/config/interface.go @@ -56,6 +56,7 @@ type ProjectConfig interface { GetAttributes() []entities.Attribute GetFlagVariationsMap() map[string][]entities.Variation GetRegion() string + GetHoldoutsForFlag(featureKey string) []entities.Holdout } // ProjectConfigManager maintains an instance of the ProjectConfig diff --git a/pkg/decision/composite_feature_service.go b/pkg/decision/composite_feature_service.go index b92ded53..2c1b2b54 100644 --- a/pkg/decision/composite_feature_service.go +++ b/pkg/decision/composite_feature_service.go @@ -34,6 +34,7 @@ func NewCompositeFeatureService(sdkKey string, compositeExperimentService Experi return &CompositeFeatureService{ logger: logging.GetLogger(sdkKey, "CompositeFeatureService"), featureServices: []FeatureService{ + NewHoldoutService(sdkKey), NewFeatureExperimentService(logging.GetLogger(sdkKey, "FeatureExperimentService"), compositeExperimentService), NewRolloutService(sdkKey), }, diff --git a/pkg/decision/composite_feature_service_test.go b/pkg/decision/composite_feature_service_test.go index 412a810c..5a75b5eb 100644 --- a/pkg/decision/composite_feature_service_test.go +++ b/pkg/decision/composite_feature_service_test.go @@ -202,9 +202,10 @@ func (s *CompositeFeatureServiceTestSuite) TestNewCompositeFeatureService() { // Assert that the service is instantiated with the correct child services in the right order compositeExperimentService := NewCompositeExperimentService("") compositeFeatureService := NewCompositeFeatureService("", compositeExperimentService) - s.Equal(2, len(compositeFeatureService.featureServices)) - s.IsType(&FeatureExperimentService{compositeExperimentService: compositeExperimentService}, compositeFeatureService.featureServices[0]) - s.IsType(&RolloutService{}, compositeFeatureService.featureServices[1]) + s.Equal(3, len(compositeFeatureService.featureServices)) + s.IsType(&HoldoutService{}, compositeFeatureService.featureServices[0]) + s.IsType(&FeatureExperimentService{compositeExperimentService: compositeExperimentService}, compositeFeatureService.featureServices[1]) + s.IsType(&RolloutService{}, compositeFeatureService.featureServices[2]) } func TestCompositeFeatureTestSuite(t *testing.T) { diff --git a/pkg/decision/entities.go b/pkg/decision/entities.go index 621b892a..fec60903 100644 --- a/pkg/decision/entities.go +++ b/pkg/decision/entities.go @@ -55,6 +55,8 @@ const ( Rollout Source = "rollout" // FeatureTest - the decision came from a feature test FeatureTest Source = "feature-test" + // Holdout - the decision came from a holdout + Holdout Source = "holdout" ) // Decision contains base information about a decision diff --git a/pkg/decision/evaluator/audience_evaluator_test.go b/pkg/decision/evaluator/audience_evaluator_test.go index 4d57a912..75897aed 100644 --- a/pkg/decision/evaluator/audience_evaluator_test.go +++ b/pkg/decision/evaluator/audience_evaluator_test.go @@ -200,6 +200,11 @@ func (m *MockProjectConfig) GetRegion() string { return args.String(0) } +func (m *MockProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Holdout { + args := m.Called(featureKey) + return args.Get(0).([]entities.Holdout) +} + // MockLogger is a mock implementation of OptimizelyLogProducer // (This declaration has been removed to resolve the redeclaration error) diff --git a/pkg/decision/holdout_service.go b/pkg/decision/holdout_service.go new file mode 100644 index 00000000..4a19d975 --- /dev/null +++ b/pkg/decision/holdout_service.go @@ -0,0 +1,154 @@ +/**************************************************************************** + * 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 ( + "fmt" + + "github.com/optimizely/go-sdk/v2/pkg/config" + "github.com/optimizely/go-sdk/v2/pkg/decide" + "github.com/optimizely/go-sdk/v2/pkg/decision/bucketer" + "github.com/optimizely/go-sdk/v2/pkg/decision/evaluator" + "github.com/optimizely/go-sdk/v2/pkg/entities" + "github.com/optimizely/go-sdk/v2/pkg/logging" +) + +// HoldoutService evaluates holdout groups for feature flags +type HoldoutService struct { + audienceTreeEvaluator evaluator.TreeEvaluator + bucketer bucketer.ExperimentBucketer + logger logging.OptimizelyLogProducer +} + +// NewHoldoutService returns a new instance of the HoldoutService +func NewHoldoutService(sdkKey string) *HoldoutService { + logger := logging.GetLogger(sdkKey, "HoldoutService") + return &HoldoutService{ + audienceTreeEvaluator: evaluator.NewMixedTreeEvaluator(logger), + bucketer: *bucketer.NewMurmurhashExperimentBucketer(logger, bucketer.DefaultHashSeed), + logger: logger, + } +} + +// GetDecision returns a decision for holdouts associated with the feature +func (h HoldoutService) GetDecision(decisionContext FeatureDecisionContext, userContext entities.UserContext, options *decide.Options) (FeatureDecision, decide.DecisionReasons, error) { + feature := decisionContext.Feature + reasons := decide.NewDecisionReasons(options) + + holdouts := decisionContext.ProjectConfig.GetHoldoutsForFlag(feature.Key) + + for _, holdout := range holdouts { + h.logger.Debug(fmt.Sprintf("Evaluating holdout %s for feature %s", holdout.Key, feature.Key)) + + // Check if holdout is running + if holdout.Status != entities.HoldoutStatusRunning { + reason := reasons.AddInfo("Holdout %s is not running.", holdout.Key) + h.logger.Info(reason) + continue + } + + // Check audience conditions + inAudience := h.checkIfUserInHoldoutAudience(&holdout, userContext, decisionContext.ProjectConfig, options) + reasons.Append(inAudience.reasons) + + if !inAudience.result { + reason := reasons.AddInfo("User %s does not meet conditions for holdout %s.", userContext.ID, holdout.Key) + h.logger.Info(reason) + continue + } + + reason := reasons.AddInfo("User %s meets conditions for holdout %s.", userContext.ID, holdout.Key) + h.logger.Info(reason) + + // Get bucketing ID + bucketingID, err := userContext.GetBucketingID() + if err != nil { + errorMessage := reasons.AddInfo("Error computing bucketing ID for holdout %q: %q", holdout.Key, err.Error()) + h.logger.Debug(errorMessage) + } + + if bucketingID != userContext.ID { + h.logger.Debug(fmt.Sprintf("Using bucketing ID: %q for user %q", bucketingID, userContext.ID)) + } + + // Convert holdout to experiment structure for bucketing + experimentForBucketing := entities.Experiment{ + ID: holdout.ID, + Key: holdout.Key, + Variations: holdout.Variations, + TrafficAllocation: holdout.TrafficAllocation, + AudienceIds: holdout.AudienceIds, + AudienceConditions: holdout.AudienceConditions, + AudienceConditionTree: holdout.AudienceConditionTree, + } + + // Bucket user into holdout variation + variation, _, _ := h.bucketer.Bucket(bucketingID, experimentForBucketing, entities.Group{}) + + if variation != nil { + reason := reasons.AddInfo("User %s is in variation %s of holdout %s.", userContext.ID, variation.Key, holdout.Key) + h.logger.Info(reason) + + featureDecision := FeatureDecision{ + Experiment: experimentForBucketing, + Variation: variation, + Source: Holdout, + } + return featureDecision, reasons, nil + } + + reason = reasons.AddInfo("User %s is in no holdout variation.", userContext.ID) + h.logger.Info(reason) + } + + return FeatureDecision{}, reasons, nil +} + +// checkIfUserInHoldoutAudience evaluates if user meets holdout audience conditions +func (h HoldoutService) checkIfUserInHoldoutAudience(holdout *entities.Holdout, userContext entities.UserContext, projectConfig config.ProjectConfig, options *decide.Options) decisionResult { + decisionReasons := decide.NewDecisionReasons(options) + + if holdout == nil { + logMessage := decisionReasons.AddInfo("Holdout is nil, defaulting to false") + h.logger.Debug(logMessage) + return decisionResult{result: false, reasons: decisionReasons} + } + + if holdout.AudienceConditionTree != nil { + condTreeParams := entities.NewTreeParameters(&userContext, projectConfig.GetAudienceMap()) + h.logger.Debug(fmt.Sprintf("Evaluating audiences for holdout %q.", holdout.Key)) + + evalResult, _, audienceReasons := h.audienceTreeEvaluator.Evaluate(holdout.AudienceConditionTree, condTreeParams, options) + decisionReasons.Append(audienceReasons) + + logMessage := decisionReasons.AddInfo("Audiences for holdout %s collectively evaluated to %v.", holdout.Key, evalResult) + h.logger.Debug(logMessage) + + return decisionResult{result: evalResult, reasons: decisionReasons} + } + + logMessage := decisionReasons.AddInfo("Audiences for holdout %s collectively evaluated to true.", holdout.Key) + h.logger.Debug(logMessage) + return decisionResult{result: true, reasons: decisionReasons} +} + +// decisionResult is a helper struct to return both result and reasons +type decisionResult struct { + result bool + reasons decide.DecisionReasons +} diff --git a/pkg/entities/experiment.go b/pkg/entities/experiment.go index 50001cc2..6d04e581 100644 --- a/pkg/entities/experiment.go +++ b/pkg/entities/experiment.go @@ -59,3 +59,23 @@ type VariationVariable struct { ID string Value string } + +// HoldoutStatus represents the status of a holdout +type HoldoutStatus string + +const ( + // HoldoutStatusRunning - the holdout status is running + HoldoutStatusRunning HoldoutStatus = "Running" +) + +// Holdout represents a holdout that can be applied to feature flags +type Holdout struct { + ID string + Key string + Status HoldoutStatus + AudienceIds []string + AudienceConditions interface{} + Variations map[string]Variation // keyed by variation ID + TrafficAllocation []Range + AudienceConditionTree *TreeNode +}