diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 189b6f6..54dd996 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,4 +17,3 @@ jobs: - run: go test -v env: test_api_key: ${{ secrets.SDK_CONSISTENCY_TEST_COMPANY_API_KEY }} - - run: go test */**.go -v diff --git a/client.go b/client.go index 7c64e88..df3b805 100644 --- a/client.go +++ b/client.go @@ -3,40 +3,34 @@ package statsig import ( "fmt" "strings" - - "github.com/statsig-io/go-sdk/internal/evaluation" - "github.com/statsig-io/go-sdk/internal/logging" - "github.com/statsig-io/go-sdk/internal/net" - - "github.com/statsig-io/go-sdk/types" ) // An instance of a StatsigClient for interfacing with Statsig Feature Gates, Dynamic Configs, Experiments, and Event Logging type Client struct { sdkKey string - evaluator *evaluation.Evaluator - logger *logging.Logger - net *net.Net - options *types.StatsigOptions + evaluator *evaluator + logger *logger + transport *transport + options *Options } // Initializes a Statsig Client with the given sdkKey -func New(sdkKey string) *Client { - return NewWithOptions(sdkKey, &types.StatsigOptions{API: "https://api.statsig.com/v1"}) +func NewClient(sdkKey string) *Client { + return NewClientWithOptions(sdkKey, &Options{API: DefaultEndpoint}) } // Initializes a Statsig Client with the given sdkKey and options -func NewWithOptions(sdkKey string, options *types.StatsigOptions) *Client { - return WrapperSDKInstance(sdkKey, options, "", "") +func NewClientWithOptions(sdkKey string, options *Options) *Client { + return wrapperSDKInstance(sdkKey, options, "", "") } -func WrapperSDKInstance(sdkKey string, options *types.StatsigOptions, sdkName string, sdkVersion string) *Client { +func wrapperSDKInstance(sdkKey string, options *Options, sdkName string, sdkVersion string) *Client { if len(options.API) == 0 { options.API = "https://api.statsig.com/v1" } - net := net.New(sdkKey, options.API, sdkName, sdkVersion) - logger := logging.New(net) - evaluator := evaluation.New(net) + transport := newTransport(sdkKey, options.API, sdkName, sdkVersion) + logger := newLogger(transport) + evaluator := newEvaluator(transport) if !strings.HasPrefix(sdkKey, "secret") { panic("Must provide a valid SDK key.") } @@ -44,13 +38,13 @@ func WrapperSDKInstance(sdkKey string, options *types.StatsigOptions, sdkName st sdkKey: sdkKey, evaluator: evaluator, logger: logger, - net: net, + transport: transport, options: options, } } // Checks the value of a Feature Gate for the given user -func (c *Client) CheckGate(user types.StatsigUser, gate string) bool { +func (c *Client) CheckGate(user User, gate string) bool { if user.UserID == "" { fmt.Println("A non-empty StatsigUser.UserID is required. See https://docs.statsig.com/messages/serverRequiredUserID") return false @@ -58,55 +52,55 @@ func (c *Client) CheckGate(user types.StatsigUser, gate string) bool { user = normalizeUser(user, *c.options) res := c.evaluator.CheckGate(user, gate) if res.FetchFromServer { - serverRes := fetchGate(user, gate, c.net) - res = &evaluation.EvalResult{Pass: serverRes.Value, Id: serverRes.RuleID} + serverRes := fetchGate(user, gate, c.transport) + res = &evalResult{Pass: serverRes.Value, Id: serverRes.RuleID} } else { - c.logger.LogGateExposure(user, gate, res.Pass, res.Id, res.SecondaryExposures) + c.logger.logGateExposure(user, gate, res.Pass, res.Id, res.SecondaryExposures) } return res.Pass } // Gets the DynamicConfig value for the given user -func (c *Client) GetConfig(user types.StatsigUser, config string) types.DynamicConfig { +func (c *Client) GetConfig(user User, config string) DynamicConfig { if user.UserID == "" { fmt.Println("A non-empty StatsigUser.UserID is required. See https://docs.statsig.com/messages/serverRequiredUserID") - return *types.NewConfig(config, nil, "") + return *NewConfig(config, nil, "") } user = normalizeUser(user, *c.options) res := c.evaluator.GetConfig(user, config) if res.FetchFromServer { - serverRes := fetchConfig(user, config, c.net) - res = &evaluation.EvalResult{ - ConfigValue: *types.NewConfig(config, serverRes.Value, serverRes.RuleID), + serverRes := fetchConfig(user, config, c.transport) + res = &evalResult{ + ConfigValue: *NewConfig(config, serverRes.Value, serverRes.RuleID), Id: serverRes.RuleID} } else { - c.logger.LogConfigExposure(user, config, res.Id, res.SecondaryExposures) + c.logger.logConfigExposure(user, config, res.Id, res.SecondaryExposures) } return res.ConfigValue } // Gets the DynamicConfig value of an Experiment for the given user -func (c *Client) GetExperiment(user types.StatsigUser, experiment string) types.DynamicConfig { +func (c *Client) GetExperiment(user User, experiment string) DynamicConfig { if user.UserID == "" { fmt.Println("A non-empty StatsigUser.UserID is required. See https://docs.statsig.com/messages/serverRequiredUserID") - return *types.NewConfig(experiment, nil, "") + return *NewConfig(experiment, nil, "") } return c.GetConfig(user, experiment) } // Logs an event to Statsig for analysis in the Statsig Console -func (c *Client) LogEvent(event types.StatsigEvent) { +func (c *Client) LogEvent(event Event) { event.User = normalizeUser(event.User, *c.options) if event.EventName == "" { return } - c.logger.LogCustom(event) + c.logger.logCustom(event) } // Cleans up Statsig, persisting any Event Logs and cleanup processes // Using any method is undefined after Shutdown() has been called func (c *Client) Shutdown() { - c.logger.Flush(true) + c.logger.flush(true) c.evaluator.Stop() } @@ -123,25 +117,25 @@ type configResponse struct { } type checkGateInput struct { - GateName string `json:"gateName"` - User types.StatsigUser `json:"user"` - StatsigMetadata net.StatsigMetadata `json:"statsigMetadata"` + GateName string `json:"gateName"` + User User `json:"user"` + StatsigMetadata statsigMetadata `json:"statsigMetadata"` } type getConfigInput struct { - ConfigName string `json:"configName"` - User types.StatsigUser `json:"user"` - StatsigMetadata net.StatsigMetadata `json:"statsigMetadata"` + ConfigName string `json:"configName"` + User User `json:"user"` + StatsigMetadata statsigMetadata `json:"statsigMetadata"` } -func fetchGate(user types.StatsigUser, gateName string, n *net.Net) gateResponse { +func fetchGate(user User, gateName string, t *transport) gateResponse { input := &checkGateInput{ GateName: gateName, User: user, - StatsigMetadata: n.GetStatsigMetadata(), + StatsigMetadata: t.metadata, } var res gateResponse - err := n.PostRequest("/check_gate", input, &res) + err := t.postRequest("/check_gate", input, &res) if err != nil { return gateResponse{ Name: gateName, @@ -152,14 +146,14 @@ func fetchGate(user types.StatsigUser, gateName string, n *net.Net) gateResponse return res } -func fetchConfig(user types.StatsigUser, configName string, n *net.Net) configResponse { +func fetchConfig(user User, configName string, t *transport) configResponse { input := &getConfigInput{ ConfigName: configName, User: user, - StatsigMetadata: n.GetStatsigMetadata(), + StatsigMetadata: t.metadata, } var res configResponse - err := n.PostRequest("/get_config", input, &res) + err := t.postRequest("/get_config", input, &res) if err != nil { return configResponse{ Name: configName, @@ -169,7 +163,7 @@ func fetchConfig(user types.StatsigUser, configName string, n *net.Net) configRe return res } -func normalizeUser(user types.StatsigUser, options types.StatsigOptions) types.StatsigUser { +func normalizeUser(user User, options Options) User { var env map[string]string if len(options.Environment.Params) > 0 { env = options.Environment.Params diff --git a/statsig_test.go b/evaluation_test.go similarity index 80% rename from statsig_test.go rename to evaluation_test.go index 09bbafb..ebd000c 100644 --- a/statsig_test.go +++ b/evaluation_test.go @@ -4,8 +4,6 @@ import ( "os" "path/filepath" "testing" - - "github.com/statsig-io/go-sdk/types" ) type data struct { @@ -13,9 +11,9 @@ type data struct { } type entry struct { - User types.StatsigUser `json:"user"` - Gates map[string]bool `json:"feature_gates"` - Configs map[string]types.DynamicConfig `json:"dynamic_configs"` + User User `json:"user"` + Gates map[string]bool `json:"feature_gates"` + Configs map[string]DynamicConfig `json:"dynamic_configs"` } var secret string @@ -49,9 +47,9 @@ func Test(t *testing.T) { func test_helper(apiOverride string, t *testing.T) { t.Logf("Testing for " + apiOverride) - c := NewWithOptions(secret, &types.StatsigOptions{API: apiOverride}) + c := NewClientWithOptions(secret, &Options{API: apiOverride}) var d data - err := c.net.PostRequest("/rulesets_e2e_test", nil, &d) + err := c.transport.postRequest("/rulesets_e2e_test", nil, &d) if err != nil { t.Errorf("Could not download test data") } diff --git a/internal/evaluation/evaluator.go b/evaluator.go similarity index 85% rename from internal/evaluation/evaluator.go rename to evaluator.go index aed9029..ee84acb 100644 --- a/internal/evaluation/evaluator.go +++ b/evaluator.go @@ -1,4 +1,4 @@ -package evaluation +package statsig import ( "crypto/sha256" @@ -11,22 +11,19 @@ import ( "strings" "time" - "github.com/statsig-io/go-sdk/internal/net" - "github.com/statsig-io/go-sdk/types" - "github.com/statsig-io/ip3country-go/pkg/countrylookup" "github.com/ua-parser/uap-go/uaparser" ) -type Evaluator struct { - store *Store +type evaluator struct { + store *store countryLookup *countrylookup.CountryLookup uaParser *uaparser.Parser } -type EvalResult struct { +type evalResult struct { Pass bool - ConfigValue types.DynamicConfig + ConfigValue DynamicConfig FetchFromServer bool Id string SecondaryExposures []map[string]string @@ -34,8 +31,8 @@ type EvalResult struct { const dynamicConfigType = "dynamic_config" -func New(net *net.Net) *Evaluator { - store := initStore(net) +func newEvaluator(transport *transport) *evaluator { + store := newStore(transport) parser := uaparser.NewFromSaved() countryLookup := countrylookup.New() defer func() { @@ -45,32 +42,32 @@ func New(net *net.Net) *Evaluator { } }() - return &Evaluator{ + return &evaluator{ store: store, countryLookup: countryLookup, uaParser: parser, } } -func (e *Evaluator) Stop() { +func (e *evaluator) Stop() { e.store.StopPolling() } -func (e *Evaluator) CheckGate(user types.StatsigUser, gateName string) *EvalResult { - if gate, hasGate := e.store.FeatureGates[gateName]; hasGate { +func (e *evaluator) CheckGate(user User, gateName string) *evalResult { + if gate, hasGate := e.store.featureGates[gateName]; hasGate { return e.eval(user, gate) } - return new(EvalResult) + return new(evalResult) } -func (e *Evaluator) GetConfig(user types.StatsigUser, configName string) *EvalResult { - if config, hasConfig := e.store.DynamicConfigs[configName]; hasConfig { +func (e *evaluator) GetConfig(user User, configName string) *evalResult { + if config, hasConfig := e.store.dynamicConfigs[configName]; hasConfig { return e.eval(user, config) } - return new(EvalResult) + return new(evalResult) } -func (e *Evaluator) eval(user types.StatsigUser, spec ConfigSpec) *EvalResult { +func (e *evaluator) eval(user User, spec configSpec) *evalResult { var configValue map[string]interface{} isDynamicConfig := strings.ToLower(spec.Type) == dynamicConfigType if isDynamicConfig { @@ -97,29 +94,29 @@ func (e *Evaluator) eval(user types.StatsigUser, spec ConfigSpec) *EvalResult { configValue = make(map[string]interface{}) } } - return &EvalResult{ + return &evalResult{ Pass: pass, - ConfigValue: *types.NewConfig(spec.Name, configValue, rule.ID), + ConfigValue: *NewConfig(spec.Name, configValue, rule.ID), Id: rule.ID, SecondaryExposures: exposures} } else { - return &EvalResult{Pass: pass, Id: rule.ID, SecondaryExposures: exposures} + return &evalResult{Pass: pass, Id: rule.ID, SecondaryExposures: exposures} } } } } if isDynamicConfig { - return &EvalResult{ + return &evalResult{ Pass: false, - ConfigValue: *types.NewConfig(spec.Name, configValue, "default"), + ConfigValue: *NewConfig(spec.Name, configValue, "default"), Id: "default", SecondaryExposures: exposures} } - return &EvalResult{Pass: false, Id: "default", SecondaryExposures: exposures} + return &evalResult{Pass: false, Id: "default", SecondaryExposures: exposures} } -func evalPassPercent(user types.StatsigUser, rule ConfigRule, spec ConfigSpec) bool { +func evalPassPercent(user User, rule configRule, spec configSpec) bool { ruleSalt := rule.Salt if ruleSalt == "" { ruleSalt = rule.ID @@ -129,9 +126,9 @@ func evalPassPercent(user types.StatsigUser, rule ConfigRule, spec ConfigSpec) b return hash%10000 < (uint64(rule.PassPercentage) * 100) } -func (e *Evaluator) evalRule(user types.StatsigUser, rule ConfigRule) *EvalResult { +func (e *evaluator) evalRule(user User, rule configRule) *evalResult { var exposures []map[string]string - var finalResult = &EvalResult{Pass: true, FetchFromServer: false} + var finalResult = &evalResult{Pass: true, FetchFromServer: false} for _, cond := range rule.Conditions { res := e.evalCondition(user, cond) if !res.Pass { @@ -146,19 +143,19 @@ func (e *Evaluator) evalRule(user types.StatsigUser, rule ConfigRule) *EvalResul return finalResult } -func (e *Evaluator) evalCondition(user types.StatsigUser, cond ConfigCondition) *EvalResult { +func (e *evaluator) evalCondition(user User, cond configCondition) *evalResult { var value interface{} switch cond.Type { case "public": - return &EvalResult{Pass: true} + return &evalResult{Pass: true} case "fail_gate", "pass_gate": dependentGateName, ok := cond.TargetValue.(string) if !ok { - return &EvalResult{Pass: false} + return &evalResult{Pass: false} } result := e.CheckGate(user, dependentGateName) if result.FetchFromServer { - return &EvalResult{FetchFromServer: true} + return &evalResult{FetchFromServer: true} } newExposure := map[string]string{ "gate": dependentGateName, @@ -167,9 +164,9 @@ func (e *Evaluator) evalCondition(user types.StatsigUser, cond ConfigCondition) } allExposures := append(result.SecondaryExposures, newExposure) if cond.Type == "pass_gate" { - return &EvalResult{Pass: result.Pass, SecondaryExposures: allExposures} + return &evalResult{Pass: result.Pass, SecondaryExposures: allExposures} } else { - return &EvalResult{Pass: !result.Pass, SecondaryExposures: allExposures} + return &evalResult{Pass: !result.Pass, SecondaryExposures: allExposures} } case "ip_based": value = getFromUser(user, cond.Field) @@ -192,7 +189,7 @@ func (e *Evaluator) evalCondition(user types.StatsigUser, cond ConfigCondition) value = int64(getHash(fmt.Sprintf("%s.%s", salt, user.UserID)) % 1000) } default: - return &EvalResult{FetchFromServer: true} + return &evalResult{FetchFromServer: true} } pass := false @@ -277,10 +274,10 @@ func (e *Evaluator) evalCondition(user types.StatsigUser, cond ConfigCondition) pass = false server = true } - return &EvalResult{Pass: pass, FetchFromServer: server} + return &evalResult{Pass: pass, FetchFromServer: server} } -func getFromUser(user types.StatsigUser, field string) interface{} { +func getFromUser(user User, field string) interface{} { var value interface{} // 1. Try to get from top level user field first switch strings.ToLower(field) { @@ -316,7 +313,7 @@ func getFromUser(user types.StatsigUser, field string) interface{} { return value } -func getFromEnvironment(user types.StatsigUser, field string) string { +func getFromEnvironment(user User, field string) string { var value string if val, ok := user.StatsigEnvironment[field]; ok { value = val @@ -327,7 +324,7 @@ func getFromEnvironment(user types.StatsigUser, field string) string { return value } -func getFromUserAgent(user types.StatsigUser, field string, parser *uaparser.Parser) string { +func getFromUserAgent(user User, field string, parser *uaparser.Parser) string { ua := getFromUser(user, "useragent") uaStr, ok := ua.(string) if !ok { @@ -347,7 +344,7 @@ func getFromUserAgent(user types.StatsigUser, field string, parser *uaparser.Par return "" } -func getFromIP(user types.StatsigUser, field string, lookup *countrylookup.CountryLookup) string { +func getFromIP(user User, field string, lookup *countrylookup.CountryLookup) string { if strings.ToLower(field) != "country" { return "" } diff --git a/go.mod b/go.mod index b17cce2..7d8974b 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,6 @@ module github.com/statsig-io/go-sdk go 1.16 -replace github.com/statsig-io/go-sdk/internal => ./internal/ - -replace github.com/statsig-io/go-sdk/types => ./types/ - require ( github.com/statsig-io/ip3country-go v0.2.0 github.com/ua-parser/uap-go v0.0.0-20210121150957-347a3497cc39 diff --git a/internal/net/net.go b/internal/net/net.go deleted file mode 100644 index 8fce8d6..0000000 --- a/internal/net/net.go +++ /dev/null @@ -1,124 +0,0 @@ -package net - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - "time" -) - -const backoffMultiplier = 10 - -const ( - MaxRetries = 5 -) - -type StatsigMetadata struct { - SDKType string `json:"sdkType"` - SDKVersion string `json:"sdkVersion"` -} - -type Net struct { - api string - metadata StatsigMetadata - sdkKey string - client *http.Client -} - -func New(secret string, api string, sdkType string, sdkVersion string) *Net { - if api == "" { - api = "https://api.statsig.com/v1" - } - if strings.HasSuffix(api, "/") { - api = api[:len(api)-1] - } - - if sdkType == "" { - sdkType = "go-sdk" - } - if sdkVersion == "" { - sdkVersion = "0.4.2" - } - - return &Net{ - api: api, - metadata: StatsigMetadata{SDKType: sdkType, SDKVersion: sdkVersion}, - sdkKey: secret, - client: &http.Client{}, - } -} - -func (n *Net) GetStatsigMetadata() StatsigMetadata { - return n.metadata -} - -func (n *Net) PostRequest( - endpoint string, - in interface{}, - out interface{}, -) error { - return n.postRequestInternal(endpoint, in, out, 0, 0) -} - -func (n *Net) RetryablePostRequest( - endpoint string, - in interface{}, - out interface{}, - retries int, -) error { - return n.postRequestInternal(endpoint, in, out, retries, 1) -} - -func (n *Net) postRequestInternal( - endpoint string, - in interface{}, - out interface{}, - retries int, - backoff int, -) error { - jsonStr, err := json.Marshal(in) - if err != nil { - return err - } - - var req *http.Request - req, err = http.NewRequest("POST", n.api+endpoint, bytes.NewBuffer(jsonStr)) - if err != nil { - return err - } - req.Header.Add("STATSIG-API-KEY", n.sdkKey) - req.Header.Set("Content-Type", "application/json") - req.Header.Add("STATSIG-CLIENT-TIME", strconv.FormatInt(time.Now().Unix()*1000, 10)) - var response *http.Response - response, err = n.client.Do(req) - if err != nil { - if retries > 0 { - time.Sleep(time.Duration(backoff) * time.Second) - return n.postRequestInternal(endpoint, in, out, retries-1, backoff*backoffMultiplier) - } - return err - } - defer response.Body.Close() - if response.StatusCode >= 200 && response.StatusCode < 300 { - err := json.NewDecoder(response.Body).Decode(&out) - return err - } else if retries > 0 { - if shouldRetry(response.StatusCode) { - time.Sleep(time.Duration(backoff) * time.Second) - return n.postRequestInternal(endpoint, in, out, retries-1, backoff*backoffMultiplier) - } - } - return fmt.Errorf("http response error code: %d", response.StatusCode) -} - -func shouldRetry(code int) bool { - switch code { - case 408, 500, 502, 503, 504, 522, 524, 599: - return true - default: - return false - } -} diff --git a/internal/logging/logger.go b/logger.go similarity index 53% rename from internal/logging/logger.go rename to logger.go index c09748f..efb71cf 100644 --- a/internal/logging/logger.go +++ b/logger.go @@ -1,43 +1,42 @@ -package logging +package statsig import ( "strconv" "time" +) - "github.com/statsig-io/go-sdk/internal/net" - "github.com/statsig-io/go-sdk/types" +const ( + maxEvents = 500 + gateExposureEvent = "statsig::gate_exposure" + configExposureEvent = "statsig::config_exposure" ) type exposureEvent struct { EventName string `json:"eventName"` - User types.StatsigUser `json:"user"` + User User `json:"user"` Value string `json:"value"` Metadata map[string]string `json:"metadata"` SecondaryExposures []map[string]string `json:"secondaryExposures"` } type logEventInput struct { - Events []interface{} `json:"events"` - StatsigMetadata net.StatsigMetadata `json:"statsigMetadata"` + Events []interface{} `json:"events"` + StatsigMetadata statsigMetadata `json:"statsigMetadata"` } type logEventResponse struct{} -const MaxEvents = 500 -const GateExposureEvent = "statsig::gate_exposure" -const ConfigExposureEvent = "statsig::config_exposure" - -type Logger struct { - events []interface{} - net *net.Net - tick *time.Ticker +type logger struct { + events []interface{} + transport *transport + tick *time.Ticker } -func New(net *net.Net) *Logger { - log := &Logger{ - events: make([]interface{}, 0), - net: net, - tick: time.NewTicker(time.Minute), +func newLogger(transport *transport) *logger { + log := &logger{ + events: make([]interface{}, 0), + transport: transport, + tick: time.NewTicker(time.Minute), } go log.backgroundFlush() @@ -45,31 +44,31 @@ func New(net *net.Net) *Logger { return log } -func (l *Logger) backgroundFlush() { +func (l *logger) backgroundFlush() { for range l.tick.C { - l.Flush(false) + l.flush(false) } } -func (l *Logger) LogCustom(evt types.StatsigEvent) { +func (l *logger) logCustom(evt Event) { evt.User.PrivateAttributes = nil l.logInternal(evt) } -func (l *Logger) logExposure(evt exposureEvent) { +func (l *logger) logExposure(evt exposureEvent) { evt.User.PrivateAttributes = nil l.logInternal(evt) } -func (l *Logger) logInternal(evt interface{}) { +func (l *logger) logInternal(evt interface{}) { l.events = append(l.events, evt) - if len(l.events) >= MaxEvents { - l.Flush(false) + if len(l.events) >= maxEvents { + l.flush(false) } } -func (l *Logger) LogGateExposure( - user types.StatsigUser, +func (l *logger) logGateExposure( + user User, gateName string, value bool, ruleID string, @@ -77,7 +76,7 @@ func (l *Logger) LogGateExposure( ) { evt := &exposureEvent{ User: user, - EventName: GateExposureEvent, + EventName: gateExposureEvent, Metadata: map[string]string{ "gate": gateName, "gateValue": strconv.FormatBool(value), @@ -88,15 +87,15 @@ func (l *Logger) LogGateExposure( l.logExposure(*evt) } -func (l *Logger) LogConfigExposure( - user types.StatsigUser, +func (l *logger) logConfigExposure( + user User, configName string, ruleID string, exposures []map[string]string, ) { evt := &exposureEvent{ User: user, - EventName: ConfigExposureEvent, + EventName: configExposureEvent, Metadata: map[string]string{ "config": configName, "ruleID": ruleID, @@ -106,7 +105,7 @@ func (l *Logger) LogConfigExposure( l.logExposure(*evt) } -func (l *Logger) Flush(closing bool) { +func (l *logger) flush(closing bool) { if closing { l.tick.Stop() } @@ -123,11 +122,11 @@ func (l *Logger) Flush(closing bool) { l.events = make([]interface{}, 0) } -func (l *Logger) sendEvents(events []interface{}) { +func (l *logger) sendEvents(events []interface{}) { input := &logEventInput{ Events: events, - StatsigMetadata: l.net.GetStatsigMetadata(), + StatsigMetadata: l.transport.metadata, } var res logEventResponse - l.net.RetryablePostRequest("/log_event", input, &res, net.MaxRetries) + l.transport.retryablePostRequest("/log_event", input, &res, maxRetries) } diff --git a/internal/logging/logger_test.go b/logger_test.go similarity index 71% rename from internal/logging/logger_test.go rename to logger_test.go index 7c4d945..cf84621 100644 --- a/internal/logging/logger_test.go +++ b/logger_test.go @@ -1,4 +1,4 @@ -package logging +package statsig import ( "net/http" @@ -6,9 +6,6 @@ import ( "reflect" "strconv" "testing" - - "github.com/statsig-io/go-sdk/internal/net" - "github.com/statsig-io/go-sdk/types" ) func TestLog(t *testing.T) { @@ -17,27 +14,27 @@ func TestLog(t *testing.T) { })) defer testServer.Close() - net := net.New("secret", testServer.URL, "", "") - logger := New(net) + transport := newTransport("secret", testServer.URL, "", "") + logger := newLogger(transport) - user := types.StatsigUser{ + user := User{ UserID: "123", Email: "123@gmail.com", PrivateAttributes: map[string]interface{}{"private": "shh"}, } - privateUser := types.StatsigUser{ + privateUser := User{ UserID: "123", Email: "123@gmail.com", } // Test custom logs - customEvent := types.StatsigEvent{ + customEvent := Event{ EventName: "test_event", User: user, Value: "3"} - customEventNoPrivate := types.StatsigEvent{ + customEventNoPrivate := Event{ EventName: "test_event", User: privateUser, Value: "3"} - logger.LogCustom(customEvent) + logger.logCustom(customEvent) if !reflect.DeepEqual(logger.events[0], customEventNoPrivate) { t.Errorf("Custom event not logged correctly.") @@ -45,8 +42,8 @@ func TestLog(t *testing.T) { // Test gate exposures exposures := []map[string]string{{"gate": "another_gate", "gateValue": "true", "ruleID": "default"}} - logger.LogGateExposure(user, "test_gate", true, "rule_id", exposures) - gateExposureEvent := exposureEvent{EventName: GateExposureEvent, User: privateUser, Metadata: map[string]string{ + logger.logGateExposure(user, "test_gate", true, "rule_id", exposures) + gateExposureEvent := exposureEvent{EventName: gateExposureEvent, User: privateUser, Metadata: map[string]string{ "gate": "test_gate", "gateValue": strconv.FormatBool(true), "ruleID": "rule_id", @@ -58,8 +55,8 @@ func TestLog(t *testing.T) { // Test config exposures exposures = append(exposures, map[string]string{"gate": "yet_another_gate", "gateValue": "false", "ruleID": ""}) - logger.LogConfigExposure(user, "test_config", "rule_id_config", exposures) - configExposureEvent := exposureEvent{EventName: ConfigExposureEvent, User: privateUser, Metadata: map[string]string{ + logger.logConfigExposure(user, "test_config", "rule_id_config", exposures) + configExposureEvent := exposureEvent{EventName: configExposureEvent, User: privateUser, Metadata: map[string]string{ "config": "test_config", "ruleID": "rule_id_config", }, SecondaryExposures: exposures} diff --git a/statsig.go b/statsig.go index ee25c6e..0dd6101 100644 --- a/statsig.go +++ b/statsig.go @@ -4,33 +4,46 @@ package statsig import ( "fmt" "sync" - - "github.com/statsig-io/go-sdk/types" ) +const DefaultEndpoint = "https://api.statsig.com/v1" + var instance *Client var once sync.Once // Initializes the global Statsig instance with the given sdkKey func Initialize(sdkKey string) { once.Do(func() { - instance = New(sdkKey) + instance = NewClient(sdkKey) }) } +// Advanced options for configuring the Statsig SDK +type Options struct { + API string `json:"api"` + Environment Environment `json:"environment"` +} + +// See https://docs.statsig.com/guides/usingEnvironments +type Environment struct { + Tier string `json:"tier"` + Params map[string]string `json:"params"` +} + // Initializes the global Statsig instance with the given sdkKey and options -func InitializeWithOptions(sdkKey string, options *types.StatsigOptions) { +func InitializeWithOptions(sdkKey string, options *Options) { WrapperSDK(sdkKey, options, "", "") } -func WrapperSDK(sdkKey string, options *types.StatsigOptions, sdkName string, sdkVersion string) { +// Used for interop with SDKs in other languages only. +func WrapperSDK(sdkKey string, options *Options, sdkName string, sdkVersion string) { once.Do(func() { - instance = WrapperSDKInstance(sdkKey, options, sdkName, sdkVersion) + instance = wrapperSDKInstance(sdkKey, options, sdkName, sdkVersion) }) } // Checks the value of a Feature Gate for the given user -func CheckGate(user types.StatsigUser, gate string) bool { +func CheckGate(user User, gate string) bool { if instance == nil { panic(fmt.Errorf("must Initialize() statsig before calling CheckGate")) } @@ -38,7 +51,7 @@ func CheckGate(user types.StatsigUser, gate string) bool { } // Gets the DynamicConfig value for the given user -func GetConfig(user types.StatsigUser, config string) types.DynamicConfig { +func GetConfig(user User, config string) DynamicConfig { if instance == nil { panic(fmt.Errorf("must Initialize() statsig before calling GetConfig")) } @@ -46,7 +59,7 @@ func GetConfig(user types.StatsigUser, config string) types.DynamicConfig { } // Gets the DynamicConfig value of an Experiment for the given user -func GetExperiment(user types.StatsigUser, experiment string) types.DynamicConfig { +func GetExperiment(user User, experiment string) DynamicConfig { if instance == nil { panic(fmt.Errorf("must Initialize() statsig before calling GetExperiment")) } @@ -54,7 +67,7 @@ func GetExperiment(user types.StatsigUser, experiment string) types.DynamicConfi } // Logs an event to the Statsig console -func LogEvent(event types.StatsigEvent) { +func LogEvent(event Event) { if instance == nil { panic(fmt.Errorf("must Initialize() statsig before calling LogEvent")) } diff --git a/internal/evaluation/store.go b/store.go similarity index 53% rename from internal/evaluation/store.go rename to store.go index fa6389f..678702b 100644 --- a/internal/evaluation/store.go +++ b/store.go @@ -1,32 +1,30 @@ -package evaluation +package statsig import ( "encoding/json" "strconv" "time" - - "github.com/statsig-io/go-sdk/internal/net" ) -type ConfigSpec struct { +type configSpec struct { Name string `json:"name"` Type string `json:"type"` Salt string `json:"salt"` Enabled bool `json:"enabled"` - Rules []ConfigRule `json:"rules"` + Rules []configRule `json:"rules"` DefaultValue json.RawMessage `json:"defaultValue"` } -type ConfigRule struct { +type configRule struct { Name string `json:"name"` ID string `json:"id"` Salt string `json:"salt"` PassPercentage float64 `json:"passPercentage"` - Conditions []ConfigCondition `json:"conditions"` + Conditions []configCondition `json:"conditions"` ReturnValue json.RawMessage `json:"returnValue"` } -type ConfigCondition struct { +type configCondition struct { Type string `json:"type"` Operator string `json:"operator"` Field string `json:"field"` @@ -34,31 +32,31 @@ type ConfigCondition struct { AdditionalValues map[string]interface{} `json:"additionalValues"` } -type DownloadConfigSpecResponse struct { +type downloadConfigSpecResponse struct { HasUpdates bool `json:"has_updates"` Time int64 `json:"time"` - FeatureGates []ConfigSpec `json:"feature_gates"` - DynamicConfigs []ConfigSpec `json:"dynamic_configs"` + FeatureGates []configSpec `json:"feature_gates"` + DynamicConfigs []configSpec `json:"dynamic_configs"` } -type DownloadConfigsInput struct { - SinceTime string `json:"sinceTime"` - StatsigMetadata net.StatsigMetadata `json:"statsigMetadata"` +type downloadConfigsInput struct { + SinceTime string `json:"sinceTime"` + StatsigMetadata statsigMetadata `json:"statsigMetadata"` } -type Store struct { - FeatureGates map[string]ConfigSpec - DynamicConfigs map[string]ConfigSpec +type store struct { + featureGates map[string]configSpec + dynamicConfigs map[string]configSpec lastSyncTime int64 - network *net.Net + transport *transport ticker *time.Ticker } -func initStore(n *net.Net) *Store { - store := &Store{ - FeatureGates: make(map[string]ConfigSpec), - DynamicConfigs: make(map[string]ConfigSpec), - network: n, +func newStore(transport *transport) *store { + store := &store{ + featureGates: make(map[string]configSpec), + dynamicConfigs: make(map[string]configSpec), + transport: transport, ticker: time.NewTicker(10 * time.Second), } @@ -68,39 +66,39 @@ func initStore(n *net.Net) *Store { return store } -func (s *Store) StopPolling() { +func (s *store) StopPolling() { s.ticker.Stop() } -func (s *Store) update(specs DownloadConfigSpecResponse) { +func (s *store) update(specs downloadConfigSpecResponse) { if specs.HasUpdates { - newGates := make(map[string]ConfigSpec) + newGates := make(map[string]configSpec) for _, gate := range specs.FeatureGates { newGates[gate.Name] = gate } - newConfigs := make(map[string]ConfigSpec) + newConfigs := make(map[string]configSpec) for _, config := range specs.DynamicConfigs { newConfigs[config.Name] = config } - s.FeatureGates = newGates - s.DynamicConfigs = newConfigs + s.featureGates = newGates + s.dynamicConfigs = newConfigs } } -func (s *Store) fetchConfigSpecs() DownloadConfigSpecResponse { - input := &DownloadConfigsInput{ +func (s *store) fetchConfigSpecs() downloadConfigSpecResponse { + input := &downloadConfigsInput{ SinceTime: strconv.FormatInt(s.lastSyncTime, 10), - StatsigMetadata: s.network.GetStatsigMetadata(), + StatsigMetadata: s.transport.metadata, } - var specs DownloadConfigSpecResponse - s.network.PostRequest("/download_config_specs", input, &specs) + var specs downloadConfigSpecResponse + s.transport.postRequest("/download_config_specs", input, &specs) s.lastSyncTime = specs.Time return specs } -func (s *Store) pollForChanges() { +func (s *store) pollForChanges() { for range s.ticker.C { specs := s.fetchConfigSpecs() s.update(specs) diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..464e83d --- /dev/null +++ b/transport.go @@ -0,0 +1,120 @@ +package statsig + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + maxRetries = 5 + backoffMultiplier = 10 +) + +type statsigMetadata struct { + SDKType string `json:"sdkType"` + SDKVersion string `json:"sdkVersion"` +} + +type transport struct { + api string + sdkKey string + metadata statsigMetadata + client *http.Client +} + +func newTransport(secret string, api string, sdkType string, sdkVersion string) *transport { + api = defaultString(api, DefaultEndpoint) + api = strings.TrimSuffix(api, "/") + sdkType = defaultString(sdkType, "go-sdk") + sdkVersion = defaultString(sdkVersion, "v1.0.0-beta.1") + + return &transport{ + api: api, + metadata: statsigMetadata{SDKType: sdkType, SDKVersion: sdkVersion}, + sdkKey: secret, + client: &http.Client{}, + } +} + +func (transport *transport) postRequest( + endpoint string, + in interface{}, + out interface{}, +) error { + return transport.postRequestInternal(endpoint, in, out, 0, 0) +} + +func (transport *transport) retryablePostRequest( + endpoint string, + in interface{}, + out interface{}, + retries int, +) error { + return transport.postRequestInternal(endpoint, in, out, retries, time.Second) +} + +func (transport *transport) postRequestInternal( + endpoint string, + in interface{}, + out interface{}, + retries int, + backoff time.Duration, +) error { + body, err := json.Marshal(in) + if err != nil { + return err + } + + return retry(retries, time.Duration(backoff), func() (bool, error) { + req, err := http.NewRequest("POST", transport.api+endpoint, bytes.NewBuffer(body)) + if err != nil { + return false, err + } + + req.Header.Add("STATSIG-API-KEY", transport.sdkKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Add("STATSIG-CLIENT-TIME", strconv.FormatInt(time.Now().Unix()*1000, 10)) + + response, err := transport.client.Do(req) + if err != nil { + return true, err + } + defer response.Body.Close() + + if response.StatusCode >= 200 && response.StatusCode < 300 { + return false, json.NewDecoder(response.Body).Decode(&out) + } + + return shouldRetry(response.StatusCode), fmt.Errorf("http response error code: %d", response.StatusCode) + }) +} + +func retry(retries int, backoff time.Duration, fn func() (bool, error)) error { + for { + if retry, err := fn(); retry { + if retries <= 0 { + return err + } + + retries-- + time.Sleep(backoff) + backoff = backoff * backoffMultiplier + } else { + return err + } + } +} + +func shouldRetry(code int) bool { + switch code { + case 408, 500, 502, 503, 504, 522, 524, 599: + return true + default: + return false + } +} diff --git a/internal/net/net_test.go b/transport_test.go similarity index 83% rename from internal/net/net_test.go rename to transport_test.go index 72b6c43..92cd0ee 100644 --- a/internal/net/net_test.go +++ b/transport_test.go @@ -1,4 +1,4 @@ -package net +package statsig import ( "encoding/json" @@ -24,8 +24,8 @@ func TestNonRetryable(t *testing.T) { defer testServer.Close() in := Empty{} var out ServerResponse - n := New("secret-123", testServer.URL, "", "") - err := n.RetryablePostRequest("/123", in, &out, 2) + n := newTransport("secret-123", testServer.URL, "", "") + err := n.retryablePostRequest("/123", in, &out, 2) if err == nil { t.Errorf("Expected error for network request but got nil") } @@ -50,8 +50,8 @@ func TestRetries(t *testing.T) { defer func() { testServer.Close() }() in := Empty{} var out ServerResponse - n := New("secret-123", testServer.URL, "", "") - err := n.RetryablePostRequest("/123", in, out, 2) + n := newTransport("secret-123", testServer.URL, "", "") + err := n.retryablePostRequest("/123", in, out, 2) if err != nil { t.Errorf("Expected successful request but got error") } diff --git a/types/dynamic_config.go b/types.go similarity index 62% rename from types/dynamic_config.go rename to types.go index f523736..89f1870 100644 --- a/types/dynamic_config.go +++ b/types.go @@ -1,4 +1,30 @@ -package types +package statsig + +// User specific attributes for evaluating Feature Gates, Experiments, and DyanmicConfigs +// +// NOTE: UserID is **required** - see https://docs.statsig.com/messages/serverRequiredUserID\ +// PrivateAttributes are only used for user targeting/grouping in feature gates, dynamic configs, +// experiments and etc; they are omitted in logs. +type User struct { + UserID string `json:"userID"` + Email string `json:"email"` + IpAddress string `json:"ip"` + UserAgent string `json:"userAgent"` + Country string `json:"country"` + Locale string `json:"locale"` + AppVersion string `json:"appVersion"` + Custom map[string]interface{} `json:"custom"` + PrivateAttributes map[string]interface{} `json:"privateAttributes"` + StatsigEnvironment map[string]string `json:"statsigEnvironment"` +} + +// an event to be sent to Statsig for logging and analysis +type Event struct { + EventName string `json:"eventName"` + User User `json:"user"` + Value string `json:"value"` + Metadata map[string]string `json:"metadata"` +} // A json blob configured in the Statsig Console type DynamicConfig struct { diff --git a/types/statsig_event.go b/types/statsig_event.go deleted file mode 100644 index 7887011..0000000 --- a/types/statsig_event.go +++ /dev/null @@ -1,9 +0,0 @@ -package types - -// A log event sent to Statsig for analysis -type StatsigEvent struct { - EventName string `json:"eventName"` - User StatsigUser `json:"user"` - Value string `json:"value"` - Metadata map[string]string `json:"metadata"` -} diff --git a/types/statsig_options.go b/types/statsig_options.go deleted file mode 100644 index 33121eb..0000000 --- a/types/statsig_options.go +++ /dev/null @@ -1,13 +0,0 @@ -package types - -// Advanced options for configuring the Statsig SDK -type StatsigOptions struct { - API string `json:"api"` - Environment StatsigEnvironment `json:"environment"` -} - -// See https://docs.statsig.com/guides/usingEnvironments -type StatsigEnvironment struct { - Tier string `json:"tier"` - Params map[string]string `json:"params"` -} diff --git a/types/user.go b/types/user.go deleted file mode 100644 index cdf3fbe..0000000 --- a/types/user.go +++ /dev/null @@ -1,17 +0,0 @@ -package types - -// User specific attributes for evaluating Feature Gates, Experiments, and DyanmicConfigs -// NOTE: UserID is **required** - see https://docs.statsig.com/messages/serverRequiredUserID\ -// PrivateAttributes are only used for user targeting/grouping in feature gates, dynamic configs, experiments and etc; they are omitted in logs. -type StatsigUser struct { - UserID string `json:"userID"` - Email string `json:"email"` - IpAddress string `json:"ip"` - UserAgent string `json:"userAgent"` - Country string `json:"country"` - Locale string `json:"locale"` - AppVersion string `json:"appVersion"` - Custom map[string]interface{} `json:"custom"` - PrivateAttributes map[string]interface{} `json:"privateAttributes"` - StatsigEnvironment map[string]string `json:"statsigEnvironment"` -} diff --git a/types/dynamic_config_test.go b/types_test.go similarity index 99% rename from types/dynamic_config_test.go rename to types_test.go index fda337b..afac270 100644 --- a/types/dynamic_config_test.go +++ b/types_test.go @@ -1,4 +1,4 @@ -package types +package statsig import ( "encoding/json" diff --git a/util.go b/util.go new file mode 100644 index 0000000..48891f8 --- /dev/null +++ b/util.go @@ -0,0 +1,8 @@ +package statsig + +func defaultString(v, d string) string { + if v == "" { + return d + } + return v +}