diff --git a/config.go b/config.go index a654c3d..d9359c0 100644 --- a/config.go +++ b/config.go @@ -51,10 +51,10 @@ type Config struct { // If none is specified the client uses `http.DefaultTransport`. Transport http.RoundTripper - // The logger used by the client to output info or error messages when that + // Logger used by the client to output info or error messages when that // are generated by background operations. // If none is specified the client uses a standard logger that outputs to - // `os.Stderr`. + // `os.Stderr`. Override this to suppress log messages. Logger Logger // Properties that will be included in every event sent by the client. @@ -169,7 +169,7 @@ func makeConfig(c Config) Config { } if c.Logger == nil { - c.Logger = newDefaultLogger() + c.Logger = newDefaultLogger(c.Verbose) } if c.BatchSize == 0 { diff --git a/examples/featureflags.go b/examples/featureflags.go index 9ac669e..0051eba 100644 --- a/examples/featureflags.go +++ b/examples/featureflags.go @@ -9,7 +9,7 @@ import ( ) func TestIsFeatureEnabled(projectAPIKey, personalAPIKey, endpoint string) { - client, _ := posthog.NewWithConfig(projectAPIKey, posthog.Config{ + client, err := posthog.NewWithConfig(projectAPIKey, posthog.Config{ Interval: 30 * time.Second, BatchSize: 100, Verbose: true, @@ -18,6 +18,10 @@ func TestIsFeatureEnabled(projectAPIKey, personalAPIKey, endpoint string) { DefaultFeatureFlagsPollingInterval: 5 * time.Second, FeatureFlagRequestTimeout: 3 * time.Second, }) + if err != nil { + fmt.Println("error:", err) + return + } defer client.Close() boolResult, boolErr := client.IsFeatureEnabled( diff --git a/feature_flags_local_test.go b/feature_flags_local_test.go index eee0e39..ae2166d 100644 --- a/feature_flags_local_test.go +++ b/feature_flags_local_test.go @@ -4547,11 +4547,12 @@ func TestFlagWithTimeoutExceeded(t *testing.T) { })) defer server.Close() - client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{ + client, err := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{ PersonalApiKey: "some very secret key", Endpoint: server.URL, FeatureFlagRequestTimeout: 10 * time.Millisecond, }) + require.NoError(t, err) defer client.Close() isMatch, err := client.IsFeatureEnabled( @@ -4634,7 +4635,7 @@ func TestFlagDefinitionsWithTimeoutExceeded(t *testing.T) { PersonalApiKey: "some very secret key", Endpoint: server.URL, FeatureFlagRequestTimeout: 10 * time.Millisecond, - Logger: StdLogger(log.New(&buf, "posthog-test", log.LstdFlags)), + Logger: StdLogger(log.New(&buf, "posthog-test", log.LstdFlags), false), }) defer client.Close() diff --git a/featureflags.go b/featureflags.go index 26b3d22..98c8d6f 100644 --- a/featureflags.go +++ b/featureflags.go @@ -32,7 +32,8 @@ type FeatureFlagsPoller struct { groups map[string]string personalApiKey string projectApiKey string - Errorf func(format string, args ...interface{}) + localEvalUrl *url.URL + Logger Logger Endpoint string http http.Client mutex sync.RWMutex @@ -112,8 +113,18 @@ type DecideResponse struct { FeatureFlagPayloads map[string]string `json:"featureFlagPayloads"` } +type matchErrorCode int + +var ( + flagExperienceContinuityEnabled matchErrorCode = 1 + missingPropertyValue matchErrorCode = 2 // property was not provided in a call + propertyIsNotSet matchErrorCode = 3 // maybe on server this property is set + unknownOperator matchErrorCode = 4 // if you encountered that, file an issue in our GitHub +) + type InconclusiveMatchError struct { - msg string + code matchErrorCode + msg string } func (e *InconclusiveMatchError) Error() string { @@ -123,7 +134,7 @@ func (e *InconclusiveMatchError) Error() string { func newFeatureFlagsPoller( projectApiKey string, personalApiKey string, - errorf func(format string, args ...interface{}), + logger Logger, endpoint string, httpClient http.Client, pollingInterval time.Duration, @@ -131,7 +142,13 @@ func newFeatureFlagsPoller( flagTimeout time.Duration, decider decider, disableGeoIP bool, -) *FeatureFlagsPoller { +) (*FeatureFlagsPoller, error) { + localEvaluationEndpoint := "/api/feature_flag/local_evaluation" + localEvalURL, err := url.Parse(endpoint + localEvaluationEndpoint) + if err != nil { + return nil, fmt.Errorf("creating local evaluation URL - %w", err) + } + if nextPollTick == nil { nextPollTick = func() time.Duration { return pollingInterval } } @@ -142,7 +159,8 @@ func newFeatureFlagsPoller( forceReload: make(chan bool), personalApiKey: personalApiKey, projectApiKey: projectApiKey, - Errorf: errorf, + localEvalUrl: localEvalURL, + Logger: logger, Endpoint: endpoint, http: httpClient, mutex: sync.RWMutex{}, @@ -153,7 +171,7 @@ func newFeatureFlagsPoller( } go poller.run() - return &poller + return &poller, nil } func (poller *FeatureFlagsPoller) run() { @@ -182,13 +200,13 @@ func (poller *FeatureFlagsPoller) run() { // the feature flags fetched from the flags API. func (poller *FeatureFlagsPoller) fetchNewFeatureFlags() { personalApiKey := poller.personalApiKey - headers := [][2]string{{"Authorization", "Bearer " + personalApiKey + ""}} + headers := http.Header{"Authorization": []string{"Bearer " + personalApiKey}} res, cancel, err := poller.localEvaluationFlags(headers) - defer cancel() if err != nil { - poller.Errorf("Unable to fetch feature flags: %s", err) + poller.Logger.Errorf("Unable to fetch feature flags: %s", err) return } + defer cancel() // Handle quota limit response (HTTP 402) if res.StatusCode == http.StatusPaymentRequired { @@ -198,36 +216,43 @@ func (poller *FeatureFlagsPoller) fetchNewFeatureFlags() { poller.cohorts = map[string]PropertyGroup{} poller.groups = map[string]string{} poller.mutex.Unlock() - poller.Errorf("[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts") + poller.Logger.Warnf("[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts") return } if res.StatusCode != http.StatusOK { - poller.Errorf("Unable to fetch feature flags, status: %s", res.Status) + poller.Logger.Errorf("Unable to fetch feature flags, status: %s", res.Status) return } defer res.Body.Close() resBody, err := io.ReadAll(res.Body) if err != nil { - poller.Errorf("Unable to fetch feature flags: %s", err) + poller.Logger.Errorf("Unable to fetch feature flags: %s", err) return } - featureFlagsResponse := FeatureFlagsResponse{} - err = json.Unmarshal([]byte(resBody), &featureFlagsResponse) - if err != nil { - poller.Errorf("Unable to unmarshal response from api/feature_flag/local_evaluation: %s", err) + var featureFlagsResponse FeatureFlagsResponse + if err = json.Unmarshal(resBody, &featureFlagsResponse); err != nil { + poller.Logger.Errorf("Unable to unmarshal response from api/feature_flag/local_evaluation: %s", err) return } - newFlags := []FeatureFlag{} - newFlags = append(newFlags, featureFlagsResponse.Flags...) + newFlags := append([]FeatureFlag(nil), featureFlagsResponse.Flags...) poller.mutex.Lock() + defer poller.mutex.Unlock() poller.featureFlags = newFlags poller.cohorts = featureFlagsResponse.Cohorts if featureFlagsResponse.GroupTypeMapping != nil { poller.groups = *featureFlagsResponse.GroupTypeMapping } - poller.mutex.Unlock() +} + +func (poller *FeatureFlagsPoller) logComputeFlagLocallyErr(key string, onlyLocal bool, err error) { + var dest *InconclusiveMatchError + if errors.As(err, &dest) && onlyLocal { + poller.Logger.Errorf("Unable to compute flag locally (%s) - %s", key, err) + } else { + poller.Logger.Warnf("Unable to compute flag locally (%s) - %s", key, err) + } } func (poller *FeatureFlagsPoller) GetFeatureFlag(flagConfig FeatureFlagPayload) (interface{}, error) { @@ -244,14 +269,14 @@ func (poller *FeatureFlagsPoller) GetFeatureFlag(flagConfig FeatureFlagPayload) flagConfig.GroupProperties, poller.cohorts, ) - } - - if err != nil { - poller.Errorf("Unable to compute flag locally (%s) - %s", flag.Key, err) + if err != nil { + poller.logComputeFlagLocallyErr(flagConfig.Key, flagConfig.OnlyEvaluateLocally, err) + } } if (err != nil || result == nil) && !flagConfig.OnlyEvaluateLocally { - result, err = poller.getFeatureFlagVariant(flag, flagConfig.Key, flagConfig.DistinctId, flagConfig.Groups, flagConfig.PersonProperties, flagConfig.GroupProperties) + result, err = poller.getFeatureFlagVariant(flagConfig.Key, flagConfig.DistinctId, flagConfig.Groups, + flagConfig.PersonProperties, flagConfig.GroupProperties) if err != nil { return nil, err } @@ -276,10 +301,8 @@ func (poller *FeatureFlagsPoller) GetFeatureFlagPayload(flagConfig FeatureFlagPa ) } if err != nil { - poller.Errorf("Unable to compute flag locally (%s) - %s", flag.Key, err) - } - - if variant != nil { + poller.logComputeFlagLocallyErr(flagConfig.Key, flagConfig.OnlyEvaluateLocally, err) + } else if variant != nil { payload, ok := flag.Filters.Payloads[fmt.Sprintf("%v", variant)] if ok { return payload, nil @@ -299,11 +322,12 @@ func (poller *FeatureFlagsPoller) GetFeatureFlagPayload(flagConfig FeatureFlagPa } func (poller *FeatureFlagsPoller) getFeatureFlag(flagConfig FeatureFlagPayload) (FeatureFlag, error) { + var featureFlag FeatureFlag featureFlags, err := poller.GetFeatureFlags() if err != nil { - return FeatureFlag{}, err + return featureFlag, err } - var featureFlag FeatureFlag + // avoid using flag for conflicts with Golang's stdlib `flag` for _, storedFlag := range featureFlags { if flagConfig.Key == storedFlag.Key { @@ -337,8 +361,9 @@ func (poller *FeatureFlagsPoller) GetAllFlags(flagConfig FeatureFlagPayloadNoKey cohorts, ) if err != nil { - poller.Errorf("Unable to compute flag locally (%s) - %s", storedFlag.Key, err) + poller.logComputeFlagLocallyErr(storedFlag.Key, flagConfig.OnlyEvaluateLocally, err) fallbackToDecide = true + // TODO we lose this error, never return it } else { response[storedFlag.Key] = result } @@ -365,6 +390,7 @@ func (poller *FeatureFlagsPoller) GetAllFlags(flagConfig FeatureFlagPayloadNoKey return response, nil } + func (poller *FeatureFlagsPoller) computeFlagLocally( flag FeatureFlag, distinctId string, @@ -374,7 +400,7 @@ func (poller *FeatureFlagsPoller) computeFlagLocally( cohorts map[string]PropertyGroup, ) (interface{}, error) { if flag.EnsureExperienceContinuity != nil && *flag.EnsureExperienceContinuity { - return nil, &InconclusiveMatchError{"Flag has experience continuity enabled"} + return nil, &InconclusiveMatchError{flagExperienceContinuityEnabled, "Flag has experience continuity enabled"} } if !flag.Active { @@ -415,7 +441,7 @@ func getMatchingVariant(flag FeatureFlag, distinctId string) interface{} { hashValue := calculateHash(flag.Key+".", distinctId, "variant") for _, variant := range lookupTable { - if hashValue >= float64(variant.ValueMin) && hashValue < float64(variant.ValueMax) { + if hashValue >= variant.ValueMin && hashValue < variant.ValueMax { return variant.Key } } @@ -471,21 +497,23 @@ func matchFeatureFlagProperties( return iValue < jValue }) + var inconclusiveErrors []error for _, condition := range sortedConditions { isMatch, err := isConditionMatch(flag, distinctId, condition, properties, cohorts) if err != nil { - if _, ok := err.(*InconclusiveMatchError); ok { + if errors.Is(err, &InconclusiveMatchError{}) { isInconclusive = true + inconclusiveErrors = append(inconclusiveErrors, err) } else { return nil, err } - } - - if isMatch { + } else if isMatch { variantOverride := condition.Variant multivariates := flag.Filters.Multivariate - if variantOverride != nil && multivariates != nil && multivariates.Variants != nil && containsVariant(multivariates.Variants, *variantOverride) { + if variantOverride != nil && multivariates != nil && multivariates.Variants != nil && + containsVariant(multivariates.Variants, *variantOverride) { + return *variantOverride, nil } else { return getMatchingVariant(flag, distinctId), nil @@ -494,7 +522,7 @@ func matchFeatureFlagProperties( } if isInconclusive { - return false, &InconclusiveMatchError{"Can't determine if feature flag is enabled or not with given properties"} + return false, errors.Join(inconclusiveErrors...) } return false, nil @@ -640,11 +668,11 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) { operator := property.Operator value := property.Value if _, ok := properties[key]; !ok { - return false, &InconclusiveMatchError{"Can't match properties without a given property value"} + return false, &InconclusiveMatchError{missingPropertyValue, "Can't match properties without a given property value"} } if operator == "is_not_set" { - return false, &InconclusiveMatchError{"Can't match properties with operator is_not_set"} + return false, &InconclusiveMatchError{propertyIsNotSet, "Can't match properties with operator is_not_set"} } override_value := properties[key] @@ -769,7 +797,7 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) { return overrideValueOrderable <= valueOrderable, nil } - return false, &InconclusiveMatchError{"Unknown operator: " + operator} + return false, &InconclusiveMatchError{unknownOperator, "Unknown operator: " + operator} } @@ -865,27 +893,25 @@ func (poller *FeatureFlagsPoller) GetFeatureFlags() ([]FeatureFlag, error) { return poller.featureFlags, nil } -func (poller *FeatureFlagsPoller) localEvaluationFlags(headers [][2]string) (*http.Response, context.CancelFunc, error) { - localEvaluationEndpoint := "api/feature_flag/local_evaluation" - - url, err := url.Parse(poller.Endpoint + "/" + localEvaluationEndpoint + "") - if err != nil { - poller.Errorf("creating url - %s", err) - } - searchParams := url.Query() +func (poller *FeatureFlagsPoller) localEvaluationFlags(headers http.Header) (*http.Response, context.CancelFunc, error) { + var u url.URL + u = *poller.localEvalUrl + searchParams := u.Query() searchParams.Add("token", poller.projectApiKey) searchParams.Add("send_cohorts", "true") - url.RawQuery = searchParams.Encode() + u.RawQuery = searchParams.Encode() - return poller.request("GET", url, []byte{}, headers, time.Duration(10)*time.Second) + return poller.request("GET", u.String(), nil, headers, poller.flagTimeout) } -func (poller *FeatureFlagsPoller) request(method string, url *url.URL, requestData []byte, headers [][2]string, timeout time.Duration) (*http.Response, context.CancelFunc, error) { +func (poller *FeatureFlagsPoller) request(method string, reqUrl string, requestData []byte, headers http.Header, timeout time.Duration) (*http.Response, context.CancelFunc, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) - req, err := http.NewRequestWithContext(ctx, method, url.String(), bytes.NewReader(requestData)) + req, err := http.NewRequestWithContext(ctx, method, reqUrl, bytes.NewReader(requestData)) if err != nil { - poller.Errorf("creating request - %s", err) + poller.Logger.Errorf("creating request - %s", err) + cancel() + return nil, nil, err } version := getVersion() @@ -894,13 +920,13 @@ func (poller *FeatureFlagsPoller) request(method string, url *url.URL, requestDa req.Header.Add("Content-Type", "application/json") req.Header.Add("Content-Length", fmt.Sprintf("%d", len(requestData))) - for _, header := range headers { - req.Header.Add(header[0], header[1]) + for key, val := range headers { + req.Header[key] = val } res, err := poller.http.Do(req) if err != nil { - poller.Errorf("sending request - %s", err) + poller.Logger.Errorf("sending request - %s", err) } return res, cancel, err @@ -922,7 +948,7 @@ func (poller *FeatureFlagsPoller) getFeatureFlagVariants(distinctId string, grou return poller.decider.makeFlagsRequest(distinctId, groups, personProperties, groupProperties, poller.disableGeoIP) } -func (poller *FeatureFlagsPoller) getFeatureFlagVariant(featureFlag FeatureFlag, key string, distinctId string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (interface{}, error) { +func (poller *FeatureFlagsPoller) getFeatureFlagVariant(key string, distinctId string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (interface{}, error) { var result interface{} = false flagsResponse, variantErr := poller.getFeatureFlagVariants(distinctId, groups, personProperties, groupProperties) diff --git a/flags.go b/flags.go index a7df74a..ebe8352 100644 --- a/flags.go +++ b/flags.go @@ -93,9 +93,9 @@ type FlagsResponse struct { // CommonResponseFields contains fields common to all decide response versions type CommonResponseFields struct { - QuotaLimited *[]string `json:"quota_limited"` - RequestId string `json:"requestId"` - ErrorsWhileComputingFlags bool `json:"errorsWhileComputingFlags"` + QuotaLimited []string `json:"quota_limited"` + RequestId string `json:"requestId"` + ErrorsWhileComputingFlags bool `json:"errorsWhileComputingFlags"` } // UnmarshalJSON implements custom unmarshaling to handle both v3 and v4 formats @@ -165,20 +165,27 @@ type flagsClient struct { endpoint string http http.Client featureFlagRequestTimeout time.Duration - errorf func(format string, args ...interface{}) + logger Logger } // newFlagsClient creates a new flagsClient -func newFlagsClient(apiKey string, endpoint string, httpClient http.Client, featureFlagRequestTimeout time.Duration, - errorf func(format string, args ...interface{})) *flagsClient { +func newFlagsClient(apiKey string, endpoint string, httpClient http.Client, + featureFlagRequestTimeout time.Duration, logger Logger) (*flagsClient, error) { + + // Try v2 endpoint first + flagsEndpoint := "flags/?v=2" + flagsEndpointURL, err := url.Parse(endpoint + "/" + flagsEndpoint) + if err != nil { + return nil, fmt.Errorf("creating url: %v", err) + } return &flagsClient{ apiKey: apiKey, - endpoint: endpoint, + endpoint: flagsEndpointURL.String(), http: httpClient, featureFlagRequestTimeout: featureFlagRequestTimeout, - errorf: errorf, - } + logger: logger, + }, nil } // makeFlagsRequest makes a request to the flags endpoint and deserializes the response @@ -199,14 +206,7 @@ func (d *flagsClient) makeFlagsRequest(distinctId string, groups Groups, personP return nil, fmt.Errorf("unable to marshal flags endpoint request data: %v", err) } - // Try v2 endpoint first - flagsEndpoint := "flags/?v=2" - url, err := url.Parse(d.endpoint + "/" + flagsEndpoint) - if err != nil { - return nil, fmt.Errorf("creating url: %v", err) - } - - req, err := http.NewRequest("POST", url.String(), bytes.NewReader(requestDataBytes)) + req, err := http.NewRequest("POST", d.endpoint, bytes.NewReader(requestDataBytes)) if err != nil { return nil, fmt.Errorf("creating request: %v", err) } @@ -241,7 +241,7 @@ func (d *flagsClient) makeFlagsRequest(distinctId string, groups Groups, personP } if flagsResponse.ErrorsWhileComputingFlags { - d.errorf("error while computing feature flags, some flags may be missing or incorrect. Learn more at https://posthog.com/docs/feature-flags/best-practices") + d.logger.Errorf("error while computing feature flags, some flags may be missing or incorrect. Learn more at https://posthog.com/docs/feature-flags/best-practices") } return &flagsResponse, nil diff --git a/logger.go b/logger.go index e1a70f1..1cc1610 100644 --- a/logger.go +++ b/logger.go @@ -5,18 +5,25 @@ import ( "os" ) -// Instances of types implementing this interface can be used to define where -// the posthog client logs are written. +// Logger defines an interface for a logger used by the PostHog clientś. type Logger interface { + // Debugf is called by PostHog client to log debug messages about the + // operations they perform. Messages logged by this method are usually + // tagged with an `DEBUG` log level in common logging libraries. + Debugf(format string, args ...interface{}) - // PostHog clients call this method to log regular messages about the - // operations they perform. - // Messages logged by this method are usually tagged with an `INFO` log - // level in common logging libraries. + // Logf is called by PostHog client to log regular messages about the + // operations they perform. Messages logged by this method are usually + // tagged with an `INFO` log level in common logging libraries. Logf(format string, args ...interface{}) - // PostHog clients call this method to log errors they encounter while - // sending events to the backend servers. + // Warnf is called by PostHog client to log warning messages about + // the operations they perform. Messages logged by this method are usually + // tagged with an `WARN` log level in common logging libraries. + Warnf(format string, args ...interface{}) + + // Errorf is called by PostHog clients call this method to log errors + // they encounter while sending events to the backend servers. // Messages logged by this method are usually tagged with an `ERROR` log // level in common logging libraries. Errorf(format string, args ...interface{}) @@ -24,24 +31,36 @@ type Logger interface { // This function instantiate an object that statisfies the posthog.Logger // interface and send logs to standard logger passed as argument. -func StdLogger(logger *log.Logger) Logger { +func StdLogger(logger *log.Logger, verbose bool) Logger { return stdLogger{ - logger: logger, + logger: logger, + verbose: verbose, } } type stdLogger struct { - logger *log.Logger + logger *log.Logger + verbose bool +} + +func (l stdLogger) Debugf(format string, args ...interface{}) { + if l.verbose { + l.logger.Printf("DEBUG: "+format, args...) + } } func (l stdLogger) Logf(format string, args ...interface{}) { l.logger.Printf("INFO: "+format, args...) } +func (l stdLogger) Warnf(format string, args ...interface{}) { + l.logger.Printf("WARN: "+format, args...) +} + func (l stdLogger) Errorf(format string, args ...interface{}) { l.logger.Printf("ERROR: "+format, args...) } -func newDefaultLogger() Logger { - return StdLogger(log.New(os.Stderr, "posthog ", log.LstdFlags)) +func newDefaultLogger(verbose bool) Logger { + return StdLogger(log.New(os.Stderr, "posthog ", log.LstdFlags), verbose) } diff --git a/logger_test.go b/logger_test.go index 882b76b..7643392 100644 --- a/logger_test.go +++ b/logger_test.go @@ -7,19 +7,41 @@ import ( "testing" ) +type loggerFromTest testing.T + +func (l *loggerFromTest) Debugf(format string, args ...interface{}) { + (*testing.T)(l).Logf(format, args...) +} + +func (l *loggerFromTest) Logf(format string, args ...interface{}) { + (*testing.T)(l).Logf(format, args...) +} + +func (l *loggerFromTest) Warnf(format string, args ...interface{}) { + (*testing.T)(l).Logf(format, args...) +} + +func (l *loggerFromTest) Errorf(format string, args ...interface{}) { + (*testing.T)(l).Errorf(format, args...) +} + +func toLogger(t *testing.T) Logger { + return Logger((*loggerFromTest)(t)) +} + // This test ensures that the interface doesn't get changed and stays compatible // with the *testing.T type. // If someone were to modify the interface in backward incompatible manner this // test would break. func TestTestingLogger(t *testing.T) { - _ = Logger(t) + _ = Logger((*loggerFromTest)(t)) } // This test ensures the standard logger shim to the Logger interface is working // as expected. func TestStdLogger(t *testing.T) { var buffer bytes.Buffer - var logger = StdLogger(log.New(&buffer, "test ", 0)) + var logger = StdLogger(log.New(&buffer, "test ", 0), false) logger.Logf("Hello World!") logger.Logf("The answer is %d", 42) diff --git a/posthog.go b/posthog.go index 6def9bd..f704281 100644 --- a/posthog.go +++ b/posthog.go @@ -121,8 +121,8 @@ func New(apiKey string) Client { return c } -// Instantiate a new client that uses the write key and configuration passed as -// arguments to send messages to the backend. +// NewWithConfig instantiate a new client that uses the write key and configuration passed +// as arguments to send messages to the backend. // The function will return an error if the configuration contained impossible // values (like a negative flush interval for example). // When the function returns an error the returned client will always be nil. @@ -146,13 +146,16 @@ func NewWithConfig(apiKey string, config Config) (cli Client, err error) { distinctIdsFeatureFlagsReported: reportedCache, } - c.decider = newFlagsClient(apiKey, config.Endpoint, c.http, config.FeatureFlagRequestTimeout, c.Errorf) + c.decider, err = newFlagsClient(apiKey, config.Endpoint, c.http, config.FeatureFlagRequestTimeout, c.Logger) + if err != nil { + return nil, fmt.Errorf("error creating flags client: %v", err) + } if len(c.PersonalApiKey) > 0 { - c.featureFlagsPoller = newFeatureFlagsPoller( + c.featureFlagsPoller, err = newFeatureFlagsPoller( c.key, c.Config.PersonalApiKey, - c.Errorf, + c.Logger, c.Endpoint, c.http, c.DefaultFeatureFlagsPollingInterval, @@ -161,6 +164,9 @@ func NewWithConfig(apiKey string, config Config) (cli Client, err error) { c.decider, c.Config.GetDisableGeoIP(), ) + if err != nil { + return nil, err + } } go c.loop() @@ -408,14 +414,17 @@ func (c *client) GetRemoteConfigPayload(flagKey string) (string, error) { return c.makeRemoteConfigRequest(flagKey) } +// ErrNoPersonalAPIKey is returned when oen tries to use feature flags without specifying a PersonalAPIKey +var ErrNoPersonalAPIKey = errors.New("no PersonalAPIKey provided") + // GetFeatureFlags returns all feature flag definitions used for local evaluation // This is only available when using a PersonalApiKey. Not to be confused with // GetAllFlags, which returns all flags and their values for a given user. func (c *client) GetFeatureFlags() ([]FeatureFlag, error) { if c.featureFlagsPoller == nil { - errorMessage := "specifying a PersonalApiKey is required for using feature flags" - c.Errorf(errorMessage) - return nil, errors.New(errorMessage) + err := fmt.Errorf("cannot use feature flags: %w", ErrNoPersonalAPIKey) + c.Logger.Debugf(err.Error()) + return nil, err } return c.featureFlagsPoller.GetFeatureFlags() } @@ -558,7 +567,7 @@ func (c *client) report(res *http.Response) (err error) { return } - c.logf("response %d %s – %s", res.StatusCode, res.Status, string(body)) + c.Logger.Logf("response %d %s – %s", res.StatusCode, res.Status, string(body)) return fmt.Errorf("%d %s", res.StatusCode, res.Status) } @@ -634,13 +643,7 @@ func (c *client) flush(q *messageQueue, wg *sync.WaitGroup, ex *executor) { } func (c *client) debugf(format string, args ...interface{}) { - if c.Verbose { - c.logf(format, args...) - } -} - -func (c *client) logf(format string, args ...interface{}) { - c.Logger.Logf(format, args...) + c.Logger.Debugf(format, args...) } func (c *client) Errorf(format string, args ...interface{}) { @@ -724,12 +727,13 @@ func (c *client) makeRemoteConfigRequest(flagKey string) (string, error) { // isFeatureFlagsQuotaLimited checks if feature flags are quota limited in the flags response func (c *client) isFeatureFlagsQuotaLimited(flagsResponse *FlagsResponse) bool { - if flagsResponse.QuotaLimited != nil { - for _, limitedFeature := range *flagsResponse.QuotaLimited { - if limitedFeature == "feature_flags" { - c.Errorf("[FEATURE FLAGS] PostHog feature flags quota limited. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts") - return true - } + if flagsResponse.QuotaLimited == nil { + return false + } + for _, limitedFeature := range flagsResponse.QuotaLimited { + if limitedFeature == "feature_flags" { + c.Logger.Warnf("[FEATURE FLAGS] PostHog feature flags quota limited. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts") + return true } } return false diff --git a/posthog_test.go b/posthog_test.go index 76085c7..52e0855 100644 --- a/posthog_test.go +++ b/posthog_test.go @@ -57,12 +57,24 @@ type testLogger struct { errorf func(string, ...interface{}) } +func (l testLogger) Debugf(format string, args ...interface{}) { + if l.logf != nil { + l.logf(format, args...) + } +} + func (l testLogger) Logf(format string, args ...interface{}) { if l.logf != nil { l.logf(format, args...) } } +func (l testLogger) Warnf(format string, args ...interface{}) { + if l.logf != nil { + l.logf(format, args...) + } +} + func (l testLogger) Errorf(format string, args ...interface{}) { if l.errorf != nil { l.errorf(format, args...) @@ -397,7 +409,7 @@ func TestEnqueue(t *testing.T) { client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{ Endpoint: server.URL, Verbose: true, - Logger: t, + Logger: toLogger(t), BatchSize: 1, now: mockTime, DisableGeoIP: test.disableGeoIP, @@ -437,10 +449,9 @@ func (c *customMessage) APIfy() APIMessage { func TestEnqueuingCustomTypeFails(t *testing.T) { client := New("0123456789") err := client.Enqueue(&customMessage{}) - - if err.Error() != "messages with custom types cannot be enqueued: *posthog.customMessage" { - t.Errorf("invalid/missing error when queuing unsupported message: %v", err) - } + require.Error(t, err) + require.EqualError(t, err, "messages with custom types cannot be enqueued: *posthog.customMessage", + "invalid/missing error when queuing unsupported message") } func TestCaptureWithInterval(t *testing.T) { @@ -456,7 +467,7 @@ func TestCaptureWithInterval(t *testing.T) { Endpoint: server.URL, Interval: interval, Verbose: true, - Logger: t, + Logger: toLogger(t), now: mockTime, }) defer client.Close() @@ -491,7 +502,7 @@ func TestCaptureWithTimestamp(t *testing.T) { client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{ Endpoint: server.URL, Verbose: true, - Logger: t, + Logger: toLogger(t), BatchSize: 1, now: mockTime, }) @@ -524,7 +535,7 @@ func TestCaptureWithDefaultProperties(t *testing.T) { Endpoint: server.URL, Verbose: true, DefaultEventProperties: NewProperties().Set("service", "api"), - Logger: t, + Logger: toLogger(t), BatchSize: 1, now: mockTime, }) @@ -556,7 +567,7 @@ func TestCaptureMany(t *testing.T) { client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{ Endpoint: server.URL, Verbose: true, - Logger: t, + Logger: toLogger(t), BatchSize: 3, now: mockTime, }) @@ -1741,7 +1752,7 @@ func TestCaptureSendFlags(t *testing.T) { client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{ Endpoint: server.URL, Verbose: true, - Logger: t, + Logger: toLogger(t), BatchSize: 1, now: mockTime,