Skip to content

Commit

Permalink
support persisted layers (#171)
Browse files Browse the repository at this point in the history
  • Loading branch information
kenny-statsig authored Apr 6, 2024
1 parent 6f1aea8 commit b171689
Show file tree
Hide file tree
Showing 5 changed files with 116 additions and 56 deletions.
22 changes: 14 additions & 8 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
Expand Down Expand Up @@ -269,8 +274,9 @@ type GetExperimentOptions struct {
PersistedValues UserPersistedValues
}

type getLayerOptions struct {
disableLogExposures bool
type GetLayerOptions struct {
DisableLogExposures bool
PersistedValues UserPersistedValues
}

type gateResponse struct {
Expand Down Expand Up @@ -364,22 +370,22 @@ 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)
}

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)
}
Expand Down
2 changes: 1 addition & 1 deletion evaluation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
125 changes: 84 additions & 41 deletions evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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{
Expand All @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions statsig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
15 changes: 9 additions & 6 deletions user_persistent_storage_interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit b171689

Please sign in to comment.