diff --git a/FINAL_GO_SDK_CODE_SAMPLES.md b/FINAL_GO_SDK_CODE_SAMPLES.md new file mode 100644 index 00000000..737a31d7 --- /dev/null +++ b/FINAL_GO_SDK_CODE_SAMPLES.md @@ -0,0 +1,961 @@ +# Optimizely Go SDK - Complete Documentation Code Samples + +**Date Extracted**: 2026-02-17 +**Source**: https://docs.developers.optimizely.com/feature-experimentation/docs/go-sdk +**Total Code Samples**: 34 +**Purpose**: Verification against actual SDK source code for correctness + +--- + +## Table of Contents + +1. [Summary by Category](#summary-by-category) +2. [Key Observations](#key-observations) +3. [All Code Samples](#all-code-samples) + +--- + +## Summary by Category + +### Installation & Setup (2 samples) +- **#1**: Basic installation command (`go get`) +- **#2**: Install from CLI source with `go install` + +### Client Initialization (8 samples) +- **#3**: Basic initialization with SDK Key (background sync) +- **#4**: Static client with hard-coded datafile +- **#5**: Static client with SDK key (one-time datafile fetch) +- **#6**: Custom initialization with polling, event processing, decide options +- **#7**: Authenticated datafile access with access token +- **#23**: Event batching basic example +- **#24**: Event batching advanced example with custom options +- **#33**: Client with experiment overrides store + +### ODP (Optimizely Data Platform) Configuration (9 samples) +- **#8**: Complete ODP Manager setup with Event and Segment managers +- **#9**: EventApiManager interface definition +- **#10**: Custom EventApiManager with HTTP timeout configuration +- **#11**: SegmentAPIManager interface definition +- **#12**: Custom ODPEventManager with queue size and flush interval +- **#13**: Custom ODPSegmentManager with cache configuration +- **#14**: Custom cache interface definition +- **#15**: CustomSegmentsCache implementation with mutex +- **#16**: Using custom cache with ODP Manager + +### Decide Methods & User Context (6 samples) +- **#17**: Complete `decide()` example with all features +- **#18**: `DecideAll()` method for all flags +- **#19**: `DecideForKeys()` method for specific flags +- **#20**: Manual CMAB (Contextual Multi-Armed Bandit) cache control +- **#21**: CMAB decision reasons extraction +- **#22**: OptimizelyUserContext type definition with all methods + +### Event Handling & Notifications (5 samples) +- **#25**: LogEvent listener registration/unregistration +- **#26**: All notification listener types setup +- **#28**: TrackEvent with event properties and tags +- **#29**: Custom event dispatcher implementation +- **#30**: Using custom event dispatcher with client + +### Logging & Debugging (2 samples) +- **#31**: Custom logger implementation +- **#32**: Setting log level + +### Advanced Features (2 samples) +- **#33**: Experiment overrides store configuration +- **#34**: Getting forced variation from override store + +### Complete Examples (1 sample) +- **#27**: Full end-to-end usage workflow + +--- + +## Key Observations + +### Import Paths +All code samples reference both v1 and v2 import paths: +- **v1**: `github.com/optimizely/go-sdk` +- **v2**: `github.com/optimizely/go-sdk/v2` (with `/v2` suffix) + +### Common Packages Referenced +- `github.com/optimizely/go-sdk/pkg/client` +- `github.com/optimizely/go-sdk/pkg/decide` +- `github.com/optimizely/go-sdk/pkg/event` +- `github.com/optimizely/go-sdk/pkg/odp` +- `github.com/optimizely/go-sdk/pkg/odp/event` +- `github.com/optimizely/go-sdk/pkg/odp/segment` +- `github.com/optimizely/go-sdk/pkg/logging` +- `github.com/optimizely/go-sdk/pkg/decision` +- `github.com/optimizely/go-sdk/pkg/utils` +- `github.com/optimizely/go-sdk/pkg/entities` + +### Key Types & Functions to Verify +- `optly.Client(sdkKey)` - main client constructor +- `client.OptimizelyFactory{}` - factory for advanced configuration +- `client.CreateUserContext()` - user context creation +- `user.Decide()`, `DecideAll()`, `DecideForKeys()` - decision methods +- `user.TrackEvent()` - event tracking +- `event.NewBatchEventProcessor()` - event batching +- `odp.NewOdpManager()` - ODP manager creation +- Various `WithXxx()` option functions + +### Potential Issues to Check +1. **Sample #12 (line 197)**: Has a typo - ends with `)is` instead of just `)` +2. **Sample #20 (lines 393-395, 405-407)**: Comments appear to be split incorrectly (e.g., "// Always fetch fresh from CMAB" followed by "service") +3. All interface definitions should match actual SDK interfaces +4. All option functions (`WithXxx`) should exist in SDK +5. Type assertions and method signatures should be accurate + +--- + +## All Code Samples + +GO SDK DOCUMENTATION CODE SAMPLES +Total: 27 samples +================================================================================ + + +### CODE SAMPLE #1 +File: install-sdk-go.md +Section: Install the Go SDK +Language: go +-------------------------------------------------------------------------------- +go get github.com/optimizely/go-sdk // for v2: go get github.com/optimizely/go-sdk/v2 +-------------------------------------------------------------------------------- + +### CODE SAMPLE #2 +File: install-sdk-go.md +Section: Install from CLI source +Language: go +-------------------------------------------------------------------------------- +go get github.com/optimizely/go-sdk // for v2: go get github.com/optimizely/go-sdk/v2 +cd $GOPATH/src/github.com/optimizely/go-sdk +go install +-------------------------------------------------------------------------------- + +### CODE SAMPLE #3 +File: initialize-sdk-go.md +Section: Basic initialization with SDK Key +Language: go +-------------------------------------------------------------------------------- +import optly "github.com/optimizely/go-sdk" // for v2: "github.com/optimizely/go-sdk/v2" + +// Instantiates a client that syncs the datafile in the background +optlyClient, err := optly.Client("SDK_KEY_HERE") +if err != nil{ + // handle error +} +-------------------------------------------------------------------------------- + +### CODE SAMPLE #4 +File: initialize-sdk-go.md +Section: Static client instance +Language: go +-------------------------------------------------------------------------------- +import "github.com/optimizely/go-sdk/pkg/client" // for v2: "github.com/optimizely/go-sdk/v2/pkg/client" + +optimizelyFactory := &client.OptimizelyFactory{ + Datafile: []byte("DATAFILE_JSON_STRING_HERE"), +} + +// Instantiate a static client (no datafile polling) +staticOptlyClient, err := optimizelyFactory.StaticClient() +if err != nil { + // handle error +} +-------------------------------------------------------------------------------- + +### CODE SAMPLE #5 +File: initialize-sdk-go.md +Section: Static client instance +Language: go +-------------------------------------------------------------------------------- +import "github.com/optimizely/go-sdk/pkg/client" // for v2: "github.com/optimizely/go-sdk/v2/pkg/client" + +optimizelyFactory := &client.OptimizelyFactory{ + SDKKey: "[SDK_KEY_HERE]", +} + +// Instantiate a static client that will pull down the datafile one time +staticOptlyClient, err := optimizelyFactory.StaticClient() +if err != nil { + // handle error +} +-------------------------------------------------------------------------------- + +### CODE SAMPLE #6 +File: initialize-sdk-go.md +Section: Custom initialization +Language: go +-------------------------------------------------------------------------------- +import ( + "time" + + "github.com/optimizely/go-sdk/pkg/client" // for v2: "github.com/optimizely/go-sdk/v2/pkg/client" +) + +optimizelyFactory := &client.OptimizelyFactory{ + SDKKey: "[SDK_KEY_HERE]", + } + +datafilePollingInterval := 2 * time.Second +eventBatchSize := 20 +eventQueueSize := 1500 +eventFlushInterval := 10 * time.Second +defaultDecideOptions := []decide.OptimizelyDecideOptions{ + decide.IgnoreUserProfileService, +} + +// Instantiate a client with custom configuration +optimizelyClient, err := optimizelyFactory.Client( + client.WithPollingConfigManager(datafilePollingInterval, nil), + client.WithBatchEventProcessor( + eventBatchSize, + eventQueueSize, + eventFlushInterval, + ), + client.WithDefaultDecideOptions(defaultDecideOptions), +) +if err != nil { + // handle error +} +-------------------------------------------------------------------------------- + +### CODE SAMPLE #7 +File: initialize-sdk-go.md +Section: Use authenticated datafile in a secure environment +Language: go +-------------------------------------------------------------------------------- +// fetch the datafile from an authenticated endpoint +accessToken := `YOUR_DATAFILE_ACCESS_TOKEN` +sdkKey := `YOUR_SDK_KEY` +factory := client.OptimizelyFactory{SDKKey: sdkKey} +optimizelyClient, err := factory.Client(client.WithDatafileAccessToken(accessToken)) +if err != nil { + // handle error +} +-------------------------------------------------------------------------------- + +### CODE SAMPLE #8 +File: initialize-sdk-go.md +Section: `ODPEventManager` +Language: go +-------------------------------------------------------------------------------- +import ( + "github.com/optimizely/go-sdk/pkg/odp" // for v2: "github.com/optimizely/go-sdk/v2/pkg/odp" + "github.com/optimizely/go-sdk/pkg/odp/event" // for v2: "github.com/optimizely/go-sdk/v2/pkg/odp/event" + "github.com/optimizely/go-sdk/pkg/odp/segment" // for v2: "github.com/optimizely/go-sdk/v2/pkg/odp/segment" +) + +// You must configure Real-Time Audiences for Feature Experimentation +// before being able to calling ODPApiManager, ODPEventManager, and ODPSegmentManager. +sdkKey := `YOUR_SDK_KEY` +defaultEventApiManager := event.NewEventAPIManager(sdkKey, nil) +odpEventManager := event.NewBatchEventManager(event.WithAPIManager(defaultEventApiManager)) +defaultSegmentApiManager := segment.NewSegmentAPIManager(sdkKey, nil) +odpSegmentManager := segment.NewSegmentManager(sdkKey, segment.WithAPIManager(defaultSegmentApiManager)) + +odpManager := odp.NewOdpManager(sdkKey, + false, + odp.WithEventManager(odpEventManager), // Optional + odp.WithSegmentManager(odpSegmentManager), // Optional +) +-------------------------------------------------------------------------------- + +### CODE SAMPLE #9 +File: initialize-sdk-go.md +Section: Customize `EventApiManager` +Language: go +-------------------------------------------------------------------------------- +// APIManager represents the event API manager. +type APIManager interface { + SendOdpEvents(apiKey, apiHost string, events []Event) (canRetry bool, err error) +} +-------------------------------------------------------------------------------- + +### CODE SAMPLE #10 +File: initialize-sdk-go.md +Section: Customize `EventApiManager` +Language: go +-------------------------------------------------------------------------------- +eventDispatchTimeoutMillis := 20000 * time.Millisecond +defaultEventApiManager := event.NewEventAPIManager(sdkKey, utils.NewHTTPRequester(logging.GetLogger(sdkKey, "EventAPIManager"), utils.Timeout(eventDispatchTimeoutMillis))) +-------------------------------------------------------------------------------- + +### CODE SAMPLE #11 +File: initialize-sdk-go.md +Section: Customize SegmentAPIManager +Language: go +-------------------------------------------------------------------------------- +// APIManager represents the segment API manager. +type APIManager interface { + FetchQualifiedSegments(apiKey, apiHost, userID string, segmentsToCheck []string) ([]string, error) +} +-------------------------------------------------------------------------------- + +### CODE SAMPLE #12 +File: initialize-sdk-go.md +Section: Customize `ODPEventManager` +Language: go +-------------------------------------------------------------------------------- +queueSize := 20000 +// Note: if this is set to 0 then batchSize will be set to 1, otherwise batchSize will be default, which is 10. +flushIntervalInMillis := 10000 * time.Millisecond // 10,000 msecs = 10 secs + +odpEventManager := event.NewBatchEventManager( + event.WithAPIManager(defaultEventApiManager), + event.WithQueueSize(queueSize), + event.WithFlushInterval(flushIntervalInMillis), +)is +-------------------------------------------------------------------------------- + +### CODE SAMPLE #13 +File: initialize-sdk-go.md +Section: Customize `ODPSegmentManager` +Language: go +-------------------------------------------------------------------------------- +cacheSize := 600 +cacheTimeoutInSeconds := 600 * time.Second // 10 mins = 600 secs +odpSegmentManager := segment.NewSegmentManager( + sdkKey, + segment.WithAPIManager(defaultSegmentApiManager), + segment.WithSegmentsCacheSize(cacheSize), + segment.WithSegmentsCacheTimeout(cacheTimeoutInSeconds), +) + +// Second method to set custom cache size and timeout using odpManager +odpManager := odp.NewOdpManager( + sdkKey, false, + odp.WithSegmentsCacheSize(cacheSize), + odp.WithSegmentsCacheTimeout(cacheTimeoutInSeconds), +) +-------------------------------------------------------------------------------- + +### CODE SAMPLE #14 +File: initialize-sdk-go.md +Section: Custom cache +Language: go +-------------------------------------------------------------------------------- +// Cache is used for caching ODP segments +type Cache interface { + Save(key string, value interface{}) + Lookup(key string) interface{} + Reset() +} +-------------------------------------------------------------------------------- + +### CODE SAMPLE #15 +File: initialize-sdk-go.md +Section: Custom cache +Language: go +-------------------------------------------------------------------------------- +type CustomSegmentsCache struct { + Cache map[string]interface{} + lock sync.Mutex +} + +func (c *CustomSegmentsCache) Save(key string, value interface{}) { + c.lock.Lock() + defer c.lock.Unlock() + c.Cache[key] = value +} + +func (c *CustomSegmentsCache) Lookup(key string) interface{} { + c.lock.Lock() + defer c.lock.Unlock() + return c.Cache[key] +} + +func (c *CustomSegmentsCache) Reset() { + c.lock.Lock() + defer c.lock.Unlock() + c.Cache = map[string]interface{}{} +} +-------------------------------------------------------------------------------- + +### CODE SAMPLE #16 +File: initialize-sdk-go.md +Section: Custom cache +Language: go +-------------------------------------------------------------------------------- +customCache := &CustomSegmentsCache{ + Cache: map[string]interface{}{}, +} + +// Note: To use custom Cache user should not pass SegmentManager +odpManager = odp.NewOdpManager( + sdkKey, + false, + odp.WithSegmentsCache(customCache), +) +-------------------------------------------------------------------------------- + +### CODE SAMPLE #17 +File: decide-methods-go.md +Section: Example `decide` +Language: go +-------------------------------------------------------------------------------- +package main + +import ( + "fmt" + + optly "github.com/optimizely/go-sdk" // for v2: "github.com/optimizely/go-sdk/v2" + "github.com/optimizely/go-sdk/pkg/decide" // for v2: "github.com/optimizely/go-sdk/v2/pkg/decide" +) + +func main() { + optimizely_client, err := optly.Client("SDK_KEY_HERE") // Replace with your SDK key + if err != nil { + panic(err) + } + + user := optimizely_client.CreateUserContext("user123", map[string]interface{}{"logged_in": true}) + decision := user.Decide("product_sort", []decide.OptimizelyDecideOptions{}) + + // Did the decision fail with a critical error? + if decision.VariationKey == "" { + fmt.Printf("[decide] error: %v", decision.Reasons) + return + } + + // Flag enabled state + enabled := decision.Enabled + fmt.Println("Decision enabled: ", enabled) + + // String variable value + var value1 string + if err := decision.Variables.GetValue("sort_method", &value1); err != nil { + panic(err) + } + // Or + value2 := decision.Variables.ToMap()["sort_method"].(string) + fmt.Println("Variable value: ", value2) + + // All variable values + allVarValues := decision.Variables + fmt.Println("All variable: ", allVarValues) + + // Variation key + variationKey := decision.VariationKey + fmt.Println("Variation Key: ", variationKey) + + // User for which the decision was made + userContext := decision.UserContext + + // Flag decision reasons + reasons := decision.Reasons + fmt.Println("Decision reasons: ", reasons) +} +-------------------------------------------------------------------------------- + +### CODE SAMPLE #18 +File: decide-methods-go.md +Section: Example `DecideAll` +Language: go +-------------------------------------------------------------------------------- +// Make a decisions for all active (unarchived) flags for the user +decisions := user.DecideAll([]decide.OptimizelyDecideOptions{}) +// Or only for enabled flags +decisions = user.DecideAll([]decide.OptimizelyDecideOptions{decide.EnabledFlagsOnly}) + +flagKeys := []string{} +flagDecisions := []client.OptimizelyDecision{} +for k, v := range decisions { + flagKeys = append(flagKeys, k) + flagDecisions = append(flagDecisions, v) +} +decisionForFlag1 := decisions["flag_1"] +-------------------------------------------------------------------------------- + +### CODE SAMPLE #19 +File: decide-methods-go.md +Section: Example `DecideForKeys` +Language: go +-------------------------------------------------------------------------------- +decisions := user.DecideForKeys([]string{"flag_1", "flag_2"}, []decide.OptimizelyDecideOptions{decide.EnabledFlagsOnly}) + +decisionForFlag1 := decisions["flag_1"] +decisionForFlag2 := decisions["flag_2"] +-------------------------------------------------------------------------------- + +### CODE SAMPLE #20 +File: decide-methods-go.md +Section: Manual cache control +Language: go +-------------------------------------------------------------------------------- +import ( + optly "github.com/optimizely/go-sdk/v2" + "github.com/optimizely/go-sdk/v2/pkg/decide" +) + +optimizely_client, err := optly.Client("SDK_KEY_HERE") +if err != nil { + panic(err) +} + +// Example 1: Bypass cache for real-time decision +user := optimizely_client.CreateUserContext("user123", +map[string]interface{}{ + "age": 25, + "location": "US", +}) +decision := user.Decide("my-cmab-flag", +[]decide.OptimizelyDecideOptions{ + decide.IgnoreCMABCache, // Always fetch fresh from CMAB +service +}) + +// Example 2: Invalidate cache when user context changes +significantly +user.SetAttributes(map[string]interface{}{ + "age": 26, + "location": "UK", // Context changed +}) +decision = user.Decide("my-cmab-flag", +[]decide.OptimizelyDecideOptions{ + decide.InvalidateUserCMABCache, // Clear cached decision +for this user +}) + +// Example 3: Reset entire CMAB cache (use sparingly) +decision = user.Decide("my-cmab-flag", +[]decide.OptimizelyDecideOptions{ + decide.ResetCMABCache, // Clear all CMAB cache entries +}) +-------------------------------------------------------------------------------- + +### CODE SAMPLE #21 +File: decide-methods-go.md +Section: CMAB decision reasons +Language: go +-------------------------------------------------------------------------------- +decision := user.Decide("my-cmab-flag", +[]decide.OptimizelyDecideOptions{ + decide.IncludeReasons, +}) + +// Print CMAB-related decision reasons +for _, reason := range decision.Reasons { + fmt.Println(reason) + // Examples: + // "CMAB decision retrieved from cache." + // "CMAB decision fetched from service." + // "CMAB cache invalidated due to attribute change." +} +-------------------------------------------------------------------------------- + +### CODE SAMPLE #22 +File: optimizelyusercontext-go.md +Section: OptimizelyUserContext definition +Language: go +-------------------------------------------------------------------------------- +type OptimizelyUserContext struct { + UserID string + Attributes map[string]interface{} +} + +// GetOptimizely returns optimizely client instance for Optimizely user context +func (o OptimizelyUserContext) GetOptimizely() *OptimizelyClient + +// GetUserID returns userID for Optimizely user context +func (o OptimizelyUserContext) GetUserID() string + +// GetUserAttributes returns user attributes for Optimizely user context +func (o OptimizelyUserContext) GetUserAttributes() map[string]interface{} + +// SetAttribute sets an attribute for a given key. +func (o *OptimizelyUserContext) SetAttribute(key string, value interface{}) + +// Decide returns a decision result for a given flag key and a user context, which contains +// all data required to deliver the flag or experiment. +func (o *OptimizelyUserContext) Decide(key string, options []decide.OptimizelyDecideOptions) OptimizelyDecision + +// DecideAll returns a key-map of decision results for all active flag keys with options. +func (o *OptimizelyUserContext) DecideAll(options []decide.OptimizelyDecideOptions) map[string]OptimizelyDecision + +// DecideForKeys returns a key-map of decision results for multiple flag keys and options. +func (o *OptimizelyUserContext) DecideForKeys(keys []string, options []decide.OptimizelyDecideOptions) map[string]OptimizelyDecision + +// TrackEvent generates a conversion event with the given event key if it exists and queues it up to be sent to the Optimizely +// log endpoint for results processing. +func (o *OptimizelyUserContext) TrackEvent(eventKey string, eventTags map[string]interface{}) (err error) + +// OptimizelyDecisionContext +type OptimizelyDecisionContext struct { + FlagKey string + RuleKey string +} + +// OptimizelyForcedDecision +type OptimizelyForcedDecision struct { + VariationKey string +} + +// SetForcedDecision sets the forced decision (variation key) for a given decision context (flag key and optional rule key). +// returns true if the forced decision has been set successfully. +func (o *OptimizelyUserContext) SetForcedDecision(context pkgDecision.OptimizelyDecisionContext, decision pkgDecision.OptimizelyForcedDecision) bool + +// GetForcedDecision returns the forced decision for a given flag and an optional rule +func (o *OptimizelyUserContext) GetForcedDecision(context pkgDecision.OptimizelyDecisionContext) (pkgDecision.OptimizelyForcedDecision, error) + +// RemoveForcedDecision removes the forced decision for a given flag and an optional rule. +func (o *OptimizelyUserContext) RemoveForcedDecision(context pkgDecision.OptimizelyDecisionContext) bool + +// RemoveAllForcedDecisions removes all forced decisions bound to this user context. +func (o *OptimizelyUserContext) RemoveAllForcedDecisions() bool + +// +// The following methods require Real-Time Audiences for Feature Experimentation. +// See note following this code sample. +// + +// GetQualifiedSegments returns an array of segment names that the user is qualified for. +// The result of **FetchQualifiedSegments()** is saved here. +// Can be nil if not properly updated with FetchQualifiedSegments(). +func (o *OptimizelyUserContext) GetQualifiedSegments() []string + +// SetQualifiedSegments can read and write directly to the qualified segments array. +// This lets you bypass the remote fetching process from ODP +// or for utilizing your own fetching service. +func (o *OptimizelyUserContext) SetQualifiedSegments(qualifiedSegments []string) + +// FetchQualifiedSegments fetches all qualified segments for the user context. +// The segments fetched are saved in the **qualifiedSegments** array +// and can be accessed any time. +func (o *OptimizelyUserContext) FetchQualifiedSegments(options []pkgOdpSegment.OptimizelySegmentOption) (success bool) + +// FetchQualifiedSegmentsAsync fetches all qualified segments aysnchronously for the user context. +// This method fetches segments in a separate go routine and invoke the provided +// callback when results are available. +func (o *OptimizelyUserContext) FetchQualifiedSegmentsAsync(options []pkgOdpSegment.OptimizelySegmentOption, callback func(success bool)) + +// IsQualifiedFor returns true if the user is qualified for the given segment name +func (o *OptimizelyUserContext) IsQualifiedFor(segment string) bool +-------------------------------------------------------------------------------- + +### CODE SAMPLE #23 +File: event-batching-go.md +Section: Basic example +Language: go +-------------------------------------------------------------------------------- +import optly "github.com/optimizely/go-sdk" // for v2: "github.com/optimizely/go-sdk/v2" + +// the default client will have a BatchEventProcessor with the default options +optlyClient, err := optly.Client("SDK_KEY_HERE") +-------------------------------------------------------------------------------- + +### CODE SAMPLE #24 +File: event-batching-go.md +Section: Advanced Example +Language: go +-------------------------------------------------------------------------------- +import ( + "time" + + "github.com/optimizely/go-sdk/pkg/client" // for v2: "github.com/optimizely/go-sdk/v2/pkg/client" + "github.com/optimizely/go-sdk/pkg/event" // for v2: "github.com/optimizely/go-sdk/v2/pkg/event" + "github.com/optimizely/go-sdk/pkg/utils" // for v2: "github.com/optimizely/go-sdk/v2/pkg/utils" +) + +optimizelyFactory := &client.OptimizelyFactory{ + SDKKey: "SDK_KEY", +} + +// You can configure the batch size and flush interval +eventProcessor := event.NewBatchEventProcessor( + event.WithBatchSize(10), + event.WithFlushInterval(30 * time.Second), +) +optlyClient, err := optimizelyFactory.Client( + client.WithEventProcessor(eventProcessor), +) +-------------------------------------------------------------------------------- + +### CODE SAMPLE #25 +File: event-batching-go.md +Section: Register and Unregister a `LogEvent` listener +Language: go +-------------------------------------------------------------------------------- +import ( + "fmt" + + "github.com/optimizely/go-sdk/pkg/client" // for v2: "github.com/optimizely/go-sdk/v2/pkg/client" + "github.com/optimizely/go-sdk/pkg/event" // for v2: "github.com/optimizely/go-sdk/v2/pkg/event" +) + +// Callback for log event notification +callback := func(notification event.LogEvent) { + + // URL to dispatch log event to + fmt.Print(notification.EndPoint) + // Batched event + fmt.Print(notification.Event) +} + +optimizelyFactory := &client.OptimizelyFactory{ + SDKKey: "SDK_KEY", +} +optimizelyClient, err := optimizelyFactory.Client() +if err != nil { + // handle error +} + +// Add callback for logEvent notification +id, err := optimizelyClient.EventProcessor.(*event.BatchEventProcessor).OnEventDispatch(callback) +if err != nil { + // handle error +} +// Remove callback for logEvent notification +err = optimizelyClient.EventProcessor.(*event.BatchEventProcessor).RemoveOnEventDispatch(id) +-------------------------------------------------------------------------------- + +### CODE SAMPLE #26 +File: set-up-notification-listener-go.md +Section: Set up each type of notification listener +Language: go +-------------------------------------------------------------------------------- +// Create default Client +optimizelyFactory := &client.OptimizelyFactory{ + SDKKey: "[SDK_KEY_HERE]", +} +optimizelyClient, err := optimizelyFactory.Client() +if err != nil { + // handle error +} + + +// SET UP DECISION NOTIFICATION LISTENER + +onDecision := func(notification notification.DecisionNotification) { + // Add a DECISION Notification Listener for type FLAG + if string(notification.Type) == "flag" { + // Access information about feature, for example, key and enabled status + fmt.Print(notification.DecisionInfo["flagKey"]) + fmt.Print(notification.DecisionInfo["enabled"]) + fmt.Print(notification.DecisionInfo["decisionEventDispatched"]) + } +} +notificationID, err := optimizelyClient.DecisionService.OnDecision(onDecision) +if err != nil { + // handle error +} + +// REMOVE DECISION NOTIFICATION LISTENER + +optimizelyClient.DecisionService.RemoveOnDecision(notificationID) + +// SET UP LOG EVENT NOTIFICATION LISTENER + +onLogEvent := func(eventNotification event.LogEvent) { + // process the logEvent object here (send to analytics provider, audit/inspect data) +} +notificationID, err = optimizelyClient.EventProcessor.OnEventDispatch(onLogEvent) +if err != nil { + // handle error +} + +// REMOVE LOG EVENT NOTIFICATION LISTENER + +optimizelyClient.EventProcessor.RemoveOnEventDispatch(notificationID) + +// SET UP OPTIMIZELY CONFIG NOTIFICATION LISTENER + +// listen to OPTIMIZELY_CONFIG_UPDATE to get updated data +// You will get notifications whenever the datafile is updated except for SDK initialization +// you will get notifications whenever the datafile is updated, except for the SDK initialization +onConfigUpdate := func(notification notification.ProjectConfigUpdateNotification) { +} +notificationID, err = optimizelyClient.ConfigManager.OnProjectConfigUpdate(onConfigUpdate) +if err != nil { + // handle error +} + +// REMOVE OPTIMIZELY CONFIG NOTIFICATION LISTENER + +optimizelyClient.ConfigManager.RemoveOnProjectConfigUpdate(notificationID) + +// SET UP TRACK LISTENER + +onTrack := func(eventKey string, userContext entities.UserContext, eventTags map[string]interface{}, conversionEvent event.ConversionEvent) { + // process the event here (send to analytics provider, audit/inspect data) +} + +notificationID, err = optimizelyClient.OnTrack(onTrack) +if err != nil { + // handle error +} + +// REMOVE TRACK LISTENER + +optimizelyClient.RemoveOnTrack(notificationID) +-------------------------------------------------------------------------------- + +### CODE SAMPLE #27 +File: example-usage-go.md +Section: Example usage of the Go SDK +Language: go +-------------------------------------------------------------------------------- +import optly "github.com/optimizely/go-sdk" // for v2: "github.com/optimizely/go-sdk/v2" + +optimizely_client, err := optly.Client("SDK_KEY_HERE") +if err != nil { + // handle the err +} +// create a user and decide a flag rule (such as an A/B test) for them +user := optimizely_client.CreateUserContext("user123", map[string]interface{}{"logged_in": true}) +decision := user.Decide("product_sort", []decide.OptimizelyDecideOptions{}) + +var variationKey string +if variationKey = decision.VariationKey; variationKey == "" { + fmt.Printf("[decide] error: %v", decision.Reasons) + return +} + +// execute code based on flag enabled state +enabled := decision.Enabled + +if enabled { + // get flag variable values + var value1 string + decision.Variables.GetValue("sort_method", &value1) + // or: + value2 := decision.Variables.ToMap()["sort_method"].(string) +} + +// or execute code based on flag variation: +if variationKey == "control" { + // Execute code for control variation +} else if variationKey == "treatment" { + // Execute code for treatment variation +} + +// Track a user event +user.TrackEvent("purchased", nil) +-------------------------------------------------------------------------------- + +### CODE SAMPLE #28 +File: track-event-go.md +Section: Example +Language: go +-------------------------------------------------------------------------------- +import "github.com/optimizely/go-sdk/pkg/client" +// for v2: "github.com/optimizely/go-sdk/v2/pkg/client" + +optimizelyFactory := client.OptimizelyFactory{SDKKey: "sdk-key"} +client, err := optimizelyFactory.StaticClient() +if err != nil { + // handle error here +} +user := client.CreateUserContext("user123", map[string]interface{}{"logged_in": true}) + +// event properties +properties := map[string]interface{}{ + "category": "shoes", + "color": "red", +} + +tags := map[string]interface{}{ + "revenue": 10000, + "value": 100.00, + "$opt_event_properties": properties, +} +//user.TrackEvent(eventkey (required), eventTag (optional) +if err := user.TrackEvent("my_purchase_event_key", tags); err != nil { + // handle error here +} +-------------------------------------------------------------------------------- + +### CODE SAMPLE #29 +File: configure-event-dispatcher-go.md +Section: Configure the Go SDK event dispatcher +Language: go +-------------------------------------------------------------------------------- +import "github.com/optimizely/go-sdk/pkg/event" // for v2: "github.com/optimizely/go-sdk/v2/pkg/event" + +type CustomEventDispatcher struct { +} + +// DispatchEvent dispatches event with callback +func (d *CustomEventDispatcher) DispatchEvent(event event.LogEvent) (bool, error) { + dispatchedEvent := map[string]interface{}{ + "url": event.EndPoint, + "http_verb": "POST", + "headers": map[string]string{"Content-Type": "application/json"}, + "params": event.Event, + } + return true, nil +} +-------------------------------------------------------------------------------- + +### CODE SAMPLE #30 +File: configure-event-dispatcher-go.md +Section: Configure the Go SDK event dispatcher +Language: go +-------------------------------------------------------------------------------- +import ( + "github.com/optimizely/go-sdk/pkg/client" // for v2: "github.com/optimizely/go-sdk/v2/pkg/client" + "github.com/optimizely/go-sdk/pkg/event" // for v2: "github.com/optimizely/go-sdk/v2/pkg/event" +) + +optimizelyFactory := &client.OptimizelyFactory{ + SDKKey: "SDK_KEY_HERE", +} + +customEventDispatcher := &CustomEventDispatcher{} + +// Create an Optimizely client with the custom event dispatcher +optlyClient, e := optimizelyFactory.Client(client.WithEventDispatcher(customEventDispatcher)) +-------------------------------------------------------------------------------- + +### CODE SAMPLE #31 +File: customize-logger-go.md +Section: Custom logger implementation in the SDK +Language: go +-------------------------------------------------------------------------------- +import "github.com/optimizely/go-sdk/pkg/logging" // for v2: "github.com/optimizely/go-sdk/v2/pkg/logging" + +type CustomLogger struct { +} + +func (l *CustomLogger) Log(level logging.LogLevel, message string, fields map[string]interface{}) { +} + +func (l *CustomLogger) SetLogLevel(level logging.LogLevel) { +} + +customLogger := New(CustomLogger) + +logging.SetLogger(customLogger) +-------------------------------------------------------------------------------- + +### CODE SAMPLE #32 +File: customize-logger-go.md +Section: Setting the log level +Language: go +-------------------------------------------------------------------------------- +import "github.com/optimizely/go-sdk/pkg/logging" // for v2: "github.com/optimizely/go-sdk/v2/pkg/logging" + +// Set log level to Debug +logging.SetLogLevel(logging.LogLevelDebug) +-------------------------------------------------------------------------------- + +### CODE SAMPLE #33 +File: get-forced-variation-go.md +Section: Example +Language: go +-------------------------------------------------------------------------------- +overrideStore := decision.NewMapExperimentOverridesStore() +client, err := optimizelyFactory.Client( + client.WithExperimentOverrides(overrideStore), +) +-------------------------------------------------------------------------------- + +### CODE SAMPLE #34 +File: get-forced-variation-go.md +Section: Example +Language: go +-------------------------------------------------------------------------------- +overrideKey := decision.ExperimentOverrideKey{ExperimentKey: "test_experiment", UserID: "test_user"} +variation, success := overrideStore.GetVariation(overrideKey) +-------------------------------------------------------------------------------- diff --git a/go.mod b/go.mod index b6385c52..ea468cc0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/optimizely/go-sdk/v2 -go 1.21.0 +go 1.21 require ( github.com/google/uuid v1.3.0 diff --git a/pkg/cmab/service_test.go b/pkg/cmab/service_test.go index 4675d773..8e419041 100644 --- a/pkg/cmab/service_test.go +++ b/pkg/cmab/service_test.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2025, Optimizely, Inc. and contributors * + * Copyright 2025-2026, 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. * @@ -235,6 +235,16 @@ func (m *MockProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Hol return args.Get(0).([]entities.Holdout) } +func (m *MockProjectConfig) GetHoldoutsForRule(ruleID string) []entities.Holdout { + args := m.Called(ruleID) + return args.Get(0).([]entities.Holdout) +} + +func (m *MockProjectConfig) GetGlobalHoldouts() []entities.Holdout { + args := m.Called() + 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 eeeb341a..2d63c325 100644 --- a/pkg/config/datafileprojectconfig/config.go +++ b/pkg/config/datafileprojectconfig/config.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2025, Optimizely, Inc. and contributors * + * Copyright 2019-2026, 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. * @@ -63,6 +63,7 @@ type DatafileProjectConfig struct { holdouts []entities.Holdout holdoutIDMap map[string]entities.Holdout flagHoldoutsMap map[string][]entities.Holdout + ruleHoldoutsMap map[string][]entities.Holdout } // GetDatafile returns a string representation of the environment's datafile @@ -292,6 +293,25 @@ func (c DatafileProjectConfig) GetHoldoutsForFlag(featureKey string) []entities. return []entities.Holdout{} } +// GetHoldoutsForRule returns local holdouts targeting a specific rule +func (c DatafileProjectConfig) GetHoldoutsForRule(ruleID string) []entities.Holdout { + if holdouts, exists := c.ruleHoldoutsMap[ruleID]; exists { + return holdouts + } + return []entities.Holdout{} +} + +// GetGlobalHoldouts returns all global holdouts (applies to all rules) +func (c DatafileProjectConfig) GetGlobalHoldouts() []entities.Holdout { + globalHoldouts := []entities.Holdout{} + for _, holdout := range c.holdouts { + if holdout.IsGlobal() { + globalHoldouts = append(globalHoldouts, holdout) + } + } + return globalHoldouts +} + // 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) @@ -338,7 +358,7 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP audienceMap, audienceSegmentList := mappers.MapAudiences(append(datafile.TypedAudiences, datafile.Audiences...)) flagVariationsMap := mappers.MapFlagVariations(featureMap) - holdouts, holdoutIDMap, flagHoldoutsMap := mappers.MapHoldouts(datafile.Holdouts, featureMap) + holdouts, holdoutIDMap, flagHoldoutsMap, ruleHoldoutsMap := mappers.MapHoldouts(datafile.Holdouts, featureMap) attributeKeyMap := make(map[string]entities.Attribute) attributeIDToKeyMap := make(map[string]string) @@ -384,6 +404,7 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP holdouts: holdouts, holdoutIDMap: holdoutIDMap, flagHoldoutsMap: flagHoldoutsMap, + ruleHoldoutsMap: ruleHoldoutsMap, } logger.Info("Datafile is valid.") diff --git a/pkg/config/datafileprojectconfig/config_test.go b/pkg/config/datafileprojectconfig/config_test.go index 96984729..51e2e732 100644 --- a/pkg/config/datafileprojectconfig/config_test.go +++ b/pkg/config/datafileprojectconfig/config_test.go @@ -789,3 +789,95 @@ func TestGetHoldoutsForFlagWithDifferentFlag(t *testing.T) { assert.Len(t, actual, 0) assert.Equal(t, []entities.Holdout{}, actual) } + +func TestGetHoldoutsForRuleWithHoldouts(t *testing.T) { + ruleID := "rule_123" + holdout1 := entities.Holdout{ + ID: "holdout_1", + Key: "test_holdout_1", + Status: entities.HoldoutStatusRunning, + IncludedRules: []string{ruleID}, + } + holdout2 := entities.Holdout{ + ID: "holdout_2", + Key: "test_holdout_2", + Status: entities.HoldoutStatusRunning, + IncludedRules: []string{ruleID}, + } + + ruleHoldoutsMap := make(map[string][]entities.Holdout) + ruleHoldoutsMap[ruleID] = []entities.Holdout{holdout1, holdout2} + + config := &DatafileProjectConfig{ + ruleHoldoutsMap: ruleHoldoutsMap, + } + + actual := config.GetHoldoutsForRule(ruleID) + assert.Len(t, actual, 2) + assert.Equal(t, holdout1, actual[0]) + assert.Equal(t, holdout2, actual[1]) +} + +func TestGetHoldoutsForRuleWithNoHoldouts(t *testing.T) { + ruleID := "rule_123" + config := &DatafileProjectConfig{ + ruleHoldoutsMap: make(map[string][]entities.Holdout), + } + + actual := config.GetHoldoutsForRule(ruleID) + assert.Len(t, actual, 0) + assert.Equal(t, []entities.Holdout{}, actual) +} + +func TestGetGlobalHoldouts(t *testing.T) { + globalHoldout1 := entities.Holdout{ + ID: "global_holdout_1", + Key: "test_global_holdout_1", + Status: entities.HoldoutStatusRunning, + IncludedRules: nil, // nil = global + } + globalHoldout2 := entities.Holdout{ + ID: "global_holdout_2", + Key: "test_global_holdout_2", + Status: entities.HoldoutStatusRunning, + IncludedRules: nil, // nil = global + } + localHoldout := entities.Holdout{ + ID: "local_holdout", + Key: "test_local_holdout", + Status: entities.HoldoutStatusRunning, + IncludedRules: []string{"rule_123"}, // non-nil = local + } + + config := &DatafileProjectConfig{ + holdouts: []entities.Holdout{globalHoldout1, localHoldout, globalHoldout2}, + } + + actual := config.GetGlobalHoldouts() + assert.Len(t, actual, 2) + assert.Equal(t, globalHoldout1, actual[0]) + assert.Equal(t, globalHoldout2, actual[1]) +} + +func TestGetGlobalHoldoutsWithNoGlobal(t *testing.T) { + localHoldout1 := entities.Holdout{ + ID: "local_holdout_1", + Key: "test_local_holdout_1", + Status: entities.HoldoutStatusRunning, + IncludedRules: []string{"rule_123"}, + } + localHoldout2 := entities.Holdout{ + ID: "local_holdout_2", + Key: "test_local_holdout_2", + Status: entities.HoldoutStatusRunning, + IncludedRules: []string{"rule_456"}, + } + + config := &DatafileProjectConfig{ + holdouts: []entities.Holdout{localHoldout1, localHoldout2}, + } + + actual := config.GetGlobalHoldouts() + assert.Len(t, actual, 0) + assert.Equal(t, []entities.Holdout{}, actual) +} diff --git a/pkg/config/datafileprojectconfig/entities/entities.go b/pkg/config/datafileprojectconfig/entities/entities.go index 1abed5d8..3f9d81b0 100644 --- a/pkg/config/datafileprojectconfig/entities/entities.go +++ b/pkg/config/datafileprojectconfig/entities/entities.go @@ -123,8 +123,7 @@ type Holdout struct { AudienceConditions interface{} `json:"audienceConditions"` Variations []Variation `json:"variations"` TrafficAllocation []TrafficAllocation `json:"trafficAllocation"` - IncludedFlags []string `json:"includedFlags,omitempty"` - ExcludedFlags []string `json:"excludedFlags,omitempty"` + IncludedRules []string `json:"includedRules,omitempty"` } // Integration represents a integration from the Optimizely datafile diff --git a/pkg/config/datafileprojectconfig/mappers/holdout.go b/pkg/config/datafileprojectconfig/mappers/holdout.go index 6108d04d..8f738da2 100644 --- a/pkg/config/datafileprojectconfig/mappers/holdout.go +++ b/pkg/config/datafileprojectconfig/mappers/holdout.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2025, Optimizely, Inc. and contributors * + * Copyright 2025-2026, 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. * @@ -23,19 +23,19 @@ import ( ) // MapHoldouts maps the raw datafile holdout entities to SDK Holdout entities -// and organizes them by flag relationships +// and organizes them by rule relationships func MapHoldouts(holdouts []datafileEntities.Holdout, featureMap map[string]entities.Feature) ( holdoutList []entities.Holdout, holdoutIDMap map[string]entities.Holdout, flagHoldoutsMap map[string][]entities.Holdout, + ruleHoldoutsMap map[string][]entities.Holdout, ) { holdoutList = []entities.Holdout{} holdoutIDMap = make(map[string]entities.Holdout) flagHoldoutsMap = make(map[string][]entities.Holdout) + ruleHoldoutsMap = make(map[string][]entities.Holdout) globalHoldouts := []entities.Holdout{} - includedHoldouts := make(map[string][]entities.Holdout) - excludedHoldouts := make(map[string][]entities.Holdout) for _, holdout := range holdouts { // Only process running holdouts @@ -47,46 +47,28 @@ func MapHoldouts(holdouts []datafileEntities.Holdout, featureMap map[string]enti holdoutList = append(holdoutList, mappedHoldout) holdoutIDMap[holdout.ID] = mappedHoldout - // Classify holdout by flag relationships - if len(holdout.IncludedFlags) == 0 { - // Global holdout - applies to all flags except excluded + // Classify holdout by scope + if len(holdout.IncludedRules) == 0 { + // Global holdout - applies to all rules (IncludedRules is nil or empty) globalHoldouts = append(globalHoldouts, mappedHoldout) - - // Track exclusions - for _, flagID := range holdout.ExcludedFlags { - excludedHoldouts[flagID] = append(excludedHoldouts[flagID], mappedHoldout) - } } else { - // Specific holdout - applies only to included flags - for _, flagID := range holdout.IncludedFlags { - includedHoldouts[flagID] = append(includedHoldouts[flagID], mappedHoldout) + // Local holdout - applies only to specific rules + for _, ruleID := range holdout.IncludedRules { + ruleHoldoutsMap[ruleID] = append(ruleHoldoutsMap[ruleID], mappedHoldout) } } } - // Build flagHoldoutsMap by combining global and specific holdouts - // Global holdouts take precedence (evaluated first), then specific holdouts + // Build flagHoldoutsMap with only global holdouts (for backward compatibility) + // Global holdouts are evaluated at flag level (before any rules) for _, feature := range featureMap { flagKey := feature.Key - flagID := feature.ID - applicableHoldouts := []entities.Holdout{} - - // Add global holdouts first (if not excluded) - they take precedence - if _, exists := excludedHoldouts[flagID]; !exists { - applicableHoldouts = append(applicableHoldouts, globalHoldouts...) - } - - // Add specifically included holdouts second - if included, exists := includedHoldouts[flagID]; exists { - applicableHoldouts = append(applicableHoldouts, included...) - } - - if len(applicableHoldouts) > 0 { - flagHoldoutsMap[flagKey] = applicableHoldouts + if len(globalHoldouts) > 0 { + flagHoldoutsMap[flagKey] = globalHoldouts } } - return holdoutList, holdoutIDMap, flagHoldoutsMap + return holdoutList, holdoutIDMap, flagHoldoutsMap, ruleHoldoutsMap } func mapHoldout(datafileHoldout datafileEntities.Holdout) entities.Holdout { @@ -130,6 +112,13 @@ func mapHoldout(datafileHoldout datafileEntities.Holdout) entities.Holdout { } } + // Map IncludedRules - nil if not present (global), otherwise copy the slice + var includedRules []string + if len(datafileHoldout.IncludedRules) > 0 { + includedRules = make([]string, len(datafileHoldout.IncludedRules)) + copy(includedRules, datafileHoldout.IncludedRules) + } + return entities.Holdout{ ID: datafileHoldout.ID, Key: datafileHoldout.Key, @@ -139,5 +128,6 @@ func mapHoldout(datafileHoldout datafileEntities.Holdout) entities.Holdout { Variations: variations, TrafficAllocation: trafficAllocation, AudienceConditionTree: audienceConditionTree, + IncludedRules: includedRules, } } diff --git a/pkg/config/datafileprojectconfig/mappers/holdout_local_test.go b/pkg/config/datafileprojectconfig/mappers/holdout_local_test.go new file mode 100644 index 00000000..b17b875c --- /dev/null +++ b/pkg/config/datafileprojectconfig/mappers/holdout_local_test.go @@ -0,0 +1,315 @@ +/**************************************************************************** + * Copyright 2025-2026, 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 mappers + +import ( + "testing" + + datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities" + "github.com/optimizely/go-sdk/v2/pkg/entities" + "github.com/stretchr/testify/assert" +) + +// TestMapHoldoutsLocalSingleRule tests local holdout with a single rule +func TestMapHoldoutsLocalSingleRule(t *testing.T) { + rawHoldouts := []datafileEntities.Holdout{ + { + ID: "holdout_1", + Key: "local_holdout_single", + Status: "Running", + IncludedRules: []string{"rule_1"}, + Variations: []datafileEntities.Variation{ + {ID: "var_1", Key: "variation_1"}, + }, + TrafficAllocation: []datafileEntities.TrafficAllocation{ + {EntityID: "var_1", EndOfRange: 10000}, + }, + }, + } + + featureMap := map[string]entities.Feature{ + "feature_1": {ID: "feature_1", Key: "feature_1"}, + } + + holdoutList, holdoutIDMap, flagHoldoutsMap, ruleHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + + // Verify holdout list and ID map + assert.Len(t, holdoutList, 1) + assert.Len(t, holdoutIDMap, 1) + assert.Equal(t, "holdout_1", holdoutList[0].ID) + assert.Equal(t, "local_holdout_single", holdoutList[0].Key) + assert.NotNil(t, holdoutList[0].IncludedRules) + assert.Len(t, holdoutList[0].IncludedRules, 1) + assert.Equal(t, "rule_1", holdoutList[0].IncludedRules[0]) + + // Local holdout should NOT appear in flagHoldoutsMap (only global holdouts) + assert.Empty(t, flagHoldoutsMap) + + // Local holdout should appear in ruleHoldoutsMap for rule_1 + assert.Contains(t, ruleHoldoutsMap, "rule_1") + assert.Len(t, ruleHoldoutsMap["rule_1"], 1) + assert.Equal(t, "holdout_1", ruleHoldoutsMap["rule_1"][0].ID) +} + +// TestMapHoldoutsLocalMultipleRules tests local holdout with multiple rules +func TestMapHoldoutsLocalMultipleRules(t *testing.T) { + rawHoldouts := []datafileEntities.Holdout{ + { + ID: "holdout_1", + Key: "local_holdout_multi", + Status: "Running", + IncludedRules: []string{"rule_1", "rule_2", "rule_3"}, + Variations: []datafileEntities.Variation{ + {ID: "var_1", Key: "variation_1"}, + }, + TrafficAllocation: []datafileEntities.TrafficAllocation{ + {EntityID: "var_1", EndOfRange: 10000}, + }, + }, + } + + featureMap := map[string]entities.Feature{ + "feature_1": {ID: "feature_1", Key: "feature_1"}, + } + + holdoutList, _, _, ruleHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + + // Verify holdout has all included rules + assert.Len(t, holdoutList[0].IncludedRules, 3) + assert.Contains(t, holdoutList[0].IncludedRules, "rule_1") + assert.Contains(t, holdoutList[0].IncludedRules, "rule_2") + assert.Contains(t, holdoutList[0].IncludedRules, "rule_3") + + // Verify ruleHoldoutsMap contains entries for all rules + assert.Contains(t, ruleHoldoutsMap, "rule_1") + assert.Contains(t, ruleHoldoutsMap, "rule_2") + assert.Contains(t, ruleHoldoutsMap, "rule_3") + assert.Len(t, ruleHoldoutsMap["rule_1"], 1) + assert.Len(t, ruleHoldoutsMap["rule_2"], 1) + assert.Len(t, ruleHoldoutsMap["rule_3"], 1) +} + +// TestMapHoldoutsGlobalVsLocal tests global holdout (nil IncludedRules) vs local holdout +func TestMapHoldoutsGlobalVsLocal(t *testing.T) { + rawHoldouts := []datafileEntities.Holdout{ + { + ID: "holdout_global", + Key: "global_holdout", + Status: "Running", + // IncludedRules is nil (not present in JSON) - this is a global holdout + Variations: []datafileEntities.Variation{ + {ID: "var_1", Key: "variation_1"}, + }, + TrafficAllocation: []datafileEntities.TrafficAllocation{ + {EntityID: "var_1", EndOfRange: 10000}, + }, + }, + { + ID: "holdout_local", + Key: "local_holdout", + Status: "Running", + IncludedRules: []string{"rule_1"}, + Variations: []datafileEntities.Variation{ + {ID: "var_2", Key: "variation_2"}, + }, + TrafficAllocation: []datafileEntities.TrafficAllocation{ + {EntityID: "var_2", EndOfRange: 10000}, + }, + }, + } + + featureMap := map[string]entities.Feature{ + "feature_1": {ID: "feature_1", Key: "feature_1"}, + "feature_2": {ID: "feature_2", Key: "feature_2"}, + } + + holdoutList, _, flagHoldoutsMap, ruleHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + + // Verify both holdouts are in the list + assert.Len(t, holdoutList, 2) + + // Global holdout should be in flagHoldoutsMap for all features + assert.Contains(t, flagHoldoutsMap, "feature_1") + assert.Contains(t, flagHoldoutsMap, "feature_2") + assert.Len(t, flagHoldoutsMap["feature_1"], 1) + assert.Equal(t, "holdout_global", flagHoldoutsMap["feature_1"][0].ID) + assert.Nil(t, flagHoldoutsMap["feature_1"][0].IncludedRules) + + // Local holdout should be in ruleHoldoutsMap for rule_1 + assert.Contains(t, ruleHoldoutsMap, "rule_1") + assert.Len(t, ruleHoldoutsMap["rule_1"], 1) + assert.Equal(t, "holdout_local", ruleHoldoutsMap["rule_1"][0].ID) + assert.NotNil(t, ruleHoldoutsMap["rule_1"][0].IncludedRules) +} + +// TestMapHoldoutsEmptyIncludedRules tests that empty IncludedRules array is treated as local with no rules +func TestMapHoldoutsEmptyIncludedRules(t *testing.T) { + rawHoldouts := []datafileEntities.Holdout{ + { + ID: "holdout_empty", + Key: "empty_rules_holdout", + Status: "Running", + IncludedRules: []string{}, // Empty slice, not nil + Variations: []datafileEntities.Variation{ + {ID: "var_1", Key: "variation_1"}, + }, + TrafficAllocation: []datafileEntities.TrafficAllocation{ + {EntityID: "var_1", EndOfRange: 10000}, + }, + }, + } + + featureMap := map[string]entities.Feature{ + "feature_1": {ID: "feature_1", Key: "feature_1"}, + } + + holdoutList, _, flagHoldoutsMap, ruleHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + + // Empty IncludedRules should be treated as global holdout (same as nil) + assert.Len(t, holdoutList, 1) + + // Global holdouts should appear in flagHoldoutsMap + assert.Contains(t, flagHoldoutsMap, "feature_1") + assert.Len(t, flagHoldoutsMap["feature_1"], 1) + + // Should NOT appear in ruleHoldoutsMap since empty means no specific rules + assert.Empty(t, ruleHoldoutsMap) +} + +// TestMapHoldoutsMultipleLocalHoldoutsSameRule tests multiple local holdouts targeting the same rule +func TestMapHoldoutsMultipleLocalHoldoutsSameRule(t *testing.T) { + rawHoldouts := []datafileEntities.Holdout{ + { + ID: "holdout_1", + Key: "local_holdout_1", + Status: "Running", + IncludedRules: []string{"rule_1"}, + Variations: []datafileEntities.Variation{ + {ID: "var_1", Key: "variation_1"}, + }, + TrafficAllocation: []datafileEntities.TrafficAllocation{ + {EntityID: "var_1", EndOfRange: 5000}, + }, + }, + { + ID: "holdout_2", + Key: "local_holdout_2", + Status: "Running", + IncludedRules: []string{"rule_1"}, + Variations: []datafileEntities.Variation{ + {ID: "var_2", Key: "variation_2"}, + }, + TrafficAllocation: []datafileEntities.TrafficAllocation{ + {EntityID: "var_2", EndOfRange: 5000}, + }, + }, + } + + featureMap := map[string]entities.Feature{ + "feature_1": {ID: "feature_1", Key: "feature_1"}, + } + + _, _, _, ruleHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + + // Both holdouts should be in ruleHoldoutsMap for rule_1 + assert.Contains(t, ruleHoldoutsMap, "rule_1") + assert.Len(t, ruleHoldoutsMap["rule_1"], 2) + + // Verify both holdouts are present + holdoutIDs := []string{ruleHoldoutsMap["rule_1"][0].ID, ruleHoldoutsMap["rule_1"][1].ID} + assert.Contains(t, holdoutIDs, "holdout_1") + assert.Contains(t, holdoutIDs, "holdout_2") +} + +// TestMapHoldoutsNonRunningHoldouts tests that non-running holdouts are excluded +func TestMapHoldoutsNonRunningLocalHoldouts(t *testing.T) { + rawHoldouts := []datafileEntities.Holdout{ + { + ID: "holdout_draft", + Key: "draft_holdout", + Status: "Draft", // Not "Running" + IncludedRules: []string{"rule_1"}, + Variations: []datafileEntities.Variation{ + {ID: "var_1", Key: "variation_1"}, + }, + TrafficAllocation: []datafileEntities.TrafficAllocation{ + {EntityID: "var_1", EndOfRange: 10000}, + }, + }, + { + ID: "holdout_running", + Key: "running_holdout", + Status: "Running", + IncludedRules: []string{"rule_2"}, + Variations: []datafileEntities.Variation{ + {ID: "var_2", Key: "variation_2"}, + }, + TrafficAllocation: []datafileEntities.TrafficAllocation{ + {EntityID: "var_2", EndOfRange: 10000}, + }, + }, + } + + featureMap := map[string]entities.Feature{ + "feature_1": {ID: "feature_1", Key: "feature_1"}, + } + + holdoutList, _, _, ruleHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + + // Only running holdout should be included + assert.Len(t, holdoutList, 1) + assert.Equal(t, "holdout_running", holdoutList[0].ID) + + // Only running holdout should appear in ruleHoldoutsMap + assert.NotContains(t, ruleHoldoutsMap, "rule_1") + assert.Contains(t, ruleHoldoutsMap, "rule_2") +} + +// TestMapHoldoutsCrossFlag tests local holdout with rules from multiple flags +func TestMapHoldoutsCrossFlag(t *testing.T) { + rawHoldouts := []datafileEntities.Holdout{ + { + ID: "holdout_cross_flag", + Key: "cross_flag_holdout", + Status: "Running", + IncludedRules: []string{"rule_1", "rule_2", "rule_3"}, // Rules from different flags + Variations: []datafileEntities.Variation{ + {ID: "var_1", Key: "variation_1"}, + }, + TrafficAllocation: []datafileEntities.TrafficAllocation{ + {EntityID: "var_1", EndOfRange: 10000}, + }, + }, + } + + featureMap := map[string]entities.Feature{ + "feature_1": {ID: "feature_1", Key: "feature_1"}, + "feature_2": {ID: "feature_2", Key: "feature_2"}, + "feature_3": {ID: "feature_3", Key: "feature_3"}, + } + + _, _, _, ruleHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + + // All three rules should have the cross-flag holdout + assert.Contains(t, ruleHoldoutsMap, "rule_1") + assert.Contains(t, ruleHoldoutsMap, "rule_2") + assert.Contains(t, ruleHoldoutsMap, "rule_3") + + assert.Equal(t, "holdout_cross_flag", ruleHoldoutsMap["rule_1"][0].ID) + assert.Equal(t, "holdout_cross_flag", ruleHoldoutsMap["rule_2"][0].ID) + assert.Equal(t, "holdout_cross_flag", ruleHoldoutsMap["rule_3"][0].ID) +} diff --git a/pkg/config/datafileprojectconfig/mappers/holdout_test.go b/pkg/config/datafileprojectconfig/mappers/holdout_test.go index 7b192005..14b4464e 100644 --- a/pkg/config/datafileprojectconfig/mappers/holdout_test.go +++ b/pkg/config/datafileprojectconfig/mappers/holdout_test.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2025, Optimizely, Inc. and contributors * + * Copyright 2025-2026, 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. * @@ -28,21 +28,22 @@ func TestMapHoldoutsEmpty(t *testing.T) { rawHoldouts := []datafileEntities.Holdout{} featureMap := map[string]entities.Feature{} - holdoutList, holdoutIDMap, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + holdoutList, holdoutIDMap, flagHoldoutsMap, ruleHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) assert.Empty(t, holdoutList) assert.Empty(t, holdoutIDMap) assert.Empty(t, flagHoldoutsMap) + assert.Empty(t, ruleHoldoutsMap) } func TestMapHoldoutsGlobalHoldout(t *testing.T) { - // Global holdout: no includedFlags, applies to all flags except excluded + // Global holdout: no IncludedRules, applies to all flags rawHoldouts := []datafileEntities.Holdout{ { - ID: "holdout_1", - Key: "global_holdout", - Status: "Running", - ExcludedFlags: []string{"feature_2"}, + ID: "holdout_1", + Key: "global_holdout", + Status: "Running", + // No IncludedRules - this is a global holdout Variations: []datafileEntities.Variation{ {ID: "var_1", Key: "variation_1"}, }, @@ -58,7 +59,7 @@ func TestMapHoldoutsGlobalHoldout(t *testing.T) { "feature_3": {ID: "feature_3", Key: "feature_3"}, } - holdoutList, holdoutIDMap, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + holdoutList, holdoutIDMap, flagHoldoutsMap, ruleHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) // Verify holdout list and ID map assert.Len(t, holdoutList, 1) @@ -66,23 +67,27 @@ func TestMapHoldoutsGlobalHoldout(t *testing.T) { assert.Equal(t, "holdout_1", holdoutList[0].ID) assert.Equal(t, "global_holdout", holdoutList[0].Key) - // Global holdout should apply to feature_1 and feature_3, but NOT feature_2 (excluded) + // Global holdout should apply to all features assert.Contains(t, flagHoldoutsMap, "feature_1") - assert.NotContains(t, flagHoldoutsMap, "feature_2") + assert.Contains(t, flagHoldoutsMap, "feature_2") assert.Contains(t, flagHoldoutsMap, "feature_3") assert.Len(t, flagHoldoutsMap["feature_1"], 1) + assert.Len(t, flagHoldoutsMap["feature_2"], 1) assert.Len(t, flagHoldoutsMap["feature_3"], 1) + + // Global holdout should NOT be in ruleHoldoutsMap + assert.Empty(t, ruleHoldoutsMap) } func TestMapHoldoutsSpecificHoldout(t *testing.T) { - // Specific holdout: has includedFlags, only applies to those flags + // Local holdout: has IncludedRules, applies to specific rules rawHoldouts := []datafileEntities.Holdout{ { ID: "holdout_1", Key: "specific_holdout", Status: "Running", - IncludedFlags: []string{"feature_1", "feature_2"}, + IncludedRules: []string{"rule_1", "rule_2"}, Variations: []datafileEntities.Variation{ {ID: "var_1", Key: "variation_1"}, }, @@ -98,19 +103,20 @@ func TestMapHoldoutsSpecificHoldout(t *testing.T) { "feature_3": {ID: "feature_3", Key: "feature_3"}, } - holdoutList, holdoutIDMap, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + holdoutList, holdoutIDMap, flagHoldoutsMap, ruleHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) // Verify holdout list and ID map assert.Len(t, holdoutList, 1) assert.Len(t, holdoutIDMap, 1) - // Specific holdout should only apply to feature_1 and feature_2 - assert.Contains(t, flagHoldoutsMap, "feature_1") - assert.Contains(t, flagHoldoutsMap, "feature_2") - assert.NotContains(t, flagHoldoutsMap, "feature_3") + // Local holdout should NOT be in flagHoldoutsMap + assert.Empty(t, flagHoldoutsMap) - assert.Len(t, flagHoldoutsMap["feature_1"], 1) - assert.Len(t, flagHoldoutsMap["feature_2"], 1) + // Local holdout should be in ruleHoldoutsMap for rule_1 and rule_2 + assert.Contains(t, ruleHoldoutsMap, "rule_1") + assert.Contains(t, ruleHoldoutsMap, "rule_2") + assert.Len(t, ruleHoldoutsMap["rule_1"], 1) + assert.Len(t, ruleHoldoutsMap["rule_2"], 1) } func TestMapHoldoutsNotRunning(t *testing.T) { @@ -130,22 +136,23 @@ func TestMapHoldoutsNotRunning(t *testing.T) { "feature_1": {ID: "feature_1", Key: "feature_1"}, } - holdoutList, holdoutIDMap, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + holdoutList, holdoutIDMap, flagHoldoutsMap, ruleHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) // Non-running holdouts should be filtered out assert.Empty(t, holdoutList) assert.Empty(t, holdoutIDMap) assert.Empty(t, flagHoldoutsMap) + assert.Empty(t, ruleHoldoutsMap) } func TestMapHoldoutsMixed(t *testing.T) { - // Mix of global and specific holdouts + // Mix of global and local holdouts rawHoldouts := []datafileEntities.Holdout{ { - ID: "holdout_global", - Key: "global_holdout", - Status: "Running", - ExcludedFlags: []string{"feature_2"}, + ID: "holdout_global", + Key: "global_holdout", + Status: "Running", + // No IncludedRules - global holdout Variations: []datafileEntities.Variation{ {ID: "var_global", Key: "variation_global"}, }, @@ -157,7 +164,7 @@ func TestMapHoldoutsMixed(t *testing.T) { ID: "holdout_specific", Key: "specific_holdout", Status: "Running", - IncludedFlags: []string{"feature_2"}, + IncludedRules: []string{"rule_1"}, Variations: []datafileEntities.Variation{ {ID: "var_specific", Key: "variation_specific"}, }, @@ -172,32 +179,33 @@ func TestMapHoldoutsMixed(t *testing.T) { "feature_2": {ID: "feature_2", Key: "feature_2"}, } - holdoutList, holdoutIDMap, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + holdoutList, holdoutIDMap, flagHoldoutsMap, ruleHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) // Verify both holdouts are in the list assert.Len(t, holdoutList, 2) assert.Len(t, holdoutIDMap, 2) - // feature_1: should get global holdout only (not excluded, not specifically included) + // All features should get global holdout assert.Contains(t, flagHoldoutsMap, "feature_1") + assert.Contains(t, flagHoldoutsMap, "feature_2") assert.Len(t, flagHoldoutsMap["feature_1"], 1) + assert.Len(t, flagHoldoutsMap["feature_2"], 1) assert.Equal(t, "global_holdout", flagHoldoutsMap["feature_1"][0].Key) - // feature_2: should get specific holdout only (excluded from global) - assert.Contains(t, flagHoldoutsMap, "feature_2") - assert.Len(t, flagHoldoutsMap["feature_2"], 1) - assert.Equal(t, "specific_holdout", flagHoldoutsMap["feature_2"][0].Key) + // rule_1 should get local holdout + assert.Contains(t, ruleHoldoutsMap, "rule_1") + assert.Len(t, ruleHoldoutsMap["rule_1"], 1) + assert.Equal(t, "specific_holdout", ruleHoldoutsMap["rule_1"][0].Key) } func TestMapHoldoutsPrecedence(t *testing.T) { - // Test that global holdouts take precedence over specific holdouts - // When both apply to the same flag, global should come first in the slice + // Test that global holdouts are in flagHoldoutsMap, local holdouts are in ruleHoldoutsMap rawHoldouts := []datafileEntities.Holdout{ { ID: "holdout_global", Key: "global_holdout", Status: "Running", - // No includedFlags = global, applies to all + // No IncludedRules = global, applies to all Variations: []datafileEntities.Variation{ {ID: "var_global", Key: "variation_global"}, }, @@ -206,15 +214,15 @@ func TestMapHoldoutsPrecedence(t *testing.T) { }, }, { - ID: "holdout_specific", - Key: "specific_holdout", + ID: "holdout_local", + Key: "local_holdout", Status: "Running", - IncludedFlags: []string{"feature_1"}, // Specific to feature_1 + IncludedRules: []string{"rule_1"}, // Local to rule_1 Variations: []datafileEntities.Variation{ - {ID: "var_specific", Key: "variation_specific"}, + {ID: "var_local", Key: "variation_local"}, }, TrafficAllocation: []datafileEntities.TrafficAllocation{ - {EntityID: "var_specific", EndOfRange: 10000}, + {EntityID: "var_local", EndOfRange: 10000}, }, }, } @@ -223,26 +231,27 @@ func TestMapHoldoutsPrecedence(t *testing.T) { "feature_1": {ID: "feature_1", Key: "feature_1"}, } - _, _, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + _, _, flagHoldoutsMap, ruleHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) - // feature_1 should have BOTH holdouts, with global FIRST (precedence) + // feature_1 should have global holdout assert.Contains(t, flagHoldoutsMap, "feature_1") - assert.Len(t, flagHoldoutsMap["feature_1"], 2) + assert.Len(t, flagHoldoutsMap["feature_1"], 1) + assert.Equal(t, "global_holdout", flagHoldoutsMap["feature_1"][0].Key) - // Global holdout should be first (takes precedence) - assert.Equal(t, "global_holdout", flagHoldoutsMap["feature_1"][0].Key, "Global holdout should take precedence (be first)") - // Specific holdout should be second - assert.Equal(t, "specific_holdout", flagHoldoutsMap["feature_1"][1].Key, "Specific holdout should be second") + // rule_1 should have local holdout + assert.Contains(t, ruleHoldoutsMap, "rule_1") + assert.Len(t, ruleHoldoutsMap["rule_1"], 1) + assert.Equal(t, "local_holdout", ruleHoldoutsMap["rule_1"][0].Key) } -func TestMapHoldoutsExcludedFlagsNotInMap(t *testing.T) { - // Test that excluded flags do not get global holdouts +func TestMapHoldoutsGlobalAppliestoAllFeatures(t *testing.T) { + // Test that global holdouts apply to all features rawHoldouts := []datafileEntities.Holdout{ { - ID: "holdout_global", - Key: "global_holdout", - Status: "Running", - ExcludedFlags: []string{"feature_excluded"}, + ID: "holdout_global", + Key: "global_holdout", + Status: "Running", + // No IncludedRules - global holdout Variations: []datafileEntities.Variation{ {ID: "var_1", Key: "variation_1"}, }, @@ -253,18 +262,17 @@ func TestMapHoldoutsExcludedFlagsNotInMap(t *testing.T) { } featureMap := map[string]entities.Feature{ - "feature_included": {ID: "feature_included", Key: "feature_included"}, - "feature_excluded": {ID: "feature_excluded", Key: "feature_excluded"}, + "feature_1": {ID: "feature_1", Key: "feature_1"}, + "feature_2": {ID: "feature_2", Key: "feature_2"}, } - _, _, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) - - // feature_included should have the global holdout - assert.Contains(t, flagHoldoutsMap, "feature_included") - assert.Len(t, flagHoldoutsMap["feature_included"], 1) + _, _, flagHoldoutsMap, _ := MapHoldouts(rawHoldouts, featureMap) - // feature_excluded should NOT be in the map (no holdouts apply) - assert.NotContains(t, flagHoldoutsMap, "feature_excluded") + // All features should have the global holdout + assert.Contains(t, flagHoldoutsMap, "feature_1") + assert.Contains(t, flagHoldoutsMap, "feature_2") + assert.Len(t, flagHoldoutsMap["feature_1"], 1) + assert.Len(t, flagHoldoutsMap["feature_2"], 1) } func TestMapHoldoutsWithAudienceConditions(t *testing.T) { @@ -288,7 +296,7 @@ func TestMapHoldoutsWithAudienceConditions(t *testing.T) { "feature_1": {ID: "feature_1", Key: "feature_1"}, } - holdoutList, _, _ := MapHoldouts(rawHoldouts, featureMap) + holdoutList, _, _, _ := MapHoldouts(rawHoldouts, featureMap) // Verify audience conditions are mapped assert.Len(t, holdoutList, 1) @@ -328,7 +336,7 @@ func TestMapHoldoutsVariationsMapping(t *testing.T) { "feature_1": {ID: "feature_1", Key: "feature_1"}, } - holdoutList, _, _ := MapHoldouts(rawHoldouts, featureMap) + holdoutList, _, _, _ := MapHoldouts(rawHoldouts, featureMap) // Verify variations are mapped correctly assert.Len(t, holdoutList, 1) diff --git a/pkg/config/interface.go b/pkg/config/interface.go index 6d45c14a..82965f8f 100644 --- a/pkg/config/interface.go +++ b/pkg/config/interface.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2025, Optimizely, Inc. and contributors * + * Copyright 2019-2026, 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. * @@ -57,6 +57,8 @@ type ProjectConfig interface { GetFlagVariationsMap() map[string][]entities.Variation GetRegion() string GetHoldoutsForFlag(featureKey string) []entities.Holdout + GetHoldoutsForRule(ruleID string) []entities.Holdout + GetGlobalHoldouts() []entities.Holdout } // ProjectConfigManager maintains an instance of the ProjectConfig diff --git a/pkg/decision/evaluator/audience_evaluator_test.go b/pkg/decision/evaluator/audience_evaluator_test.go index 75897aed..3da1bbbd 100644 --- a/pkg/decision/evaluator/audience_evaluator_test.go +++ b/pkg/decision/evaluator/audience_evaluator_test.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2025, Optimizely, Inc. and contributors * + * Copyright 2019-2026, 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. * @@ -205,6 +205,16 @@ func (m *MockProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Hol return args.Get(0).([]entities.Holdout) } +func (m *MockProjectConfig) GetHoldoutsForRule(ruleID string) []entities.Holdout { + args := m.Called(ruleID) + return args.Get(0).([]entities.Holdout) +} + +func (m *MockProjectConfig) GetGlobalHoldouts() []entities.Holdout { + args := m.Called() + 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/feature_experiment_service.go b/pkg/decision/feature_experiment_service.go index f2bc3689..1df5e61d 100644 --- a/pkg/decision/feature_experiment_service.go +++ b/pkg/decision/feature_experiment_service.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2021, Optimizely, Inc. and contributors * + * Copyright 2019-2026, 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. * @@ -60,6 +60,18 @@ func (f FeatureExperimentService) GetDecision(decisionContext FeatureDecisionCon } } + // Check local holdouts targeting this specific rule + localHoldouts := decisionContext.ProjectConfig.GetHoldoutsForRule(featureExperiment.ID) + for i := range localHoldouts { + holdout := &localHoldouts[i] + holdoutDecision, holdoutReasons := f.evaluateHoldout(holdout, userContext, decisionContext, options, feature.Key) + reasons.Append(holdoutReasons) + if holdoutDecision.Variation != nil { + // User is in local holdout - return immediately, skip rule evaluation + return holdoutDecision, reasons, nil + } + } + experiment := featureExperiment experimentDecisionContext := ExperimentDecisionContext{ Experiment: &experiment, @@ -99,3 +111,10 @@ func (f FeatureExperimentService) GetDecision(decisionContext FeatureDecisionCon return FeatureDecision{}, reasons, nil } + +// evaluateHoldout evaluates a single holdout for a user and returns a decision +func (f FeatureExperimentService) evaluateHoldout(holdout *entities.Holdout, userContext entities.UserContext, decisionContext FeatureDecisionContext, options *decide.Options, flagKey string) (FeatureDecision, decide.DecisionReasons) { + // Delegate to shared HoldoutService method to avoid code duplication + holdoutService := NewHoldoutService("") + return holdoutService.EvaluateLocalHoldout(holdout, userContext, decisionContext.ProjectConfig, options, fmt.Sprintf("feature experiment %s", flagKey)) +} diff --git a/pkg/decision/feature_experiment_service_test.go b/pkg/decision/feature_experiment_service_test.go index 7d1c55c1..479b472d 100644 --- a/pkg/decision/feature_experiment_service_test.go +++ b/pkg/decision/feature_experiment_service_test.go @@ -24,6 +24,7 @@ import ( "github.com/optimizely/go-sdk/v2/pkg/entities" "github.com/optimizely/go-sdk/v2/pkg/logging" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" ) @@ -295,6 +296,174 @@ func (s *FeatureExperimentServiceTestSuite) TestGetDecisionWithCmabError() { s.mockExperimentService.AssertExpectations(s.T()) } +func (s *FeatureExperimentServiceTestSuite) TestGetDecisionWithLocalHoldout() { + // Create a custom mock that returns holdouts for the specific rule + customMock := &mockProjectConfigWithHoldouts{ + mockProjectConfig: s.mockConfig, + holdoutsForRule: map[string][]entities.Holdout{ + testExp1113.ID: { + { + ID: "holdout_1", + Key: "test_holdout", + Status: entities.HoldoutStatusRunning, + Variations: map[string]entities.Variation{ + "var_holdout": {ID: "var_holdout", Key: "holdout_variation"}, + }, + TrafficAllocation: []entities.Range{ + {EntityID: "var_holdout", EndOfRange: 10000}, + }, + IncludedRules: []string{testExp1113.ID}, + }, + }, + }, + audienceMap: map[string]entities.Audience{}, + } + + testUserContext := entities.UserContext{ID: "test_user_1"} + customDecisionContext := FeatureDecisionContext{ + Feature: &testFeat3335, + ProjectConfig: customMock, + } + + featureExperimentService := &FeatureExperimentService{ + compositeExperimentService: s.mockExperimentService, + logger: logging.GetLogger("sdkKey", "FeatureExperimentService"), + } + + decision, _, err := featureExperimentService.GetDecision(customDecisionContext, testUserContext, s.options) + + s.NoError(err) + s.Equal(Holdout, decision.Source, "Decision source should be Holdout") + s.NotNil(decision.Variation, "Should have a variation from holdout") +} + +func (s *FeatureExperimentServiceTestSuite) TestGetDecisionWithLocalHoldoutNotBucketed() { + // Create a holdout with 0 traffic so user won't be bucketed + customMock := &mockProjectConfigWithHoldouts{ + mockProjectConfig: s.mockConfig, + holdoutsForRule: map[string][]entities.Holdout{ + testExp1113.ID: { + { + ID: "holdout_zero_traffic", + Key: "test_holdout_no_bucket", + Status: entities.HoldoutStatusRunning, + Variations: map[string]entities.Variation{ + "var_holdout": {ID: "var_holdout", Key: "holdout_variation"}, + }, + TrafficAllocation: []entities.Range{ + {EntityID: "var_holdout", EndOfRange: 0}, // 0% traffic + }, + IncludedRules: []string{testExp1113.ID}, + }, + }, + }, + audienceMap: map[string]entities.Audience{}, + } + + testUserContext := entities.UserContext{ID: "test_user_1"} + customDecisionContext := FeatureDecisionContext{ + Feature: &testFeat3335, + ProjectConfig: customMock, + } + + // Mock experiment service to return a variation (simulating normal bucketing after holdout check) + expectedVariation := &testExp1113Var2223 + expectedDecision := ExperimentDecision{ + Variation: expectedVariation, + Decision: Decision{Reason: "bucketed"}, + } + s.mockExperimentService.On("GetDecision", mock.Anything, testUserContext, s.options). + Return(expectedDecision, decide.NewDecisionReasons(s.options), nil).Once() + + featureExperimentService := &FeatureExperimentService{ + compositeExperimentService: s.mockExperimentService, + logger: logging.GetLogger("sdkKey", "FeatureExperimentService"), + } + + decision, _, err := featureExperimentService.GetDecision(customDecisionContext, testUserContext, s.options) + + s.NoError(err) + // Should fall through to normal experiment bucketing since holdout didn't bucket user + s.Equal(FeatureTest, decision.Source, "Decision source should be FeatureTest (not Holdout)") + s.Equal(expectedVariation, decision.Variation, "Should have variation from experiment bucketing") + s.mockExperimentService.AssertExpectations(s.T()) +} + +func (s *FeatureExperimentServiceTestSuite) TestGetDecisionWithMultipleLocalHoldouts() { + // Create a scenario with multiple holdouts where first doesn't bucket but second does + customMock := &mockProjectConfigWithHoldouts{ + mockProjectConfig: s.mockConfig, + holdoutsForRule: map[string][]entities.Holdout{ + testExp1113.ID: { + // First holdout - 0% traffic, won't bucket user + { + ID: "holdout_first", + Key: "test_holdout_first", + Status: entities.HoldoutStatusRunning, + Variations: map[string]entities.Variation{ + "var_first": {ID: "var_first", Key: "first_variation"}, + }, + TrafficAllocation: []entities.Range{ + {EntityID: "var_first", EndOfRange: 0}, // 0% traffic + }, + IncludedRules: []string{testExp1113.ID}, + }, + // Second holdout - 100% traffic, will bucket user + { + ID: "holdout_second", + Key: "test_holdout_second", + Status: entities.HoldoutStatusRunning, + Variations: map[string]entities.Variation{ + "var_second": {ID: "var_second", Key: "second_variation"}, + }, + TrafficAllocation: []entities.Range{ + {EntityID: "var_second", EndOfRange: 10000}, // 100% traffic + }, + IncludedRules: []string{testExp1113.ID}, + }, + }, + }, + audienceMap: map[string]entities.Audience{}, + } + + testUserContext := entities.UserContext{ID: "test_user_1"} + customDecisionContext := FeatureDecisionContext{ + Feature: &testFeat3335, + ProjectConfig: customMock, + } + + featureExperimentService := &FeatureExperimentService{ + compositeExperimentService: s.mockExperimentService, + logger: logging.GetLogger("sdkKey", "FeatureExperimentService"), + } + + decision, _, err := featureExperimentService.GetDecision(customDecisionContext, testUserContext, s.options) + + s.NoError(err) + // Should bucket into second holdout after first one returns nil + s.Equal(Holdout, decision.Source, "Decision source should be Holdout") + s.NotNil(decision.Variation, "Should have a variation from second holdout") + s.Equal("second_variation", decision.Variation.Key, "Should be from second holdout") +} + +// Custom mock that overrides GetHoldoutsForRule and GetAudienceMap +type mockProjectConfigWithHoldouts struct { + *mockProjectConfig + holdoutsForRule map[string][]entities.Holdout + audienceMap map[string]entities.Audience +} + +func (m *mockProjectConfigWithHoldouts) GetHoldoutsForRule(ruleID string) []entities.Holdout { + if holdouts, ok := m.holdoutsForRule[ruleID]; ok { + return holdouts + } + return []entities.Holdout{} +} + +func (m *mockProjectConfigWithHoldouts) GetAudienceMap() map[string]entities.Audience { + return m.audienceMap +} + func TestFeatureExperimentServiceTestSuite(t *testing.T) { suite.Run(t, new(FeatureExperimentServiceTestSuite)) } diff --git a/pkg/decision/helpers_test.go b/pkg/decision/helpers_test.go index 8c7a94cc..9c20a741 100644 --- a/pkg/decision/helpers_test.go +++ b/pkg/decision/helpers_test.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2021, Optimizely, Inc. and contributors * + * Copyright 2019-2026, 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. * @@ -64,6 +64,18 @@ func (c *mockProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Hol return args.Get(0).([]entities.Holdout) } +func (c *mockProjectConfig) GetHoldoutsForRule(ruleID string) []entities.Holdout { + // Don't call the mock - just return empty slice + // Tests can override this method if they need specific behavior + return []entities.Holdout{} +} + +func (c *mockProjectConfig) GetGlobalHoldouts() []entities.Holdout { + // Don't call the mock - just return empty slice + // Tests can override this method if they need specific behavior + return []entities.Holdout{} +} + type MockExperimentDecisionService struct { mock.Mock } diff --git a/pkg/decision/holdout_service.go b/pkg/decision/holdout_service.go index 48fae2ad..7a3b6201 100644 --- a/pkg/decision/holdout_service.go +++ b/pkg/decision/holdout_service.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2025, Optimizely, Inc. and contributors * + * Copyright 2025-2026, 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. * @@ -64,7 +64,7 @@ func (h HoldoutService) GetDecision(decisionContext FeatureDecisionContext, user } // Check audience conditions - inAudience := h.checkIfUserInHoldoutAudience(holdout, userContext, decisionContext.ProjectConfig, options) + inAudience := h.CheckIfUserInHoldoutAudience(holdout, userContext, decisionContext.ProjectConfig, options) reasons.Append(inAudience.reasons) if !inAudience.result { @@ -120,8 +120,8 @@ func (h HoldoutService) GetDecision(decisionContext FeatureDecisionContext, user 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 { +// 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 { @@ -148,6 +148,75 @@ func (h HoldoutService) checkIfUserInHoldoutAudience(holdout *entities.Holdout, return decisionResult{result: true, reasons: decisionReasons} } +// EvaluateLocalHoldout evaluates a single local holdout for a user and returns a decision +// This is shared logic used by both RolloutService and FeatureExperimentService +func (h HoldoutService) EvaluateLocalHoldout(holdout *entities.Holdout, userContext entities.UserContext, projectConfig config.ProjectConfig, options *decide.Options, contextDescription string) (FeatureDecision, decide.DecisionReasons) { + reasons := decide.NewDecisionReasons(options) + + h.logger.Debug(fmt.Sprintf("Evaluating local holdout %s for %s", holdout.Key, contextDescription)) + + // Check if holdout is running + if holdout.Status != entities.HoldoutStatusRunning { + reason := reasons.AddInfo("Local holdout %s is not running.", holdout.Key) + h.logger.Info(reason) + return FeatureDecision{}, reasons + } + + // Check audience conditions + inAudience := h.CheckIfUserInHoldoutAudience(holdout, userContext, projectConfig, options) + reasons.Append(inAudience.reasons) + + if !inAudience.result { + reason := reasons.AddInfo("User %s does not meet conditions for local holdout %s.", userContext.ID, holdout.Key) + h.logger.Info(reason) + return FeatureDecision{}, reasons + } + + reason := reasons.AddInfo("User %s meets conditions for local 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 local 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 local holdout %s.", userContext.ID, variation.Key, holdout.Key) + h.logger.Info(reason) + + featureDecision := FeatureDecision{ + Experiment: experimentForBucketing, + Variation: variation, + Source: Holdout, + } + return featureDecision, reasons + } + + reason = reasons.AddInfo("User %s is in no local holdout variation.", userContext.ID) + h.logger.Info(reason) + return FeatureDecision{}, reasons +} + // decisionResult is a helper struct to return both result and reasons type decisionResult struct { result bool diff --git a/pkg/decision/holdout_service_test.go b/pkg/decision/holdout_service_test.go index 9a5e4a71..7ddbaf16 100644 --- a/pkg/decision/holdout_service_test.go +++ b/pkg/decision/holdout_service_test.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2025, Optimizely, Inc. and contributors * + * Copyright 2025-2026, 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. * @@ -302,7 +302,7 @@ func (s *HoldoutServiceTestSuite) TestCheckIfUserInHoldoutAudienceNilHoldout() { } s.mockLogger.On("Debug", mock.Anything).Return() - result := testHoldoutService.checkIfUserInHoldoutAudience(nil, s.testUserContext, s.mockConfig, s.options) + result := testHoldoutService.CheckIfUserInHoldoutAudience(nil, s.testUserContext, s.mockConfig, s.options) s.False(result.result) } @@ -315,7 +315,7 @@ func (s *HoldoutServiceTestSuite) TestCheckIfUserInHoldoutAudienceNoConditionTre } s.mockLogger.On("Debug", mock.Anything).Return() - result := testHoldoutService.checkIfUserInHoldoutAudience(&holdout, s.testUserContext, s.mockConfig, s.options) + result := testHoldoutService.CheckIfUserInHoldoutAudience(&holdout, s.testUserContext, s.mockConfig, s.options) s.True(result.result) } @@ -329,12 +329,83 @@ func (s *HoldoutServiceTestSuite) TestCheckIfUserInHoldoutAudienceWithConditionT s.mockAudienceTreeEvaluator.On("Evaluate", holdout.AudienceConditionTree, mock.Anything, s.options).Return(true, true, s.decisionReasons) s.mockLogger.On("Debug", mock.Anything).Return() - result := testHoldoutService.checkIfUserInHoldoutAudience(&holdout, s.testUserContext, s.mockConfig, s.options) + result := testHoldoutService.CheckIfUserInHoldoutAudience(&holdout, s.testUserContext, s.mockConfig, s.options) s.True(result.result) s.mockAudienceTreeEvaluator.AssertExpectations(s.T()) } +func (s *HoldoutServiceTestSuite) TestEvaluateLocalHoldout() { + // Test EvaluateLocalHoldout method with a holdout that buckets user (no audience) + holdout := &entities.Holdout{ + ID: "local_holdout_1", + Key: "test_local_holdout", + Status: entities.HoldoutStatusRunning, + Variations: map[string]entities.Variation{ + "holdout_var": {ID: "holdout_var", Key: "holdout_variation"}, + }, + TrafficAllocation: []entities.Range{ + {EntityID: "holdout_var", EndOfRange: 10000}, // 100% traffic + }, + AudienceConditionTree: nil, // No audience conditions + } + + s.mockConfig.On("GetAudienceMap").Return(map[string]entities.Audience{}) + s.mockLogger.On("Debug", mock.Anything).Return() + s.mockLogger.On("Info", mock.Anything).Return() + + testHoldoutService := &HoldoutService{ + audienceTreeEvaluator: s.mockAudienceTreeEvaluator, + bucketer: s.mockBucketer, + logger: s.mockLogger, + } + + // Mock bucketer to return variation + expectedVariation := &entities.Variation{ID: "holdout_var", Key: "holdout_variation"} + s.mockBucketer.On("Bucket", s.testUserContext.ID, mock.AnythingOfType("entities.Experiment"), entities.Group{}). + Return(expectedVariation, reasons.Reason(""), nil) + + decision, _ := testHoldoutService.EvaluateLocalHoldout( + holdout, + s.testUserContext, + s.mockConfig, + s.options, + "test context", + ) + + s.Equal(Holdout, decision.Source) + s.NotNil(decision.Variation) + s.Equal("holdout_variation", decision.Variation.Key) + s.mockBucketer.AssertExpectations(s.T()) +} + +func (s *HoldoutServiceTestSuite) TestEvaluateLocalHoldoutNotRunning() { + // Test EvaluateLocalHoldout with non-running holdout + holdout := &entities.Holdout{ + ID: "paused_holdout", + Key: "test_paused_holdout", + Status: "Paused", // Not running (any status other than "Running") + } + + s.mockLogger.On("Debug", mock.Anything).Return() + s.mockLogger.On("Info", mock.Anything).Return() + + testHoldoutService := &HoldoutService{ + logger: s.mockLogger, + } + + decision, resultReasons := testHoldoutService.EvaluateLocalHoldout( + holdout, + s.testUserContext, + s.mockConfig, + s.options, + "test context", + ) + + s.Equal(FeatureDecision{}, decision) + s.NotNil(resultReasons) +} + func TestHoldoutServiceTestSuite(t *testing.T) { suite.Run(t, new(HoldoutServiceTestSuite)) } diff --git a/pkg/decision/rollout_service.go b/pkg/decision/rollout_service.go index 13c1402c..4750e17e 100644 --- a/pkg/decision/rollout_service.go +++ b/pkg/decision/rollout_service.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2021, Optimizely, Inc. and contributors * + * Copyright 2019-2026, 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. * @@ -98,62 +98,115 @@ func (r RolloutService) GetDecision(decisionContext FeatureDecisionContext, user return nil } + // Evaluate targeted delivery rules (all except last "Everyone Else" rule) for index := 0; index < numberOfExperiments-1; index++ { loggingKey := strconv.Itoa(index + 1) experiment := &rollout.Experiments[index] - // Checking for forced decision + // Check for forced decision if forcedDecision := checkForForcedDecision(experiment); forcedDecision != nil { return *forcedDecision, reasons, nil } - experimentDecisionContext := getExperimentDecisionContext(experiment) - // Move to next evaluation if condition tree is available and evaluation fails + // Check local holdouts targeting this delivery rule + localHoldouts := decisionContext.ProjectConfig.GetHoldoutsForRule(experiment.ID) + for i := range localHoldouts { + holdout := &localHoldouts[i] + holdoutDecision, holdoutReasons := r.evaluateHoldout(holdout, userContext, decisionContext, options) + reasons.Append(holdoutReasons) + if holdoutDecision.Variation != nil { + // User is in local holdout - return immediately + return holdoutDecision, reasons, nil + } + } + // Evaluate audience conditions evaluationResult := experiment.AudienceConditionTree == nil || evaluateConditionTree(experiment, loggingKey) r.logger.Debug(fmt.Sprintf(logging.RolloutAudiencesEvaluatedTo.String(), loggingKey, evaluationResult)) if !evaluationResult { logMessage := reasons.AddInfo(logging.UserNotInRollout.String(), userContext.ID, loggingKey) r.logger.Debug(logMessage) - // Evaluate this user for the next rule + // Continue to next rule continue } + experimentDecisionContext := getExperimentDecisionContext(experiment) + // Bucket user into variation decision, decisionReasons, _ := r.experimentBucketerService.GetDecision(experimentDecisionContext, userContext, options) reasons.Append(decisionReasons) + if decision.Variation == nil { // Evaluate fall back rule / last rule now break } + + // User bucketed successfully finalFeatureDecision := r.getFeatureDecision(&featureDecision, userContext, *feature, experiment, &decision) return finalFeatureDecision, reasons, nil } - // fall back rule / last rule - experiment := &rollout.Experiments[numberOfExperiments-1] + // Evaluate "Everyone Else" rule (last rule) + return r.evaluateEveryoneElseRule( + &rollout.Experiments[numberOfExperiments-1], + &featureDecision, + feature, + userContext, + decisionContext, + options, + reasons, + checkForForcedDecision, + evaluateConditionTree, + getExperimentDecisionContext, + ) +} - // Checking for forced decision +// evaluateEveryoneElseRule evaluates the "Everyone Else" fallback rule +func (r RolloutService) evaluateEveryoneElseRule( + experiment *entities.Experiment, + featureDecision *FeatureDecision, + feature *entities.Feature, + userContext entities.UserContext, + decisionContext FeatureDecisionContext, + options *decide.Options, + reasons decide.DecisionReasons, + checkForForcedDecision func(*entities.Experiment) *FeatureDecision, + evaluateConditionTree func(*entities.Experiment, string) bool, + getExperimentDecisionContext func(*entities.Experiment) ExperimentDecisionContext, +) (FeatureDecision, decide.DecisionReasons, error) { + // Check for forced decision if forcedDecision := checkForForcedDecision(experiment); forcedDecision != nil { return *forcedDecision, reasons, nil } - experimentDecisionContext := getExperimentDecisionContext(experiment) - // Move to bucketing if conditionTree is unavailable or evaluation passes + // Check local holdouts targeting the "Everyone Else" rule + localHoldouts := decisionContext.ProjectConfig.GetHoldoutsForRule(experiment.ID) + for i := range localHoldouts { + holdout := &localHoldouts[i] + holdoutDecision, holdoutReasons := r.evaluateHoldout(holdout, userContext, decisionContext, options) + reasons.Append(holdoutReasons) + if holdoutDecision.Variation != nil { + // User is in local holdout - return immediately + return holdoutDecision, reasons, nil + } + } + + // Evaluate audience conditions evaluationResult := experiment.AudienceConditionTree == nil || evaluateConditionTree(experiment, "Everyone Else") r.logger.Debug(fmt.Sprintf(logging.RolloutAudiencesEvaluatedTo.String(), "Everyone Else", evaluationResult)) if evaluationResult { + experimentDecisionContext := getExperimentDecisionContext(experiment) decision, decisionReasons, err := r.experimentBucketerService.GetDecision(experimentDecisionContext, userContext, options) reasons.Append(decisionReasons) if err == nil { logMessage := reasons.AddInfo(logging.UserInEveryoneElse.String(), userContext.ID) r.logger.Debug(logMessage) } - finalFeatureDecision := r.getFeatureDecision(&featureDecision, userContext, *feature, experiment, &decision) - return finalFeatureDecision, reasons, nil + *featureDecision = r.getFeatureDecision(featureDecision, userContext, *feature, experiment, &decision) + return *featureDecision, reasons, nil } - return featureDecision, reasons, nil + return *featureDecision, reasons, nil } // creating this sub method to avoid cyco-complexity warning @@ -187,3 +240,10 @@ func (r RolloutService) getForcedDecision(decisionContext FeatureDecisionContext } return nil, reasons } + +// evaluateHoldout evaluates a single local holdout for a user and returns a decision +func (r RolloutService) evaluateHoldout(holdout *entities.Holdout, userContext entities.UserContext, decisionContext FeatureDecisionContext, options *decide.Options) (FeatureDecision, decide.DecisionReasons) { + // Delegate to shared HoldoutService method to avoid code duplication + holdoutService := NewHoldoutService("") + return holdoutService.EvaluateLocalHoldout(holdout, userContext, decisionContext.ProjectConfig, options, "delivery rule") +} diff --git a/pkg/decision/rollout_service_test.go b/pkg/decision/rollout_service_test.go index 70cd20b0..12ef20f4 100644 --- a/pkg/decision/rollout_service_test.go +++ b/pkg/decision/rollout_service_test.go @@ -387,6 +387,251 @@ func TestNewRolloutService(t *testing.T) { assert.IsType(t, &ExperimentBucketerService{logger: logging.GetLogger("sdkKey", "ExperimentBucketerService")}, rolloutService.experimentBucketerService) } +func TestEvaluateHoldoutNotRunning(t *testing.T) { + rolloutService := NewRolloutService("") + mockConfig := new(mockProjectConfig) + userContext := entities.UserContext{ID: "test_user"} + options := &decide.Options{} + + draftHoldout := &entities.Holdout{ + ID: "holdout_1", + Key: "draft_holdout", + Status: "Draft", // Not running + } + + decisionContext := FeatureDecisionContext{ + ProjectConfig: mockConfig, + } + + decision, reasons := rolloutService.evaluateHoldout(draftHoldout, userContext, decisionContext, options) + + // Should return empty decision for non-running holdout + assert.Nil(t, decision.Variation) + assert.NotNil(t, reasons) +} + +func TestEvaluateHoldoutRunningNoAudience(t *testing.T) { + rolloutService := NewRolloutService("") + mockConfig := new(mockProjectConfig) + userContext := entities.UserContext{ID: "test_user"} + options := &decide.Options{} + + holdoutVar := entities.Variation{ID: "var_1", Key: "control"} + runningHoldout := &entities.Holdout{ + ID: "holdout_1", + Key: "running_holdout", + Status: entities.HoldoutStatusRunning, + Variations: map[string]entities.Variation{ + "var_1": holdoutVar, + }, + TrafficAllocation: []entities.Range{ + {EntityID: "var_1", EndOfRange: 10000}, // 100% traffic + }, + AudienceConditionTree: nil, // No audience conditions + } + + mockConfig.On("GetAudienceMap").Return(map[string]entities.Audience{}) + + decisionContext := FeatureDecisionContext{ + ProjectConfig: mockConfig, + Feature: &testFeatRollout3334, + } + + decision, reasons := rolloutService.evaluateHoldout(runningHoldout, userContext, decisionContext, options) + + // Should return holdout variation for 100% traffic + assert.NotNil(t, decision.Variation) + assert.Equal(t, "control", decision.Variation.Key) + assert.Equal(t, Holdout, decision.Source) + assert.NotNil(t, reasons) +} + +func TestEvaluateHoldoutAudienceFails(t *testing.T) { + rolloutService := NewRolloutService("") + mockConfig := new(mockProjectConfig) + userContext := entities.UserContext{ID: "test_user"} + options := &decide.Options{} + + holdoutVar := entities.Variation{ID: "var_1", Key: "control"} + audienceCondTree := &entities.TreeNode{ + Operator: "or", + Nodes: []*entities.TreeNode{ + {Item: "audience_123"}, + }, + } + holdoutWithAudience := &entities.Holdout{ + ID: "holdout_2", + Key: "audience_holdout", + Status: entities.HoldoutStatusRunning, + Variations: map[string]entities.Variation{"var_1": holdoutVar}, + TrafficAllocation: []entities.Range{{EntityID: "var_1", EndOfRange: 10000}}, + AudienceConditionTree: audienceCondTree, + } + + mockConfig.On("GetAudienceMap").Return(map[string]entities.Audience{}) + + decisionContext := FeatureDecisionContext{ + ProjectConfig: mockConfig, + Feature: &testFeatRollout3334, + } + + decision, reasons := rolloutService.evaluateHoldout(holdoutWithAudience, userContext, decisionContext, options) + + // Should return empty decision when audience doesn't match + assert.Nil(t, decision.Variation) + assert.NotNil(t, reasons) +} + +func TestEvaluateHoldoutNotBucketed(t *testing.T) { + rolloutService := NewRolloutService("") + mockConfig := new(mockProjectConfig) + userContext := entities.UserContext{ID: "test_user_not_bucketed"} + options := &decide.Options{} + + holdoutVar := entities.Variation{ID: "var_1", Key: "control"} + runningHoldout := &entities.Holdout{ + ID: "holdout_3", + Key: "zero_traffic_holdout", + Status: entities.HoldoutStatusRunning, + Variations: map[string]entities.Variation{ + "var_1": holdoutVar, + }, + TrafficAllocation: []entities.Range{ + {EntityID: "var_1", EndOfRange: 0}, // 0% traffic - no one gets bucketed + }, + AudienceConditionTree: nil, + } + + mockConfig.On("GetAudienceMap").Return(map[string]entities.Audience{}) + + decisionContext := FeatureDecisionContext{ + ProjectConfig: mockConfig, + Feature: &testFeatRollout3334, + } + + decision, reasons := rolloutService.evaluateHoldout(runningHoldout, userContext, decisionContext, options) + + // Should return empty decision when user not bucketed (0% traffic) + assert.Nil(t, decision.Variation) + assert.NotNil(t, reasons) +} + +func TestEvaluateHoldoutWithCustomBucketingID(t *testing.T) { + rolloutService := NewRolloutService("") + mockConfig := new(mockProjectConfig) + // User context with custom bucketing ID attribute + userContext := entities.UserContext{ + ID: "test_user", + Attributes: map[string]interface{}{ + "$opt_bucketing_id": "custom_bucket_123", + }, + } + options := &decide.Options{} + + holdoutVar := entities.Variation{ID: "var_1", Key: "control"} + runningHoldout := &entities.Holdout{ + ID: "holdout_4", + Key: "bucketing_id_holdout", + Status: entities.HoldoutStatusRunning, + Variations: map[string]entities.Variation{ + "var_1": holdoutVar, + }, + TrafficAllocation: []entities.Range{ + {EntityID: "var_1", EndOfRange: 10000}, + }, + AudienceConditionTree: nil, + } + + mockConfig.On("GetAudienceMap").Return(map[string]entities.Audience{}) + + decisionContext := FeatureDecisionContext{ + ProjectConfig: mockConfig, + Feature: &testFeatRollout3334, + } + + _, reasons := rolloutService.evaluateHoldout(runningHoldout, userContext, decisionContext, options) + + // Should use custom bucketing ID and not error + assert.NotNil(t, reasons) +} + +func TestEveryoneElseRuleAudienceFails(t *testing.T) { + rolloutService := NewRolloutService("") + mockConfig := new(mockProjectConfig) + userContext := entities.UserContext{ID: "test_user"} + options := &decide.Options{} + reasons := decide.NewDecisionReasons(options) + + mockConfig.On("GetHoldoutsForRule", testExp1118.ID).Return([]entities.Holdout{}) + mockConfig.On("GetAudienceMap").Return(map[string]entities.Audience{}) + + featureDecision := FeatureDecision{Source: Rollout} + decisionContext := FeatureDecisionContext{ + ProjectConfig: mockConfig, + Feature: &testFeatRollout3334, + } + + checkForForcedDecision := func(exp *entities.Experiment) *FeatureDecision { return nil } + evaluateConditionTree := func(exp *entities.Experiment, loggingKey string) bool { return false } // Audience fails + getExperimentDecisionContext := func(exp *entities.Experiment) ExperimentDecisionContext { + return ExperimentDecisionContext{Experiment: exp, ProjectConfig: mockConfig} + } + + decision, _, _ := rolloutService.evaluateEveryoneElseRule( + &testExp1118, + &featureDecision, + &testFeatRollout3334, + userContext, + decisionContext, + options, + reasons, + checkForForcedDecision, + evaluateConditionTree, + getExperimentDecisionContext, + ) + + // Should return original featureDecision when audience fails + assert.Nil(t, decision.Variation) + assert.Equal(t, Rollout, decision.Source) +} + +func (s *RolloutServiceTestSuite) TestGetDecisionWithLocalHoldout() { + // Test local holdout in targeted delivery rule returns holdout decision + customMock := &mockProjectConfigWithHoldouts{ + mockProjectConfig: s.mockConfig, + holdoutsForRule: map[string][]entities.Holdout{ + testExp1112.ID: { + { + ID: "local_holdout_1", + Key: "test_local_holdout", + Status: entities.HoldoutStatusRunning, + Variations: map[string]entities.Variation{ + "holdout_var": {ID: "holdout_var", Key: "holdout_variation"}, + }, + TrafficAllocation: []entities.Range{ + {EntityID: "holdout_var", EndOfRange: 10000}, // 100% traffic + }, + IncludedRules: []string{testExp1112.ID}, + }, + }, + }, + audienceMap: map[string]entities.Audience{}, + } + + decisionContext := FeatureDecisionContext{ + ProjectConfig: customMock, + Feature: &testFeatRollout3334, + } + + rolloutService := NewRolloutService("") + decision, _, err := rolloutService.GetDecision(decisionContext, s.testUserContext, s.options) + + s.NoError(err) + s.Equal(Holdout, decision.Source, "Decision source should be Holdout") + s.NotNil(decision.Variation, "Should have variation from local holdout") + s.Equal("holdout_variation", decision.Variation.Key) +} + func TestRolloutServiceTestSuite(t *testing.T) { suite.Run(t, new(RolloutServiceTestSuite)) } diff --git a/pkg/entities/experiment.go b/pkg/entities/experiment.go index 0de662b6..ce2a7538 100644 --- a/pkg/entities/experiment.go +++ b/pkg/entities/experiment.go @@ -90,4 +90,10 @@ type Holdout struct { Variations map[string]Variation // keyed by variation ID TrafficAllocation []Range AudienceConditionTree *TreeNode + IncludedRules []string // nil = global (all rules), non-nil = local (specific rules) +} + +// IsGlobal returns true if the holdout is global (applies to all rules) +func (h Holdout) IsGlobal() bool { + return h.IncludedRules == nil } diff --git a/pkg/entities/experiment_test.go b/pkg/entities/experiment_test.go new file mode 100644 index 00000000..0cc1280a --- /dev/null +++ b/pkg/entities/experiment_test.go @@ -0,0 +1,55 @@ +/**************************************************************************** + * Copyright 2026, 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 entities + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHoldout_IsGlobal(t *testing.T) { + t.Run("Returns true when IncludedRules is nil", func(t *testing.T) { + holdout := Holdout{ + ID: "holdout_1", + Key: "test_holdout", + IncludedRules: nil, + } + + assert.True(t, holdout.IsGlobal(), "Holdout with nil IncludedRules should be global") + }) + + t.Run("Returns false when IncludedRules is non-nil", func(t *testing.T) { + holdout := Holdout{ + ID: "holdout_2", + Key: "local_holdout", + IncludedRules: []string{"rule_1", "rule_2"}, + } + + assert.False(t, holdout.IsGlobal(), "Holdout with non-nil IncludedRules should be local") + }) + + t.Run("Returns false when IncludedRules is empty slice", func(t *testing.T) { + holdout := Holdout{ + ID: "holdout_3", + Key: "empty_rules_holdout", + IncludedRules: []string{}, + } + + assert.False(t, holdout.IsGlobal(), "Holdout with empty IncludedRules slice should be local") + }) +}