From b1716896f4db7f9bce59902aaa851b97031ae0e8 Mon Sep 17 00:00:00 2001 From: kenny-statsig <111380336+kenny-statsig@users.noreply.github.com> Date: Fri, 5 Apr 2024 17:00:42 -0700 Subject: [PATCH] support persisted layers (#171) https://github.com/statsig-io/kong/pull/1917 --- client.go | 22 +++-- evaluation_test.go | 2 +- evaluator.go | 125 ++++++++++++++++++--------- statsig.go | 8 ++ user_persistent_storage_interface.go | 15 ++-- 5 files changed, 116 insertions(+), 56 deletions(-) diff --git a/client.go b/client.go index 4e699d9..2025031 100644 --- a/client.go +++ b/client.go @@ -161,13 +161,18 @@ func (c *Client) GetUserPersistedValues(user User, idType string) UserPersistedV // Gets the Layer object for the given user func (c *Client) GetLayer(user User, layer string) Layer { - options := getLayerOptions{disableLogExposures: false} + options := &GetLayerOptions{DisableLogExposures: false, PersistedValues: nil} return c.getLayerImpl(user, layer, options) } // Gets the Layer object for the given user without logging an exposure event func (c *Client) GetLayerWithExposureLoggingDisabled(user User, layer string) Layer { - options := getLayerOptions{disableLogExposures: true} + options := &GetLayerOptions{DisableLogExposures: true, PersistedValues: nil} + return c.getLayerImpl(user, layer, options) +} + +// Gets the Layer object for the given user with configurable options +func (c *Client) GetLayerWithOptions(user User, layer string, options *GetLayerOptions) Layer { return c.getLayerImpl(user, layer, options) } @@ -178,7 +183,7 @@ func (c *Client) ManuallyLogLayerParameterExposure(user User, layer string, para return } user = normalizeUser(user, *c.options) - res := c.evaluator.evalLayer(user, layer) + res := c.evaluator.evalLayer(user, layer, nil) config := NewLayer(layer, res.JsonValue, res.RuleID, res.GroupName, nil, res.ConfigDelegate) context := &logContext{isManualExposure: true} c.logger.logLayerExposure(user, *config, parameter, *res, res.EvaluationDetails, context) @@ -269,8 +274,9 @@ type GetExperimentOptions struct { PersistedValues UserPersistedValues } -type getLayerOptions struct { - disableLogExposures bool +type GetLayerOptions struct { + DisableLogExposures bool + PersistedValues UserPersistedValues } type gateResponse struct { @@ -364,14 +370,14 @@ func (c *Client) getConfigImpl(user User, name string, context getConfigImplCont }) } -func (c *Client) getLayerImpl(user User, name string, options getLayerOptions) Layer { +func (c *Client) getLayerImpl(user User, name string, options *GetLayerOptions) Layer { return c.errorBoundary.captureGetLayer(func() Layer { if !c.verifyUser(user) { return *NewLayer(name, nil, "", "", nil, "") } user = normalizeUser(user, *c.options) - res := c.evaluator.evalLayer(user, name) + res := c.evaluator.evalLayer(user, name, options.PersistedValues) if res.FetchFromServer { res = c.fetchConfigFromServer(user, name) @@ -379,7 +385,7 @@ func (c *Client) getLayerImpl(user User, name string, options getLayerOptions) L logFunc := func(layer Layer, parameterName string) { var exposure *ExposureEvent = nil - if !options.disableLogExposures { + if !options.DisableLogExposures { context := &logContext{isManualExposure: false} exposure = c.logger.logLayerExposure(user, layer, parameterName, *res, res.EvaluationDetails, context) } diff --git a/evaluation_test.go b/evaluation_test.go index d39d365..f7cdfa7 100644 --- a/evaluation_test.go +++ b/evaluation_test.go @@ -171,7 +171,7 @@ func test_helper(apiOverride string, t *testing.T) { } for layer, serverResult := range entry.Layers { - sdkResult := c.evaluator.evalLayer(u, layer) + sdkResult := c.evaluator.evalLayer(u, layer, nil) if !reflect.DeepEqual(sdkResult.JsonValue, serverResult.Value) { t.Errorf("Values are different for layer %s. SDK got %s but server is %s. User is %+v", layer, sdkResult.JsonValue, serverResult.Value, u) diff --git a/evaluator.go b/evaluator.go index dd8a945..59b0cc8 100644 --- a/evaluator.go +++ b/evaluator.go @@ -45,23 +45,29 @@ func newEvalResultFromStickyValues(evalMap StickyValues) *evalResult { ) return &evalResult{ - Value: evalMap.Value, - RuleID: evalMap.RuleID, - GroupName: evalMap.GroupName, - SecondaryExposures: evalMap.SecondaryExposures, - JsonValue: evalMap.JsonValue, - EvaluationDetails: evaluationDetails, + Value: evalMap.Value, + RuleID: evalMap.RuleID, + GroupName: evalMap.GroupName, + SecondaryExposures: evalMap.SecondaryExposures, + JsonValue: evalMap.JsonValue, + EvaluationDetails: evaluationDetails, + ConfigDelegate: evalMap.ConfigDelegate, + ExplicitParameters: evalMap.ExplicitParameters, + UndelegatedSecondaryExposures: evalMap.UndelegatedSecondaryExposures, } } func (e *evalResult) toStickyValues() StickyValues { return StickyValues{ - Value: e.Value, - JsonValue: e.JsonValue, - RuleID: e.RuleID, - GroupName: e.GroupName, - SecondaryExposures: e.SecondaryExposures, - Time: e.EvaluationDetails.configSyncTime, + Value: e.Value, + JsonValue: e.JsonValue, + RuleID: e.RuleID, + GroupName: e.GroupName, + SecondaryExposures: e.SecondaryExposures, + Time: e.EvaluationDetails.configSyncTime, + ConfigDelegate: e.ConfigDelegate, + ExplicitParameters: e.ExplicitParameters, + UndelegatedSecondaryExposures: e.UndelegatedSecondaryExposures, } } @@ -159,35 +165,31 @@ func (e *evaluator) evalConfigImpl(user User, configName string, persistedValues SecondaryExposures: make([]map[string]string, 0), } } - if config, hasConfig := e.store.getDynamicConfig(configName); hasConfig { - var evaluation *evalResult - if persistedValues != nil && config.IsActive != nil && *config.IsActive { - stickyResult := newEvalResultFromUserPersistedValues(configName, persistedValues) - if stickyResult != nil { - return stickyResult - } + config, hasConfig := e.store.getDynamicConfig(configName) + if !hasConfig { + emptyEvalResult := new(evalResult) + emptyEvalResult.EvaluationDetails = e.createEvaluationDetails(reasonUnrecognized) + emptyEvalResult.SecondaryExposures = make([]map[string]string, 0) + return emptyEvalResult + } - evaluation = e.eval(user, config, depth+1) - if evaluation.IsExperimentGroup != nil && *evaluation.IsExperimentGroup { - e.persistentStorageUtils.save(user, config.IDType, configName, evaluation) - } - } else { - e.persistentStorageUtils.delete(user, config.IDType, configName) - evaluation = e.eval(user, config, depth+1) - } - return evaluation + if persistedValues == nil || config.IsActive == nil || !*config.IsActive { + return e.evalAndSDeleteFromPersistentStorage(user, config, depth) } - emptyEvalResult := new(evalResult) - emptyEvalResult.EvaluationDetails = e.createEvaluationDetails(reasonUnrecognized) - emptyEvalResult.SecondaryExposures = make([]map[string]string, 0) - return emptyEvalResult + + stickyResult := newEvalResultFromUserPersistedValues(configName, persistedValues) + if stickyResult != nil { + return stickyResult + } + + return e.evalAndSaveToPersistentStorage(user, config, depth) } -func (e *evaluator) evalLayer(user User, name string) *evalResult { - return e.evalLayerImpl(user, name, 0) +func (e *evaluator) evalLayer(user User, name string, persistedValues UserPersistedValues) *evalResult { + return e.evalLayerImpl(user, name, persistedValues, 0) } -func (e *evaluator) evalLayerImpl(user User, name string, depth int) *evalResult { +func (e *evaluator) evalLayerImpl(user User, name string, persistedValues UserPersistedValues, depth int) *evalResult { if layerOverride, hasOverride := e.getLayerOverride(name); hasOverride { evalDetails := e.createEvaluationDetails(reasonLocalOverride) return &evalResult{ @@ -198,13 +200,54 @@ func (e *evaluator) evalLayerImpl(user User, name string, depth int) *evalResult SecondaryExposures: make([]map[string]string, 0), } } - if config, hasConfig := e.store.getLayerConfig(name); hasConfig { - return e.eval(user, config, depth+1) + config, hasConfig := e.store.getLayerConfig(name) + if !hasConfig { + emptyEvalResult := new(evalResult) + emptyEvalResult.EvaluationDetails = e.createEvaluationDetails(reasonUnrecognized) + emptyEvalResult.SecondaryExposures = make([]map[string]string, 0) + return emptyEvalResult } - emptyEvalResult := new(evalResult) - emptyEvalResult.EvaluationDetails = e.createEvaluationDetails(reasonUnrecognized) - emptyEvalResult.SecondaryExposures = make([]map[string]string, 0) - return emptyEvalResult + + if persistedValues == nil { + return e.evalAndSDeleteFromPersistentStorage(user, config, depth) + } + + stickyResult := newEvalResultFromUserPersistedValues(name, persistedValues) + if stickyResult != nil { + if e.allocatedExperimentExistsAndIsActive(stickyResult) { + return stickyResult + } else { + return e.evalAndSDeleteFromPersistentStorage(user, config, depth) + } + } else { + evaluation := e.eval(user, config, depth) + if e.allocatedExperimentExistsAndIsActive(evaluation) { + if evaluation.IsExperimentGroup != nil && *evaluation.IsExperimentGroup { + e.persistentStorageUtils.save(user, config.IDType, name, evaluation) + } + } else { + e.persistentStorageUtils.delete(user, config.IDType, name) + } + return evaluation + } +} + +func (e *evaluator) allocatedExperimentExistsAndIsActive(evaluation *evalResult) bool { + delegate, exists := e.store.getDynamicConfig(evaluation.ConfigDelegate) + return exists && delegate.IsActive != nil && *delegate.IsActive +} + +func (e *evaluator) evalAndSaveToPersistentStorage(user User, config configSpec, depth int) *evalResult { + evaluation := e.eval(user, config, depth) + if evaluation.IsExperimentGroup != nil && *evaluation.IsExperimentGroup { + e.persistentStorageUtils.save(user, config.IDType, config.Name, evaluation) + } + return evaluation +} + +func (e *evaluator) evalAndSDeleteFromPersistentStorage(user User, config configSpec, depth int) *evalResult { + e.persistentStorageUtils.delete(user, config.IDType, config.Name) + return e.eval(user, config, depth) } func (e *evaluator) getGateOverride(name string) (bool, bool) { diff --git a/statsig.go b/statsig.go index 13c4561..f5350b7 100644 --- a/statsig.go +++ b/statsig.go @@ -244,6 +244,14 @@ func GetLayerWithExposureLoggingDisabled(user User, layer string) Layer { return instance.GetLayerWithExposureLoggingDisabled(user, layer) } +// Gets the Layer object for the given user with configurable options +func GetLayerWithOptions(user User, layer string, options *GetLayerOptions) Layer { + if !IsInitialized() { + panic(fmt.Errorf("must Initialize() statsig before calling GetLayerWithOptions")) + } + return instance.getLayerImpl(user, layer, options) +} + // Logs an exposure event for the parameter in the given layer func ManuallyLogLayerParameterExposure(user User, layer string, parameter string) { if !IsInitialized() { diff --git a/user_persistent_storage_interface.go b/user_persistent_storage_interface.go index fc7e96b..7e2c68e 100644 --- a/user_persistent_storage_interface.go +++ b/user_persistent_storage_interface.go @@ -3,12 +3,15 @@ package statsig // The properties of this struct must fit a universal schema that // when JSON-ified, can be parsed by every SDK supporting user persistent evaluation. type StickyValues struct { - Value bool `json:"value"` - JsonValue map[string]interface{} `json:"json_value"` - RuleID string `json:"rule_id"` - GroupName string `json:"group_name"` - SecondaryExposures []map[string]string `json:"secondary_exposures"` - Time int64 `json:"time"` + Value bool `json:"value"` + JsonValue map[string]interface{} `json:"json_value"` + RuleID string `json:"rule_id"` + GroupName string `json:"group_name"` + SecondaryExposures []map[string]string `json:"secondary_exposures"` + Time int64 `json:"time"` + ConfigDelegate string `json:"config_delegate,omitempty"` + ExplicitParameters map[string]bool `json:"explicit_parameters,omitempty"` + UndelegatedSecondaryExposures []map[string]string `json:"undelegated_secondary_exposures"` } type UserPersistedValues = map[string]StickyValues