diff --git a/pkg/client/client.go b/pkg/client/client.go index 61fc8174..4c9647db 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -101,16 +101,18 @@ const ( // OptimizelyClient is the entry point to the Optimizely SDK type OptimizelyClient struct { - ctx context.Context - ConfigManager config.ProjectConfigManager - DecisionService decision.Service - EventProcessor event.Processor - OdpManager odp.Manager - notificationCenter notification.Center - execGroup *utils.ExecGroup - logger logging.OptimizelyLogProducer - defaultDecideOptions *decide.Options - tracer tracing.Tracer + ctx context.Context + ConfigManager config.ProjectConfigManager + DecisionService decision.Service + DecisionServiceWithoutUPS decision.Service + UserProfileService decision.UserProfileService + EventProcessor event.Processor + OdpManager odp.Manager + notificationCenter notification.Center + execGroup *utils.ExecGroup + logger logging.OptimizelyLogProducer + defaultDecideOptions *decide.Options + tracer tracing.Tracer } // CreateUserContext creates a context of the user for which decision APIs will be called. @@ -130,7 +132,7 @@ func (o *OptimizelyClient) WithTraceContext(ctx context.Context) *OptimizelyClie return o } -func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string, options *decide.Options) OptimizelyDecision { +func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, userProfile *decision.UserProfile, key string, options *decide.Options) OptimizelyDecision { var err error defer func() { if r := recover(); r != nil { @@ -179,13 +181,6 @@ func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string, var featureDecision decision.FeatureDecision var reasons decide.DecisionReasons - // To avoid cyclo-complexity warning - findRegularDecision := func() { - // regular decision - featureDecision, reasons, err = o.DecisionService.GetFeatureDecision(decisionContext, usrContext, &allOptions) - decisionReasons.Append(reasons) - } - // check forced-decisions first // Passing empty rule-key because checking mapping with flagKey only if userContext.forcedDecisionService != nil { @@ -193,12 +188,14 @@ func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string, variation, reasons, err = userContext.forcedDecisionService.FindValidatedForcedDecision(projectConfig, decision.OptimizelyDecisionContext{FlagKey: key, RuleKey: ""}, &allOptions) decisionReasons.Append(reasons) if err != nil { - findRegularDecision() + featureDecision, reasons, err = o.findRegularDecision(decisionContext, usrContext, userProfile, &allOptions) + decisionReasons.Append(reasons) } else { featureDecision = decision.FeatureDecision{Decision: decision.Decision{Reason: pkgReasons.ForcedDecisionFound}, Variation: variation, Source: decision.FeatureTest} } } else { - findRegularDecision() + featureDecision, reasons, err = o.findRegularDecision(decisionContext, usrContext, userProfile, &allOptions) + decisionReasons.Append(reasons) } if err != nil { @@ -238,6 +235,77 @@ func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string, return NewOptimizelyDecision(variationKey, ruleKey, key, flagEnabled, optimizelyJSON, userContext, reasonsToReport) } +func (o *OptimizelyClient) findRegularDecision(decisionContext decision.FeatureDecisionContext, userContext entities.UserContext, userProfile *decision.UserProfile, options *decide.Options) (decision.FeatureDecision, decide.DecisionReasons, error) { + if o.UserProfileService == nil || options.IgnoreUserProfileService { + return o.DecisionService.GetFeatureDecision(decisionContext, userContext, options) + } + + featureDecision := decision.FeatureDecision{} + if decisionContext.Feature != nil { + for _, featureExperiment := range decisionContext.Feature.FeatureExperiments { + decisionKey := decision.NewUserDecisionKey(featureExperiment.ID) + + fx := featureExperiment + experimentDecisionContext := decision.ExperimentDecisionContext{ + ProjectConfig: decisionContext.ProjectConfig, + Experiment: &fx, + } + + expDecision, reasons, err := decision.NewExperimentWhitelistService().GetDecision(experimentDecisionContext, userContext, options) + if err == nil && expDecision.Variation != nil { + featureDecision.Variation = expDecision.Variation + featureDecision.Experiment = featureExperiment + featureDecision.Source = decision.FeatureTest + return featureDecision, reasons, nil + } + + if decisionContext.ForcedDecisionService != nil { + forcedDecision, _reasons, err := decisionContext.ForcedDecisionService.FindValidatedForcedDecision(decisionContext.ProjectConfig, decision.OptimizelyDecisionContext{FlagKey: decisionContext.Feature.Key, RuleKey: featureExperiment.Key}, options) + reasons.Append(_reasons) + if err == nil { + return decision.FeatureDecision{ + Experiment: featureExperiment, + Variation: forcedDecision, + Source: decision.FeatureTest, + }, reasons, nil + } + } + + if savedVariationID, ok := userProfile.ExperimentBucketMap[decisionKey]; ok { + if variation, ok := featureExperiment.Variations[savedVariationID]; ok { + featureDecision.Variation = &variation + infoMessage := reasons.AddInfo(`User "%s" was previously bucketed into variation "%s" of experiment "%s".`, userContext.ID, variation.Key, featureExperiment.Key) + o.logger.Debug(infoMessage) + } else { + warningMessage := reasons.AddInfo(`User "%s" was previously bucketed into variation with ID "%s" for experiment "%s", but no matching variation was found.`, userContext.ID, savedVariationID, featureExperiment.Key) + o.logger.Warning(warningMessage) + } + } + + if featureDecision.Variation != nil { + featureDecision.Experiment = featureExperiment + featureDecision.Source = decision.FeatureTest + return featureDecision, reasons, nil + } + + } + } + + // if no saved decision found, bucket the user + featureDecision, reason, err := o.DecisionServiceWithoutUPS.GetFeatureDecision(decisionContext, userContext, options) + if err != nil { + return featureDecision, reason, err + } + if featureDecision.Variation != nil { + decisionKey := decision.NewUserDecisionKey(featureDecision.Experiment.ID) + if userProfile.ExperimentBucketMap == nil { + userProfile.ExperimentBucketMap = make(map[decision.UserDecisionKey]string) + } + userProfile.ExperimentBucketMap[decisionKey] = featureDecision.Variation.ID + } + return featureDecision, reason, nil +} + func (o *OptimizelyClient) decideForKeys(userContext OptimizelyUserContext, keys []string, options *decide.Options) map[string]OptimizelyDecision { var err error defer func() { @@ -269,11 +337,31 @@ func (o *OptimizelyClient) decideForKeys(userContext OptimizelyUserContext, keys return decisionMap } - enabledFlagsOnly := o.getAllOptions(options).EnabledFlagsOnly + allOptions := o.getAllOptions(options) + + var userProfile *decision.UserProfile + userProfileBucketLen := 0 + ignoreUserProfileSvc := o.UserProfileService == nil || allOptions.IgnoreUserProfileService + if !ignoreUserProfileSvc { + up := o.UserProfileService.Lookup(userContext.GetUserID()) + if up.ID == "" { + up = decision.UserProfile{ + ID: userContext.GetUserID(), + ExperimentBucketMap: make(map[decision.UserDecisionKey]string), + } + } + userProfile = &up + userProfileBucketLen = len(userProfile.ExperimentBucketMap) + } + for _, key := range keys { - optimizelyDecision := o.decide(userContext, key, options) - if !enabledFlagsOnly || optimizelyDecision.Enabled { - decisionMap[key] = optimizelyDecision + optimizelyDecision := o.decide(userContext, userProfile, key, options) + decisionMap[key] = optimizelyDecision + } + + if !ignoreUserProfileSvc { + if userProfile != nil && len(userProfile.ExperimentBucketMap) > userProfileBucketLen { + o.UserProfileService.Save(*userProfile) } } diff --git a/pkg/client/factory.go b/pkg/client/factory.go index fd9d62ed..b5090f0f 100644 --- a/pkg/client/factory.go +++ b/pkg/client/factory.go @@ -145,6 +145,17 @@ func (f *OptimizelyFactory) Client(clientOptions ...OptionFunc) (*OptimizelyClie appClient.EventProcessor = event.NewBatchEventProcessor(eventProcessorOptions...) } + if f.userProfileService != nil { + appClient.UserProfileService = f.userProfileService + var experimentServiceOptions []decision.CESOptionFunc + if f.overrideStore != nil { + experimentServiceOptions = append(experimentServiceOptions, decision.WithOverrideStore(f.overrideStore)) + } + compositeExperimentServiceWithoutUPS := decision.NewCompositeExperimentService(f.SDKKey, experimentServiceOptions...) + compositeServiceWithoutUPS := decision.NewCompositeService(f.SDKKey, decision.WithCompositeExperimentService(compositeExperimentServiceWithoutUPS)) + appClient.DecisionServiceWithoutUPS = compositeServiceWithoutUPS + } + if f.decisionService != nil { appClient.DecisionService = f.decisionService } else { diff --git a/pkg/client/optimizely_user_context.go b/pkg/client/optimizely_user_context.go index 1b592247..215cfd77 100644 --- a/pkg/client/optimizely_user_context.go +++ b/pkg/client/optimizely_user_context.go @@ -130,21 +130,31 @@ func (o *OptimizelyUserContext) IsQualifiedFor(segment string) bool { func (o *OptimizelyUserContext) Decide(key string, options []decide.OptimizelyDecideOptions) OptimizelyDecision { // use a copy of the user context so that any changes to the original context are not reflected inside the decision userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService(), o.GetQualifiedSegments()) - return o.optimizely.decide(userContextCopy, key, convertDecideOptions(options)) + decision, found := o.optimizely.decideForKeys(userContextCopy, []string{key}, convertDecideOptions(options))[key] + if !found { + return NewErrorDecision(key, *o, decide.GetDecideError(decide.SDKNotReady)) + } + return decision } // 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 { // use a copy of the user context so that any changes to the original context are not reflected inside the decision userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService(), o.GetQualifiedSegments()) - return o.optimizely.decideAll(userContextCopy, convertDecideOptions(options)) + decideOptions := convertDecideOptions(options) + decisionMap := o.optimizely.decideAll(userContextCopy, decideOptions) + + return filteredDecision(decisionMap, o.optimizely.getAllOptions(decideOptions).EnabledFlagsOnly) } // 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 { // use a copy of the user context so that any changes to the original context are not reflected inside the decision userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService(), o.GetQualifiedSegments()) - return o.optimizely.decideForKeys(userContextCopy, keys, convertDecideOptions(options)) + decideOptions := convertDecideOptions(options) + decisionMap := o.optimizely.decideForKeys(userContextCopy, keys, decideOptions) + + return filteredDecision(decisionMap, o.optimizely.getAllOptions(decideOptions).EnabledFlagsOnly) } // TrackEvent generates a conversion event with the given event key if it exists and queues it up to be sent to the Optimizely @@ -208,3 +218,13 @@ func copyQualifiedSegments(qualifiedSegments []string) (qualifiedSegmentsCopy [] copy(qualifiedSegmentsCopy, qualifiedSegments) return } + +func filteredDecision(decisionMap map[string]OptimizelyDecision, enabledFlagsOnly bool) map[string]OptimizelyDecision { + filteredDecision := make(map[string]OptimizelyDecision) + for key, decision := range decisionMap { + if !enabledFlagsOnly || decision.Enabled { + filteredDecision[key] = decision + } + } + return filteredDecision +}