...

Source file src/github.com/growthbook/growthbook-golang/growthbook.go

Documentation: github.com/growthbook/growthbook-golang

     1  // Package growthbook provides a Go SDK for the GrowthBook A/B testing
     2  // and feature flagging service.
     3  package growthbook
     4  
     5  import (
     6  	"errors"
     7  	"fmt"
     8  	"net/url"
     9  	"reflect"
    10  	"regexp"
    11  	"runtime"
    12  	"strconv"
    13  	"strings"
    14  	"sync"
    15  	"time"
    16  )
    17  
    18  type subscriptionID uint
    19  
    20  // Assignment is used for recording subscription information.
    21  type Assignment struct {
    22  	Experiment *Experiment
    23  	Result     *Result
    24  }
    25  
    26  // GrowthBook is the main export of the SDK.
    27  type GrowthBook struct {
    28  	inner *growthBookData
    29  }
    30  
    31  type growthBookData struct {
    32  	sync.RWMutex
    33  	context             *Context
    34  	forcedFeatureValues map[string]interface{}
    35  	attributeOverrides  Attributes
    36  	trackedFeatures     map[string]interface{}
    37  	trackedExperiments  map[string]bool
    38  	nextSubscriptionID  subscriptionID
    39  	subscriptions       map[subscriptionID]ExperimentCallback
    40  	assigned            map[string]*Assignment
    41  	ready               bool
    42  }
    43  
    44  // New creates a new GrowthBook instance.
    45  func New(context *Context) *GrowthBook {
    46  	// There is a little complexity here. The feature auto-refresh code
    47  	// needs to keep track of information about GrowthBook instances to
    48  	// update them with new feature information, but we want GrowthBook
    49  	// instances to be garbage collected normally. Go doesn't have weak
    50  	// references (if it did, the auto-refresh code could keep weak
    51  	// references to GrowthBook instances), so we have to do something
    52  	// sneaky.
    53  	//
    54  	// The main GrowthBook instance is a wrapper around a growthBookData
    55  	// instance. The auto-refresh code stores references only to the
    56  	// inner growthBookData data structures. The main outer GrowthBook
    57  	// data structure has a finalizer that handles removing the inner
    58  	// growthBookData instance from the auto-refresh code.
    59  	//
    60  	// This means that the lifecycle of the relevant objects is:
    61  	//
    62  	//  1. growthBookData value created (here in New).
    63  	//  2. GrowthBook value created, wrapping growthBookData value (here
    64  	//     in New).
    65  	//  3. GrowthBook instance is used...
    66  	//  4. GrowthBook instance is subscribed to auto-refresh updates.
    67  	//     This adds a reference to the inner growthBookData value to
    68  	//     the auto-refresh code's data structures.
    69  	//
    70  	//  ... more use of GrowthBook instance ...
    71  	//
    72  	//  5. GrowthBook instance is unreferenced, so eligible for GC.
    73  	//  6. Garbage collection.
    74  	//  7. GrowthBook instance is collected by GC and its finalizer is
    75  	//     run, which calls repoUnsubscribe. This removes the inner
    76  	//     growthBookData instance from the auto-refresh code's data
    77  	//     structures. (The finalizer resurrects the GrowthBook
    78  	//     instance, so another cycle of GC is needed to collect it for
    79  	//     real.)
    80  	//  8. Both the main GrowthBook instance and the inner
    81  	//     growthBookData instance are now unreferenced, so eligible for
    82  	//     GC.
    83  	//  9. Garbage collection.
    84  	// 10. Main GrowthBook instance and inner growthBookData instance
    85  	//     are collected.
    86  	//
    87  	// The end result of all this is that the auto-refresh code can keep
    88  	// hold of the data that it needs to update instances with new
    89  	// features, but those resources are freed correctly when users drop
    90  	// references to instances of the public GrowthBook structure.
    91  
    92  	if context == nil {
    93  		context = NewContext()
    94  	}
    95  	inner := &growthBookData{
    96  		context:             context,
    97  		forcedFeatureValues: nil,
    98  		attributeOverrides:  nil,
    99  		trackedFeatures:     make(map[string]interface{}),
   100  		trackedExperiments:  make(map[string]bool),
   101  		nextSubscriptionID:  1,
   102  		subscriptions:       make(map[subscriptionID]ExperimentCallback),
   103  		assigned:            make(map[string]*Assignment),
   104  	}
   105  	gb := &GrowthBook{inner}
   106  	runtime.SetFinalizer(gb, func(gb *GrowthBook) { repoUnsubscribe(gb) })
   107  	if context.ClientKey != "" {
   108  		go gb.refresh(nil, true, false)
   109  	}
   110  	return gb
   111  }
   112  
   113  // Ready returns the ready flag, which indicates that features have
   114  // been loaded.
   115  func (gb *GrowthBook) Ready() bool {
   116  	gb.inner.RLock()
   117  	defer gb.inner.RUnlock()
   118  
   119  	return gb.inner.ready
   120  }
   121  
   122  // WithForcedFeatures updates the current forced feature values.
   123  func (gb *GrowthBook) WithForcedFeatures(values map[string]interface{}) *GrowthBook {
   124  	gb.inner.Lock()
   125  	defer gb.inner.Unlock()
   126  
   127  	gb.inner.forcedFeatureValues = values
   128  	return gb
   129  }
   130  
   131  // WithAttributeOverrides updates the current attribute overrides.
   132  func (gb *GrowthBook) WithAttributeOverrides(overrides Attributes) *GrowthBook {
   133  	gb.inner.Lock()
   134  	defer gb.inner.Unlock()
   135  
   136  	gb.inner.attributeOverrides = overrides
   137  	return gb
   138  }
   139  
   140  // WithEnabled sets the enabled flag in a GrowthBook's context.
   141  func (gb *GrowthBook) WithEnabled(enabled bool) *GrowthBook {
   142  	gb.inner.Lock()
   143  	defer gb.inner.Unlock()
   144  
   145  	gb.inner.context.Enabled = enabled
   146  	return gb
   147  }
   148  
   149  // WithAttributes updates the attributes in a GrowthBook's context.
   150  func (gb *GrowthBook) WithAttributes(attrs Attributes) *GrowthBook {
   151  	gb.inner.Lock()
   152  	defer gb.inner.Unlock()
   153  
   154  	gb.inner.context.Attributes = attrs
   155  	return gb
   156  }
   157  
   158  // Attributes returns the attributes in a GrowthBook's context,
   159  // possibly modified by overrides.
   160  func (gb *GrowthBook) Attributes() Attributes {
   161  	gb.inner.RLock()
   162  	defer gb.inner.RUnlock()
   163  
   164  	attrs := Attributes{}
   165  	for id, v := range gb.inner.context.Attributes {
   166  		attrs[id] = v
   167  	}
   168  	if gb.inner.attributeOverrides != nil {
   169  		for id, v := range gb.inner.attributeOverrides {
   170  			attrs[id] = v
   171  		}
   172  	}
   173  	return attrs
   174  }
   175  
   176  // WithURL sets the URL in a GrowthBook's context.
   177  func (gb *GrowthBook) WithURL(url *url.URL) *GrowthBook {
   178  	gb.inner.Lock()
   179  	defer gb.inner.Unlock()
   180  
   181  	gb.inner.context.URL = url
   182  	return gb
   183  }
   184  
   185  // WithFeatures updates the features in a GrowthBook's context.
   186  func (gb *GrowthBook) WithFeatures(features FeatureMap) *GrowthBook {
   187  	gb.inner.withFeatures(features)
   188  	return gb
   189  }
   190  
   191  func (inner *growthBookData) withFeatures(features FeatureMap) {
   192  	inner.Lock()
   193  	defer inner.Unlock()
   194  
   195  	inner.context.Features = features
   196  	inner.ready = true
   197  }
   198  
   199  // Features returns the features in a GrowthBook's context.
   200  func (gb *GrowthBook) Features() FeatureMap {
   201  	return gb.inner.features()
   202  }
   203  
   204  func (inner *growthBookData) features() FeatureMap {
   205  	inner.RLock()
   206  	defer inner.RUnlock()
   207  
   208  	return inner.context.Features
   209  }
   210  
   211  // WithEncryptedFeatures updates the features in a GrowthBook's
   212  // context from encrypted data.
   213  func (gb *GrowthBook) WithEncryptedFeatures(encrypted string, key string) (*GrowthBook, error) {
   214  	err := gb.inner.withEncryptedFeatures(encrypted, key)
   215  	return gb, err
   216  }
   217  
   218  func (inner *growthBookData) withEncryptedFeatures(encrypted string, key string) error {
   219  	inner.Lock()
   220  	defer inner.Unlock()
   221  
   222  	if key == "" {
   223  		key = inner.context.DecryptionKey
   224  	}
   225  	featuresJson, err := decrypt(encrypted, key)
   226  
   227  	var features FeatureMap
   228  	if err == nil {
   229  		features = ParseFeatureMap([]byte(featuresJson))
   230  		if features != nil {
   231  			inner.context.Features = ParseFeatureMap([]byte(featuresJson))
   232  		}
   233  	}
   234  	if err != nil || features == nil {
   235  		err = errors.New("failed to decode encrypted features")
   236  	}
   237  	return err
   238  }
   239  
   240  // WithForcedVariations sets the forced variations in a GrowthBook's
   241  // context.
   242  func (gb *GrowthBook) WithForcedVariations(forcedVariations ForcedVariationsMap) *GrowthBook {
   243  	gb.inner.Lock()
   244  	defer gb.inner.Unlock()
   245  
   246  	gb.inner.context.ForcedVariations = forcedVariations
   247  	return gb
   248  }
   249  
   250  func (gb *GrowthBook) ForceVariation(key string, variation int) {
   251  	gb.inner.RLock()
   252  	defer gb.inner.RUnlock()
   253  
   254  	gb.inner.context.ForceVariation(key, variation)
   255  }
   256  
   257  func (gb *GrowthBook) UnforceVariation(key string) {
   258  	gb.inner.RLock()
   259  	defer gb.inner.RUnlock()
   260  
   261  	gb.inner.context.UnforceVariation(key)
   262  }
   263  
   264  // WithQAMode can be used to enable or disable the QA mode for a
   265  // context.
   266  func (gb *GrowthBook) WithQAMode(qaMode bool) *GrowthBook {
   267  	gb.inner.Lock()
   268  	defer gb.inner.Unlock()
   269  
   270  	gb.inner.context.QAMode = qaMode
   271  	return gb
   272  }
   273  
   274  // WithDevMode can be used to enable or disable the development mode
   275  // for a context.
   276  func (gb *GrowthBook) WithDevMode(devMode bool) *GrowthBook {
   277  	gb.inner.Lock()
   278  	defer gb.inner.Unlock()
   279  
   280  	gb.inner.context.DevMode = devMode
   281  	return gb
   282  }
   283  
   284  // WithTrackingCallback is used to set a tracking callback for a
   285  // context.
   286  func (gb *GrowthBook) WithTrackingCallback(callback ExperimentCallback) *GrowthBook {
   287  	gb.inner.Lock()
   288  	defer gb.inner.Unlock()
   289  
   290  	gb.inner.context.TrackingCallback = callback
   291  	return gb
   292  }
   293  
   294  // WithFeatureUsageCallback is used to set a feature usage callback
   295  // for a context.
   296  func (gb *GrowthBook) WithFeatureUsageCallback(callback FeatureUsageCallback) *GrowthBook {
   297  	gb.inner.Lock()
   298  	defer gb.inner.Unlock()
   299  
   300  	gb.inner.context.OnFeatureUsage = callback
   301  	return gb
   302  }
   303  
   304  // WithGroups sets the groups map of a context.
   305  func (gb *GrowthBook) WithGroups(groups map[string]bool) *GrowthBook {
   306  	gb.inner.Lock()
   307  	defer gb.inner.Unlock()
   308  
   309  	gb.inner.context.Groups = groups
   310  	return gb
   311  }
   312  
   313  // WithAPIHost sets the API host of a context.
   314  func (gb *GrowthBook) WithAPIHost(host string) *GrowthBook {
   315  	gb.inner.Lock()
   316  	defer gb.inner.Unlock()
   317  
   318  	gb.inner.context.APIHost = host
   319  	return gb
   320  }
   321  
   322  // WithClientKey sets the API client key of a context.
   323  func (gb *GrowthBook) WithClientKey(key string) *GrowthBook {
   324  	gb.inner.Lock()
   325  	defer gb.inner.Unlock()
   326  
   327  	gb.inner.context.ClientKey = key
   328  	return gb
   329  }
   330  
   331  // WithDecryptionKey sets the decryption key of a context.
   332  func (gb *GrowthBook) WithDecryptionKey(key string) *GrowthBook {
   333  	gb.inner.Lock()
   334  	defer gb.inner.Unlock()
   335  
   336  	gb.inner.context.DecryptionKey = key
   337  	return gb
   338  }
   339  
   340  // GetValueWithDefault extracts a value from a FeatureResult with a
   341  // default.
   342  func (fr *FeatureResult) GetValueWithDefault(def FeatureValue) FeatureValue {
   343  	if fr.Value == nil {
   344  		return def
   345  	}
   346  	return fr.Value
   347  }
   348  
   349  // IsOn determines whether a feature is on.
   350  func (gb *GrowthBook) IsOn(key string) bool {
   351  	return gb.EvalFeature(key).On
   352  }
   353  
   354  // IsOff determines whether a feature is off.
   355  func (gb *GrowthBook) IsOff(key string) bool {
   356  	return gb.EvalFeature(key).Off
   357  }
   358  
   359  // GetFeatureValue returns the result for a feature identified by a
   360  // string feature key, with an explicit default.
   361  func (gb *GrowthBook) GetFeatureValue(key string, defaultValue interface{}) interface{} {
   362  	featureValue := gb.EvalFeature(key).Value
   363  	if featureValue != nil {
   364  		return featureValue
   365  	}
   366  	return defaultValue
   367  }
   368  
   369  // Deprecated: Use EvalFeature instead. Feature returns the result for
   370  // a feature identified by a string feature key.
   371  func (gb *GrowthBook) Feature(key string) *FeatureResult {
   372  	return gb.EvalFeature(key)
   373  }
   374  
   375  // EvalFeature returns the result for a feature identified by a string
   376  // feature key.
   377  func (gb *GrowthBook) EvalFeature(id string) *FeatureResult {
   378  	gb.inner.RLock()
   379  	defer gb.inner.RUnlock()
   380  
   381  	// Global override.
   382  	if gb.inner.forcedFeatureValues != nil {
   383  		if override, ok := gb.inner.forcedFeatureValues[id]; ok {
   384  			logInfo("Global override", id, override)
   385  			return gb.getFeatureResult(id, override, OverrideResultSource, "", nil, nil)
   386  		}
   387  	}
   388  
   389  	// Handle unknown features.
   390  	feature, ok := gb.inner.context.Features[id]
   391  	if !ok {
   392  		logWarn("Unknown feature", id)
   393  		return gb.getFeatureResult(id, nil, UnknownResultSource, "", nil, nil)
   394  	}
   395  
   396  	// Loop through the feature rules.
   397  	for _, rule := range feature.Rules {
   398  		// If the rule has a condition and the condition does not pass,
   399  		// skip this rule.
   400  		if rule.Condition != nil && !rule.Condition.Eval(gb.Attributes()) {
   401  			logInfo("Skip rule because of condition", id, rule)
   402  			continue
   403  		}
   404  
   405  		// Apply any filters for who is included (e.g. namespaces).
   406  		if rule.Filters != nil && gb.isFilteredOut(rule.Filters) {
   407  			logInfo("Skip rule because of filters", id, rule)
   408  			continue
   409  		}
   410  
   411  		// Feature value is being forced.
   412  		if rule.Force != nil {
   413  			// If this is a percentage rollout, skip if not included.
   414  			seed := id
   415  			if rule.Seed != "" {
   416  				seed = rule.Seed
   417  			}
   418  			if !gb.isIncludedInRollout(
   419  				seed,
   420  				rule.HashAttribute,
   421  				rule.Range,
   422  				rule.Coverage,
   423  				rule.HashVersion,
   424  			) {
   425  				logInfo("Skip rule because user not included in rollout", id, rule)
   426  				continue
   427  			}
   428  
   429  			// Return forced feature result.
   430  			logInfo("Force value from rule", id, rule)
   431  			return gb.getFeatureResult(id, rule.Force, ForceResultSource, rule.ID, nil, nil)
   432  		}
   433  
   434  		if rule.Variations == nil {
   435  			logWarn("Skip invalid rule", id, rule)
   436  			continue
   437  		}
   438  
   439  		// Otherwise, convert the rule to an Experiment object, copying
   440  		// values from the rule as necessary.
   441  		exp := experimentFromFeatureRule(id, rule)
   442  
   443  		// Run the experiment.
   444  		result := gb.doRun(exp, id)
   445  		gb.fireSubscriptions(exp, result)
   446  
   447  		// Only return a value if the user is part of the experiment.
   448  		// gb.fireSubscriptions(experiment, result)
   449  		if result.InExperiment && !result.Passthrough {
   450  			return gb.getFeatureResult(id, result.Value, ExperimentResultSource, rule.ID, exp, result)
   451  		}
   452  	}
   453  
   454  	// Fall back to using the default value.
   455  	logInfo("Use default value", id, feature.DefaultValue)
   456  	return gb.getFeatureResult(id, feature.DefaultValue, DefaultValueResultSource, "", nil, nil)
   457  }
   458  
   459  // Run an experiment. (Uses doRun to make wrapping for subscriptions
   460  // simple.)
   461  func (gb *GrowthBook) Run(exp *Experiment) *Result {
   462  	gb.inner.RLock()
   463  	defer gb.inner.RUnlock()
   464  
   465  	result := gb.doRun(exp, "")
   466  	gb.fireSubscriptions(exp, result)
   467  	return result
   468  }
   469  
   470  // Subscribe adds a callback that is called every time GrowthBook.Run
   471  // is called. This is different from the tracking callback since it
   472  // also fires when a user is not included in an experiment.
   473  func (gb *GrowthBook) Subscribe(callback ExperimentCallback) func() {
   474  	gb.inner.Lock()
   475  	defer gb.inner.Unlock()
   476  
   477  	id := gb.inner.nextSubscriptionID
   478  	gb.inner.subscriptions[id] = callback
   479  	gb.inner.nextSubscriptionID++
   480  	return func() {
   481  		delete(gb.inner.subscriptions, id)
   482  	}
   483  }
   484  
   485  // GetAllResults returns a map containing all the latest results from
   486  // all experiments that have been run, indexed by the experiment key.
   487  func (gb *GrowthBook) GetAllResults() map[string]*Assignment {
   488  	gb.inner.RLock()
   489  	defer gb.inner.RUnlock()
   490  
   491  	return gb.inner.assigned
   492  }
   493  
   494  // ClearSavedResults clears out any experiment results saved within a
   495  // GrowthBook instance (used for deciding whether to send data to
   496  // subscriptions).
   497  func (gb *GrowthBook) ClearSavedResults() {
   498  	gb.inner.Lock()
   499  	defer gb.inner.Unlock()
   500  
   501  	gb.inner.assigned = make(map[string]*Assignment)
   502  }
   503  
   504  // ClearTrackingData clears out records of calls to the experiment
   505  // tracking callback.
   506  func (gb *GrowthBook) ClearTrackingData() {
   507  	gb.inner.Lock()
   508  	defer gb.inner.Unlock()
   509  
   510  	gb.inner.trackedExperiments = make(map[string]bool)
   511  }
   512  
   513  // GetAPIInfo gets the hostname and client key for GrowthBook API
   514  // access.
   515  func (gb *GrowthBook) GetAPIInfo() (string, string) {
   516  	gb.inner.RLock()
   517  	defer gb.inner.RUnlock()
   518  
   519  	apiHost := gb.inner.context.APIHost
   520  	if apiHost == "" {
   521  		apiHost = "https://cdn.growthbook.io"
   522  	}
   523  
   524  	return strings.TrimRight(apiHost, "/"), gb.inner.context.ClientKey
   525  }
   526  
   527  type FeatureRepoOptions struct {
   528  	AutoRefresh bool
   529  	Timeout     time.Duration
   530  	SkipCache   bool
   531  }
   532  
   533  func (gb *GrowthBook) LoadFeatures(options *FeatureRepoOptions) {
   534  	gb.refresh(options, true, true)
   535  	if options != nil && options.AutoRefresh {
   536  		repoSubscribe(gb)
   537  	}
   538  }
   539  
   540  func (gb *GrowthBook) LatestFeatureUpdate() *time.Time {
   541  	return repoLatestUpdate(gb)
   542  }
   543  
   544  func (gb *GrowthBook) RefreshFeatures(options *FeatureRepoOptions) {
   545  	gb.refresh(options, false, true)
   546  }
   547  
   548  //-- PRIVATE FUNCTIONS START HERE ----------------------------------------------
   549  
   550  func (gb *GrowthBook) refresh(
   551  	options *FeatureRepoOptions, allowStale bool, updateInstance bool) {
   552  
   553  	if gb.inner.context.ClientKey == "" {
   554  		logError("Missing clientKey")
   555  		return
   556  	}
   557  	var timeout time.Duration
   558  	skipCache := gb.inner.context.DevMode
   559  	if options != nil {
   560  		timeout = options.Timeout
   561  		skipCache = skipCache || options.SkipCache
   562  	}
   563  	configureCacheStaleTTL(gb.inner.context.CacheTTL)
   564  	repoRefreshFeatures(gb, timeout, skipCache, allowStale, updateInstance)
   565  }
   566  
   567  func (gb *GrowthBook) trackFeatureUsage(key string, res *FeatureResult) {
   568  	// Don't track feature usage that was forced via an override.
   569  	if res.Source == OverrideResultSource {
   570  		return
   571  	}
   572  
   573  	// Only track a feature once, unless the assigned value changed.
   574  	if saved, ok := gb.inner.trackedFeatures[key]; ok && reflect.DeepEqual(saved, res.Value) {
   575  		return
   576  	}
   577  	gb.inner.trackedFeatures[key] = res.Value
   578  
   579  	// Fire user-supplied callback
   580  	if gb.inner.context.OnFeatureUsage != nil {
   581  		gb.inner.context.OnFeatureUsage(key, res)
   582  	}
   583  }
   584  
   585  func (gb *GrowthBook) getFeatureResult(
   586  	key string,
   587  	value FeatureValue,
   588  	source FeatureResultSource,
   589  	ruleID string,
   590  	experiment *Experiment,
   591  	result *Result) *FeatureResult {
   592  	on := truthy(value)
   593  	off := !on
   594  	retval := FeatureResult{
   595  		Value:            value,
   596  		On:               on,
   597  		Off:              off,
   598  		Source:           source,
   599  		RuleID:           ruleID,
   600  		Experiment:       experiment,
   601  		ExperimentResult: result,
   602  	}
   603  
   604  	gb.trackFeatureUsage(key, &retval)
   605  
   606  	return &retval
   607  }
   608  
   609  func (gb *GrowthBook) getResult(
   610  	exp *Experiment, variationIndex int,
   611  	hashUsed bool, featureID string, bucket *float64) *Result {
   612  	inExperiment := true
   613  
   614  	// If assigned variation is not valid, use the baseline and mark the
   615  	// user as not in the experiment
   616  	if variationIndex < 0 || variationIndex >= len(exp.Variations) {
   617  		variationIndex = 0
   618  		inExperiment = false
   619  	}
   620  
   621  	// Get the hashAttribute and hashValue
   622  	hashAttribute, hashString := gb.getHashAttribute(exp.HashAttribute)
   623  
   624  	var meta *VariationMeta
   625  	if exp.Meta != nil {
   626  		if variationIndex < len(exp.Meta) {
   627  			meta = &exp.Meta[variationIndex]
   628  		}
   629  	}
   630  
   631  	// Return
   632  	var value FeatureValue
   633  	if variationIndex < len(exp.Variations) {
   634  		value = exp.Variations[variationIndex]
   635  	}
   636  	key := fmt.Sprint(variationIndex)
   637  	name := ""
   638  	passthrough := false
   639  	if meta != nil {
   640  		if meta.Key != "" {
   641  			key = meta.Key
   642  		}
   643  		if meta.Name != "" {
   644  			name = meta.Name
   645  		}
   646  		passthrough = meta.Passthrough
   647  	}
   648  	return &Result{
   649  		Key:           key,
   650  		FeatureID:     featureID,
   651  		InExperiment:  inExperiment,
   652  		HashUsed:      hashUsed,
   653  		VariationID:   variationIndex,
   654  		Value:         value,
   655  		HashAttribute: hashAttribute,
   656  		HashValue:     hashString,
   657  		Bucket:        bucket,
   658  		Name:          name,
   659  		Passthrough:   passthrough,
   660  	}
   661  }
   662  
   663  func (gb *GrowthBook) fireSubscriptions(exp *Experiment, result *Result) {
   664  	// Determine whether the result changed from the last stored result
   665  	// for the experiment.
   666  	changed := false
   667  	storedResult, exists := gb.inner.assigned[exp.Key]
   668  	if exists {
   669  		if storedResult.Result.InExperiment != result.InExperiment ||
   670  			storedResult.Result.VariationID != result.VariationID {
   671  			changed = true
   672  		}
   673  	}
   674  
   675  	// Store the experiment result.
   676  	gb.inner.assigned[exp.Key] = &Assignment{exp, result}
   677  
   678  	// If the result changed, trigger all subscriptions.
   679  	if changed || !exists {
   680  		for _, sub := range gb.inner.subscriptions {
   681  			sub(exp, result)
   682  		}
   683  	}
   684  }
   685  
   686  // Worker function to run an experiment.
   687  func (gb *GrowthBook) doRun(exp *Experiment, featureID string) *Result {
   688  	// 1. If experiment has fewer than two variations, return default
   689  	//    result.
   690  	if len(exp.Variations) < 2 {
   691  		logWarn("Invalid experiment", exp.Key)
   692  		return gb.getResult(exp, -1, false, featureID, nil)
   693  	}
   694  
   695  	// 2. If the context is disabled, return default result.
   696  	if !gb.inner.context.Enabled {
   697  		logInfo("Context disabled", exp.Key)
   698  		return gb.getResult(exp, -1, false, featureID, nil)
   699  	}
   700  
   701  	// 2.5. Merge in experiment overrides from the context
   702  	exp = gb.mergeOverrides(exp)
   703  
   704  	// 3. If a variation is forced from a querystring, return the forced
   705  	//    variation.
   706  	if gb.inner.context.URL != nil {
   707  		qsOverride := getQueryStringOverride(exp.Key, gb.inner.context.URL, len(exp.Variations))
   708  		if qsOverride != nil {
   709  			logInfo("Force via querystring", exp.Key, qsOverride)
   710  			return gb.getResult(exp, *qsOverride, false, featureID, nil)
   711  		}
   712  	}
   713  
   714  	// 4. If a variation is forced in the context, return the forced
   715  	//    variation.
   716  	if gb.inner.context.ForcedVariations != nil {
   717  		force, forced := gb.inner.context.ForcedVariations[exp.Key]
   718  		if forced {
   719  			logInfo("Forced variation", exp.Key, force)
   720  			return gb.getResult(exp, force, false, featureID, nil)
   721  		}
   722  	}
   723  
   724  	// 5. Exclude inactive experiments and return default result.
   725  	if exp.Status == DraftStatus || !exp.Active {
   726  		logInfo("Skip because inactive", exp.Key)
   727  		return gb.getResult(exp, -1, false, featureID, nil)
   728  	}
   729  
   730  	// 6. Get the user hash value and return if empty.
   731  	_, hashString := gb.getHashAttribute(exp.HashAttribute)
   732  	if hashString == "" {
   733  		logInfo("Skip because of missing hash attribute", exp.Key)
   734  		return gb.getResult(exp, -1, false, featureID, nil)
   735  	}
   736  
   737  	// 7. If exp.Namespace is set, return if not in range.
   738  	if exp.Filters != nil {
   739  		if gb.isFilteredOut(exp.Filters) {
   740  			logInfo("Skip because of filters", exp.Key)
   741  			return gb.getResult(exp, -1, false, featureID, nil)
   742  		}
   743  	} else if exp.Namespace != nil {
   744  		if !exp.Namespace.inNamespace(hashString) {
   745  			logInfo("Skip because of namespace", exp.Key)
   746  			return gb.getResult(exp, -1, false, featureID, nil)
   747  		}
   748  	}
   749  
   750  	// 7.5. Exclude if include function returns false.
   751  	if exp.Include != nil && !exp.Include() {
   752  		logInfo("Skip because of include function", exp.Key)
   753  		return gb.getResult(exp, -1, false, featureID, nil)
   754  	}
   755  
   756  	// 8. Exclude if condition is false.
   757  	if exp.Condition != nil {
   758  		if !exp.Condition.Eval(gb.inner.context.Attributes) {
   759  			logInfo("Skip because of condition", exp.Key)
   760  			return gb.getResult(exp, -1, false, featureID, nil)
   761  		}
   762  	}
   763  
   764  	// 8.1. Exclude if user is not in a required group.
   765  	if exp.Groups != nil && !gb.hasGroupOverlap(exp.Groups) {
   766  		logInfo("Skip because of groups", exp.Key)
   767  		return gb.getResult(exp, -1, false, featureID, nil)
   768  	}
   769  
   770  	// 8.2. Old style URL targeting.
   771  	if exp.URL != nil && !gb.urlIsValid(exp.URL) {
   772  		logInfo("Skip because of URL", exp.Key)
   773  		return gb.getResult(exp, -1, false, featureID, nil)
   774  	}
   775  
   776  	// 8.3. New, more powerful URL targeting
   777  	if exp.URLPatterns != nil && !isURLTargeted(gb.inner.context.URL, exp.URLPatterns) {
   778  		logInfo("Skip because of URL targeting", exp.Key)
   779  		return gb.getResult(exp, -1, false, featureID, nil)
   780  	}
   781  
   782  	// 9. Calculate bucket ranges for the variations and choose one.
   783  	seed := exp.Key
   784  	if exp.Seed != "" {
   785  		seed = exp.Seed
   786  	}
   787  	hv := 1
   788  	if exp.HashVersion != 0 {
   789  		hv = exp.HashVersion
   790  	}
   791  	n := hash(seed, hashString, hv)
   792  	if n == nil {
   793  		logWarn("Skip because of invalid hash version", exp.Key)
   794  		return gb.getResult(exp, -1, false, featureID, nil)
   795  	}
   796  	coverage := float64(1)
   797  	if exp.Coverage != nil {
   798  		coverage = *exp.Coverage
   799  	}
   800  	ranges := exp.Ranges
   801  	if ranges == nil {
   802  		ranges = getBucketRanges(len(exp.Variations), coverage, exp.Weights)
   803  	}
   804  	assigned := chooseVariation(*n, ranges)
   805  
   806  	// 10. If assigned == -1, return default result.
   807  	if assigned == -1 {
   808  		logInfo("Skip because of coverage", exp.Key)
   809  		return gb.getResult(exp, -1, false, featureID, nil)
   810  	}
   811  
   812  	// 11. If experiment has a forced variation, return it.
   813  	if exp.Force != nil {
   814  		return gb.getResult(exp, *exp.Force, false, featureID, nil)
   815  	}
   816  
   817  	// 12. If in QA mode, return default result.
   818  	if gb.inner.context.QAMode {
   819  		return gb.getResult(exp, -1, false, featureID, nil)
   820  	}
   821  
   822  	// 12.5. Exclude if experiment is stopped.
   823  	if exp.Status == StoppedStatus {
   824  		logInfo("Skip because stopped", exp.Key)
   825  		return gb.getResult(exp, -1, false, featureID, nil)
   826  	}
   827  
   828  	// 13. Build the result object.
   829  	result := gb.getResult(exp, assigned, true, featureID, n)
   830  
   831  	// 14. Fire tracking callback if required.
   832  	gb.track(exp, result)
   833  
   834  	logInfo("In experiment", fmt.Sprintf("%s[%d]", exp.Key, result.VariationID))
   835  	return result
   836  }
   837  
   838  func (gb *GrowthBook) mergeOverrides(exp *Experiment) *Experiment {
   839  	if gb.inner.context.Overrides == nil {
   840  		return exp
   841  	}
   842  	if override, ok := gb.inner.context.Overrides[exp.Key]; ok {
   843  		exp = exp.applyOverride(override)
   844  	}
   845  	return exp
   846  }
   847  
   848  // Fire Context.TrackingCallback if it's set and the combination of
   849  // hashAttribute, hashValue, experiment key, and variation ID has not
   850  // been tracked before.
   851  func (gb *GrowthBook) track(exp *Experiment, result *Result) {
   852  	if gb.inner.context.TrackingCallback == nil {
   853  		return
   854  	}
   855  
   856  	// Make sure tracking callback is only fired once per unique
   857  	// experiment.
   858  	key := result.HashAttribute + result.HashValue +
   859  		exp.Key + strconv.Itoa(result.VariationID)
   860  	if _, exists := gb.inner.trackedExperiments[key]; exists {
   861  		return
   862  	}
   863  
   864  	gb.inner.trackedExperiments[key] = true
   865  	gb.inner.context.TrackingCallback(exp, result)
   866  }
   867  
   868  func (gb *GrowthBook) getHashAttribute(attr string) (string, string) {
   869  	hashAttribute := "id"
   870  	if attr != "" {
   871  		hashAttribute = attr
   872  	}
   873  
   874  	var hashValue interface{}
   875  	ok := false
   876  	if gb.inner.attributeOverrides != nil {
   877  		hashValue, ok = gb.inner.attributeOverrides[hashAttribute]
   878  	}
   879  	if !ok {
   880  		if gb.inner.context.Attributes != nil {
   881  			hashValue, ok = gb.inner.context.Attributes[hashAttribute]
   882  		} else if gb.inner.context.UserAttributes != nil {
   883  			hashValue, ok = gb.inner.context.UserAttributes[hashAttribute]
   884  		}
   885  		if !ok {
   886  			return "", ""
   887  		}
   888  	}
   889  	hashString, ok := convertHashValue(hashValue)
   890  	if !ok {
   891  		return "", ""
   892  	}
   893  
   894  	return hashAttribute, hashString
   895  }
   896  
   897  func (gb *GrowthBook) isIncludedInRollout(
   898  	seed string,
   899  	hashAttribute string,
   900  	rng *Range,
   901  	coverage *float64,
   902  	hashVersion int,
   903  ) bool {
   904  	if rng == nil && coverage == nil {
   905  		return true
   906  	}
   907  
   908  	_, hashValue := gb.getHashAttribute(hashAttribute)
   909  	if hashValue == "" {
   910  		return false
   911  	}
   912  
   913  	hv := 1
   914  	if hashVersion != 0 {
   915  		hv = hashVersion
   916  	}
   917  	n := hash(seed, hashValue, hv)
   918  	if n == nil {
   919  		return false
   920  	}
   921  
   922  	if rng != nil {
   923  		return rng.InRange(*n)
   924  	}
   925  	if coverage != nil {
   926  		return *n <= *coverage
   927  	}
   928  	return true
   929  }
   930  
   931  func (gb *GrowthBook) isFilteredOut(filters []Filter) bool {
   932  	for _, filter := range filters {
   933  		_, hashValue := gb.getHashAttribute(filter.Attribute)
   934  		if hashValue == "" {
   935  			return true
   936  		}
   937  		hv := 2
   938  		if filter.HashVersion != 0 {
   939  			hv = filter.HashVersion
   940  		}
   941  		n := hash(filter.Seed, hashValue, hv)
   942  		if n == nil {
   943  			return true
   944  		}
   945  		if filter.Ranges != nil {
   946  			inRange := false
   947  			for _, rng := range filter.Ranges {
   948  				if rng.InRange(*n) {
   949  					inRange = true
   950  					break
   951  				}
   952  			}
   953  			if !inRange {
   954  				return true
   955  			}
   956  		}
   957  	}
   958  	return false
   959  }
   960  
   961  func (gb *GrowthBook) hasGroupOverlap(groups []string) bool {
   962  	for _, g := range groups {
   963  		if val, ok := gb.inner.context.Groups[g]; ok && val {
   964  			return true
   965  		}
   966  	}
   967  	return false
   968  }
   969  
   970  func (gb *GrowthBook) urlIsValid(urlRegexp *regexp.Regexp) bool {
   971  	url := gb.inner.context.URL
   972  	if url == nil {
   973  		return false
   974  	}
   975  
   976  	return urlRegexp.MatchString(url.String()) ||
   977  		urlRegexp.MatchString(url.Path)
   978  }
   979  

View as plain text