Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion pkg/client/client.go
Original file line number Diff line number Diff line change
@@ -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. *
Expand Down Expand Up @@ -251,6 +251,15 @@ func (o *OptimizelyClient) dispatchDecisionEvents(projectConfig config.ProjectCo
if o.sendHoldoutImpression(projectConfig, featureDecision, usrContext, key) {
eventSent = true
}
for _, hd := range featureDecision.LocalHoldoutDecisions {
if hd.Variation == nil {
continue
}
if hue, ok := event.CreateImpressionUserEvent(projectConfig, hd.Experiment, hd.Variation, usrContext, key, hd.Experiment.Key, decision.Holdout, hd.Variation.FeatureEnabled, nil); ok {
o.EventProcessor.ProcessEvent(hue)
eventSent = true
}
}
return eventSent
}

Expand Down
166 changes: 165 additions & 1 deletion pkg/client/client_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/****************************************************************************
* Copyright 2019-2020,2022-2025 Optimizely, Inc. and contributors *
* Copyright 2019-2020,2022-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. *
Expand Down Expand Up @@ -3271,3 +3271,167 @@ func TestClientTestSuiteTrackEvent(t *testing.T) {
func TestClientTestSuiteTrackNotification(t *testing.T) {
suite.Run(t, new(ClientTestSuiteTrackNotification))
}

type localHoldoutClientFixture struct {
client *OptimizelyClient
feature entities.Feature
eventProcessor *MockEventProcessor
}

func newLocalHoldoutClientFixture(featureDecision decision.FeatureDecision) localHoldoutClientFixture {
mockConfig := new(MockProjectConfig)
mockConfigManager := new(MockProjectConfigManager)
mockDecisionService := new(MockDecisionService)
mockEventProcessor := new(MockEventProcessor)

feature := makeTestFeatureWithExperiment("feature_1", makeTestExperiment("exp_1"))
mockConfig.On("GetFeatureByKey", feature.Key).Return(feature, nil)
mockConfigManager.On("GetConfig").Return(mockConfig, nil)
mockDecisionService.On("GetFeatureDecision", mock.Anything, mock.Anything, mock.Anything).Return(featureDecision, decide.NewDecisionReasons(nil), nil)
mockEventProcessor.On("ProcessEvent", mock.AnythingOfType("event.UserEvent"))

return localHoldoutClientFixture{
client: &OptimizelyClient{
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
EventProcessor: mockEventProcessor,
defaultDecideOptions: &decide.Options{},
logger: logging.GetLogger("", ""),
tracer: &MockTracer{},
},
feature: feature,
eventProcessor: mockEventProcessor,
}
}

func makeLocalHoldoutDecision(holdoutKey string) decision.FeatureDecision {
variation := makeTestVariation("holdout_off", false)
return decision.FeatureDecision{
Experiment: makeTestExperimentWithVariations(holdoutKey, []entities.Variation{variation}),
Variation: &variation,
Source: decision.Holdout,
}
}

func processedImpressions(ep *MockEventProcessor, ruleType string) []event.UserEvent {
var events []event.UserEvent
for _, call := range ep.Calls {
if call.Method != "ProcessEvent" {
continue
}
ue := call.Arguments.Get(0).(event.UserEvent)
if ue.Impression != nil && ue.Impression.Metadata.RuleType == ruleType {
events = append(events, ue)
}
}
return events
}

func TestDecideLocalHoldoutHitThenLaterRuleServes(t *testing.T) {
servedVariation := makeTestVariation("on", true)
servedExperiment := makeTestExperimentWithVariations("exp_2", []entities.Variation{servedVariation})
f := newLocalHoldoutClientFixture(decision.FeatureDecision{
Experiment: servedExperiment,
Variation: &servedVariation,
Source: decision.FeatureTest,
LocalHoldoutDecisions: []decision.FeatureDecision{makeLocalHoldoutDecision("local_holdout")},
})
userContext := newOptimizelyUserContext(f.client, "test_user_1", nil, nil, nil)

d := f.client.decide(&userContext, f.feature.Key, &decide.Options{})

assert.Equal(t, "on", d.VariationKey)
assert.Equal(t, "exp_2", d.RuleKey)
assert.True(t, d.Enabled)
holdoutEvents := processedImpressions(f.eventProcessor, decision.Holdout)
assert.Len(t, holdoutEvents, 1)
assert.Equal(t, "local_holdout", holdoutEvents[0].Impression.Metadata.RuleKey)
assert.Equal(t, f.feature.Key, holdoutEvents[0].Impression.Metadata.FlagKey)
assert.Len(t, processedImpressions(f.eventProcessor, decision.FeatureTest), 1)
}

func TestDecideLocalHoldoutHitsOnSeveralRulesSendSeveralEvents(t *testing.T) {
f := newLocalHoldoutClientFixture(decision.FeatureDecision{
Source: decision.Rollout,
LocalHoldoutDecisions: []decision.FeatureDecision{
makeLocalHoldoutDecision("local_holdout"),
makeLocalHoldoutDecision("local_holdout"),
},
})
userContext := newOptimizelyUserContext(f.client, "test_user_1", nil, nil, nil)

d := f.client.decide(&userContext, f.feature.Key, &decide.Options{})

assert.Equal(t, "", d.VariationKey)
assert.False(t, d.Enabled)
// MockProjectConfig has sendFlagDecisions off: holdout events still go out, no rollout event does.
assert.Len(t, processedImpressions(f.eventProcessor, decision.Holdout), 2)
assert.Len(t, f.eventProcessor.Calls, 2)
}

func TestDecideLocalHoldoutOnDeliveryRuleSendsEventWithSendFlagDecisionsOff(t *testing.T) {
servedVariation := makeTestVariation("on", true)
f := newLocalHoldoutClientFixture(decision.FeatureDecision{
Experiment: makeTestExperimentWithVariations("rollout_2", []entities.Variation{servedVariation}),
Variation: &servedVariation,
Source: decision.Rollout,
LocalHoldoutDecisions: []decision.FeatureDecision{makeLocalHoldoutDecision("delivery_holdout")},
})
userContext := newOptimizelyUserContext(f.client, "test_user_1", nil, nil, nil)

d := f.client.decide(&userContext, f.feature.Key, &decide.Options{})

assert.Equal(t, "rollout_2", d.RuleKey)
assert.Len(t, processedImpressions(f.eventProcessor, decision.Holdout), 1)
assert.Len(t, f.eventProcessor.Calls, 1)
}

func TestDecideDisableDecisionEventSuppressesLocalHoldoutEvents(t *testing.T) {
f := newLocalHoldoutClientFixture(decision.FeatureDecision{
Source: decision.Rollout,
LocalHoldoutDecisions: []decision.FeatureDecision{makeLocalHoldoutDecision("local_holdout")},
})
userContext := newOptimizelyUserContext(f.client, "test_user_1", nil, nil, nil)

f.client.decide(&userContext, f.feature.Key, &decide.Options{DisableDecisionEvent: true})

f.eventProcessor.AssertNotCalled(t, "ProcessEvent", mock.Anything)
}

func TestDecideDecisionErrorSuppressesLocalHoldoutEvents(t *testing.T) {
holdoutDecision := decision.FeatureDecision{
Source: decision.FeatureTest,
LocalHoldoutDecisions: []decision.FeatureDecision{makeLocalHoldoutDecision("local_holdout")},
}
f := newLocalHoldoutClientFixture(holdoutDecision)
cmabErrorMessage := "Failed to fetch CMAB data for experiment exp_1."
mockDecisionService := f.client.DecisionService.(*MockDecisionService)
mockDecisionService.ExpectedCalls = nil
mockDecisionService.On("GetFeatureDecision", mock.Anything, mock.Anything, mock.Anything).Return(holdoutDecision, decide.NewDecisionReasons(nil), errors.New(cmabErrorMessage))
userContext := newOptimizelyUserContext(f.client, "test_user_1", nil, nil, nil)

d := f.client.decide(&userContext, f.feature.Key, &decide.Options{})

assert.Equal(t, "", d.VariationKey)
assert.Equal(t, "", d.RuleKey)
assert.False(t, d.Enabled)
assert.Contains(t, d.Reasons, cmabErrorMessage)
f.eventProcessor.AssertNotCalled(t, "ProcessEvent", mock.Anything)
}

func TestIsFeatureEnabledSendsNoLocalHoldoutEvent(t *testing.T) {
servedVariation := makeTestVariation("on", true)
f := newLocalHoldoutClientFixture(decision.FeatureDecision{
Experiment: makeTestExperimentWithVariations("exp_2", []entities.Variation{servedVariation}),
Variation: &servedVariation,
Source: decision.FeatureTest,
LocalHoldoutDecisions: []decision.FeatureDecision{makeLocalHoldoutDecision("local_holdout")},
})

enabled, err := f.client.IsFeatureEnabled(f.feature.Key, entities.UserContext{ID: "test_user_1"})

assert.NoError(t, err)
assert.True(t, enabled)
assert.Empty(t, processedImpressions(f.eventProcessor, decision.Holdout))
assert.Len(t, processedImpressions(f.eventProcessor, decision.FeatureTest), 1)
}
12 changes: 9 additions & 3 deletions pkg/decision/composite_feature_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func (f CompositeFeatureService) GetDecision(decisionContext FeatureDecisionCont
}

var featureDecision FeatureDecision
var localHoldoutDecisions []FeatureDecision
var err error
for _, featureDecisionService := range f.featureServices {
var decisionReasons decide.DecisionReasons
Expand All @@ -77,6 +78,8 @@ func (f CompositeFeatureService) GetDecision(decisionContext FeatureDecisionCont
return FeatureDecision{}, reasons, err
}

localHoldoutDecisions = append(localHoldoutDecisions, featureDecision.LocalHoldoutDecisions...)
featureDecision.LocalHoldoutDecisions = localHoldoutDecisions
if featureDecision.Variation != nil {
return featureDecision, reasons, err
}
Expand All @@ -95,6 +98,7 @@ func (f CompositeFeatureService) getDecisionWithExcludedTD(holdoutDecision Featu

// Skip experiment evaluation entirely (A/B/MAB/CMAB are blocked by holdout).
// Evaluate rollout service only (targeted deliveries are excluded from holdout blocking).
var localHoldoutDecisions []FeatureDecision
if f.rolloutService != nil {
rolloutDecision, rolloutReasons, err := f.rolloutService.GetDecision(decisionContext, userContext, options)
reasons.Append(rolloutReasons)
Expand All @@ -108,15 +112,17 @@ func (f CompositeFeatureService) getDecisionWithExcludedTD(holdoutDecision Featu
rolloutDecision.HoldoutVariation = holdoutVar
return rolloutDecision, reasons, nil
}
localHoldoutDecisions = rolloutDecision.LocalHoldoutDecisions
}

emptyDecision := FeatureDecision{
// Match the rollout service's no-match behavior (see RolloutService.GetDecision),
// so a served impression under sendFlagDecisions carries ruleType "rollout" rather
// than a blank value.
Source: Rollout,
HoldoutExperiment: &holdoutExp,
HoldoutVariation: holdoutVar,
Source: Rollout,
HoldoutExperiment: &holdoutExp,
HoldoutVariation: holdoutVar,
LocalHoldoutDecisions: localHoldoutDecisions,
}
return emptyDecision, reasons, nil
}
44 changes: 39 additions & 5 deletions pkg/decision/composite_feature_service_test.go
Original file line number Diff line number Diff line change
@@ -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. *
Expand Down Expand Up @@ -350,9 +350,10 @@ func TestExcludeTDTrueAllowsTDRollout(t *testing.T) {

rolloutVar := entities.Variation{ID: "rollout_var", Key: "rollout_variation"}
rolloutDecision := FeatureDecision{
Variation: &rolloutVar,
Source: Rollout,
Experiment: entities.Experiment{ID: "rollout_1", Key: "rollout_rule"},
Variation: &rolloutVar,
Source: Rollout,
Experiment: entities.Experiment{ID: "rollout_1", Key: "rollout_rule"},
LocalHoldoutDecisions: []FeatureDecision{{Source: Holdout, Experiment: entities.Experiment{Key: "local_holdout"}}},
}

mockFeatureService := new(MockFeatureDecisionService)
Expand Down Expand Up @@ -385,6 +386,7 @@ func TestExcludeTDTrueAllowsTDRollout(t *testing.T) {
assert.NotNil(t, decision.HoldoutExperiment)
assert.NotNil(t, decision.HoldoutVariation)
assert.Equal(t, holdoutVar.ID, decision.HoldoutVariation.ID)
assert.Equal(t, rolloutDecision.LocalHoldoutDecisions, decision.LocalHoldoutDecisions)
mockFeatureService.AssertNotCalled(t, "GetDecision")
mockRolloutService.AssertExpectations(t)

Expand Down Expand Up @@ -432,7 +434,7 @@ func TestExcludeTDTrueNoDownstreamMatchReturnsEmpty(t *testing.T) {
options := &decide.Options{IncludeReasons: true}
decisionReasons := decide.NewDecisionReasons(options)

emptyDecision := FeatureDecision{}
emptyDecision := FeatureDecision{LocalHoldoutDecisions: []FeatureDecision{{Source: Holdout, Experiment: entities.Experiment{Key: "local_holdout"}}}}
mockRolloutService.On("GetDecision", decisionContext, userContext, options).Return(emptyDecision, decisionReasons, nil)

compositeFeatureService := &CompositeFeatureService{
Expand All @@ -452,6 +454,7 @@ func TestExcludeTDTrueNoDownstreamMatchReturnsEmpty(t *testing.T) {
assert.NotNil(t, decision.HoldoutExperiment)
assert.NotNil(t, decision.HoldoutVariation)
assert.Equal(t, holdoutVar.ID, decision.HoldoutVariation.ID)
assert.Equal(t, emptyDecision.LocalHoldoutDecisions, decision.LocalHoldoutDecisions)
mockFeatureService.AssertNotCalled(t, "GetDecision")
mockRolloutService.AssertExpectations(t)

Expand Down Expand Up @@ -509,6 +512,37 @@ func TestLocalHoldoutIgnoresExcludeTargetedDeliveries(t *testing.T) {
assert.Equal(t, Holdout, decision.Source)
}

func (s *CompositeFeatureServiceTestSuite) TestGetDecisionAccumulatesLocalHoldoutDecisions() {
testUserContext := entities.UserContext{ID: "test_user_1"}
expHoldout := FeatureDecision{Source: Holdout, Experiment: entities.Experiment{Key: "exp_holdout"}, Variation: &entities.Variation{Key: "hv"}}
rolloutHoldout := FeatureDecision{Source: Holdout, Experiment: entities.Experiment{Key: "rollout_holdout"}, Variation: &entities.Variation{Key: "hv"}}

s.mockFeatureService.On("GetDecision", s.testFeatureDecisionContext, testUserContext, s.options).Return(
FeatureDecision{LocalHoldoutDecisions: []FeatureDecision{expHoldout}}, s.reasons, nil)
s.mockFeatureService2.On("GetDecision", s.testFeatureDecisionContext, testUserContext, s.options).Return(
FeatureDecision{Source: Rollout, Variation: &testExp1113Var2223, LocalHoldoutDecisions: []FeatureDecision{rolloutHoldout}}, s.reasons, nil)

compositeFeatureService := &CompositeFeatureService{
featureServices: []FeatureService{s.mockFeatureService, s.mockFeatureService2},
logger: logging.GetLogger("sdkKey", "CompositeFeatureService"),
}
decision, _, err := compositeFeatureService.GetDecision(s.testFeatureDecisionContext, testUserContext, s.options)

s.NoError(err)
s.Equal(&testExp1113Var2223, decision.Variation)
s.Equal([]FeatureDecision{expHoldout, rolloutHoldout}, decision.LocalHoldoutDecisions)

// No rule serves: the final empty decision still carries every hit.
s.mockFeatureService2.ExpectedCalls = nil
s.mockFeatureService2.On("GetDecision", s.testFeatureDecisionContext, testUserContext, s.options).Return(
FeatureDecision{Source: Rollout, LocalHoldoutDecisions: []FeatureDecision{rolloutHoldout}}, s.reasons, nil)
decision, _, err = compositeFeatureService.GetDecision(s.testFeatureDecisionContext, testUserContext, s.options)

s.NoError(err)
s.Nil(decision.Variation)
s.Equal([]FeatureDecision{expHoldout, rolloutHoldout}, decision.LocalHoldoutDecisions)
}

func (s *CompositeFeatureServiceTestSuite) TestNewCompositeFeatureService() {
// Assert that the service is instantiated with the correct child services in the right order
compositeExperimentService := NewCompositeExperimentService("")
Expand Down
3 changes: 3 additions & 0 deletions pkg/decision/entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ type FeatureDecision struct {
// the client emit a holdout impression alongside the served rollout decision.
HoldoutExperiment *entities.Experiment
HoldoutVariation *entities.Variation
// LocalHoldoutDecisions holds local holdout hits for rules that were skipped; the decide
// path sends one holdout impression per entry.
LocalHoldoutDecisions []FeatureDecision
}

// ExperimentDecision contains the decision information about an experiment
Expand Down
Loading
Loading