Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(flags): fall back to /decide endpoint for GetFeatureFlag and GetAllFlags so that users can use this library without needing a personal API Key #46

Merged
merged 5 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
# 2.0.0 - 2022-08-15
# Changelog

## 2.0.1 - 2024-08-07

1. The client will fall back to the `/decide` endpoint when evaluating feature flags if the user does not wish to provide a PersonalApiKey. This fixes an issue where users were unable to use this SDK without providing a PersonalApiKey. This fallback will make feature flag usage less performant, but will save users money by not making them pay for public API access.

## 2.0.0 - 2022-08-15

Breaking changes:

Expand Down
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ import (
)

func main() {
client := posthog.New(os.Getenv("POSTHOG_API_KEY"))
client := posthog.New(os.Getenv("POSTHOG_API_KEY")) // This value must be set to the project API key in PostHog
// alternatively, you can do
// client, _ := posthog.NewWithConfig(
// os.Getenv("POSTHOG_API_KEY"),
// posthog.Config{
// PersonalApiKey: "your personal API key", // Set this to your personal API token you want feature flag evaluation to be more performant. This will incur more costs, though
// Endpoint: "https://us.i.posthog.com",
// },
// )
defer client.Close()

// Capture an event
Expand Down Expand Up @@ -54,10 +62,37 @@ func main() {
Properties: posthog.NewProperties().
Set("$current_url", "https://example.com"),
})

// Check if a feature flag is enabled
isMyFlagEnabled, err := client.IsFeatureEnabled(
FeatureFlagPayload{
Key: "flag-key",
DistinctId: "distinct_id_of_your_user",
})

if isMyFlagEnabled == true {
// Do something differently for this user
}
}

```

## Testing Locally

You can run your Go app against a local build of `posthog-go` by making the following change to your `go.mod` file for whichever your app, e.g.

```Go
module example/posthog-go-app

go 1.22.5

require github.com/posthog/posthog-go v0.0.0-20240327112532-87b23fe11103

require github.com/google/uuid v1.3.0 // indirect

replace github.com/posthog/posthog-go => /path-to-your-local/posthog-go
```

## Questions?

### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ)
7 changes: 5 additions & 2 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ type Config struct {
// `DefaultEndpoint` by default.
Endpoint string

// You must specify a Personal API Key to use feature flags
// More information on how to get one: https://posthog.com/docs/api/overview
// Specifying a Personal API key will make feature flag evaluation more performant,
// but it's not required for feature flags. If you don't have a personal API key,
// you can leave this field empty, and all of the relevant feature flag evaluation
// methods will still work.
// Information on how to get a personal API key: https://posthog.com/docs/api/overview
PersonalApiKey string

// The flushing interval of the client. Messages will be sent when they've
Expand Down
2 changes: 1 addition & 1 deletion feature_flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ func TestFlagGroup(t *testing.T) {
t.Errorf("Expected personProperties to be map[region:Canada], got %s", reqBody.PersonProperties)
}

groupPropertiesEquality := reflect.DeepEqual(reqBody.GroupProperties, map[string]Properties{"company": Properties{"name": "Project Name 1"}})
groupPropertiesEquality := reflect.DeepEqual(reqBody.GroupProperties, map[string]Properties{"company": {"name": "Project Name 1"}})
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compiler told me that Properties field was unused.

if !groupPropertiesEquality {
t.Errorf("Expected groupProperties to be map[company:map[name:Project Name 1]], got %s", reqBody.GroupProperties)
}
Expand Down
16 changes: 8 additions & 8 deletions featureflags.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ func (poller *FeatureFlagsPoller) computeFlagLocally(
groupName, exists := poller.groups[fmt.Sprintf("%d", *flag.Filters.AggregationGroupTypeIndex)]

if !exists {
errMessage := "Flag has unknown group type index"
errMessage := "flag has unknown group type index"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compiler told me to lowercase error messages

return nil, errors.New(errMessage)
}

Expand Down Expand Up @@ -467,7 +467,7 @@ func matchCohort(property FlagProperty, properties Properties, cohorts map[strin
cohortId := fmt.Sprint(property.Value)
propertyGroup, ok := cohorts[cohortId]
if !ok {
return false, fmt.Errorf("Can't match cohort: cohort %s not found", cohortId)
return false, fmt.Errorf("can't match cohort: cohort %s not found", cohortId)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compiler told me to lowercase error messages

}

return matchPropertyGroup(propertyGroup, properties, cohorts)
Expand Down Expand Up @@ -578,7 +578,7 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) {
return false, &InconclusiveMatchError{"Can't match properties with operator is_not_set"}
}

override_value, _ := properties[key]
override_value := properties[key]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compiler told me this was unused.


if operator == "exact" {
switch t := value.(type) {
Expand Down Expand Up @@ -637,7 +637,7 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) {
valueString = strconv.Itoa(valueInt)
r, err = regexp.Compile(valueString)
} else {
errMessage := "Regex expression not allowed"
errMessage := "regex expression not allowed"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compiler told me to lowercase error messages

return false, errors.New(errMessage)
}

Expand All @@ -653,7 +653,7 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) {
valueString = strconv.Itoa(valueInt)
match = r.MatchString(valueString)
} else {
errMessage := "Value type not supported"
errMessage := "value type not supported"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compiler told me to lowercase error messages

return false, errors.New(errMessage)
}

Expand Down Expand Up @@ -707,12 +707,12 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) {
func validateOrderable(firstValue interface{}, secondValue interface{}) (float64, float64, error) {
convertedFirstValue, err := interfaceToFloat(firstValue)
if err != nil {
errMessage := "Value 1 is not orderable"
errMessage := "value 1 is not orderable"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compiler told me to lowercase error messages

return 0, 0, errors.New(errMessage)
}
convertedSecondValue, err := interfaceToFloat(secondValue)
if err != nil {
errMessage := "Value 2 is not orderable"
errMessage := "value 2 is not orderable"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compiler told me to lowercase error messages

return 0, 0, errors.New(errMessage)
}

Expand Down Expand Up @@ -809,7 +809,7 @@ func (poller *FeatureFlagsPoller) GetFeatureFlags() ([]FeatureFlag, error) {
_, closed := <-poller.loaded
if closed && poller.featureFlags == nil {
// There was an error with initial flag fetching
return nil, fmt.Errorf("Flags were not successfully fetched yet")
return nil, fmt.Errorf("flags were not successfully fetched yet")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compiler told me to lowercase error messages

}

return poller.featureFlags, nil
Expand Down
151 changes: 132 additions & 19 deletions posthog.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"io/ioutil"
"net/http"
"net/url"
"sync"
"time"
)
Expand Down Expand Up @@ -44,14 +45,19 @@ type Client interface {
// if the given flag is on or off for the user
GetFeatureFlag(FeatureFlagPayload) (interface{}, error)
//
// Get all flags - returns all flags for a user
GetAllFlags(FeatureFlagPayloadNoKey) (map[string]interface{}, error)
//
// Method forces a reload of feature flags
// NB: This is only available when using a PersonalApiKey
ReloadFeatureFlags() error
//
// Get feature flags - for testing only
// NB: This is only available when using a PersonalApiKey
GetFeatureFlags() ([]FeatureFlag, error)
//
// Get all flags - returns all flags for a user
GetAllFlags(FeatureFlagPayloadNoKey) (map[string]interface{}, error)
// Get the last captured event
GetLastCapturedEvent() *Capture
}

type client struct {
Expand Down Expand Up @@ -79,6 +85,11 @@ type client struct {
featureFlagsPoller *FeatureFlagsPoller

distinctIdsFeatureFlagsReported *SizeLimitedMap

// Last captured event
lastCapturedEvent *Capture
// Mutex to protect last captured event
lastEventMutex sync.RWMutex
}

// Instantiate a new client that uses the write key passed as first argument to
Expand Down Expand Up @@ -216,6 +227,7 @@ func (c *client) Enqueue(msg Message) (err error) {
}
m.Properties["$active_feature_flags"] = featureKeys
}
c.setLastCapturedEvent(m)
msg = m

default:
Expand All @@ -238,17 +250,28 @@ func (c *client) Enqueue(msg Message) (err error) {
return
}

func (c *client) setLastCapturedEvent(event Capture) {
c.lastEventMutex.Lock()
defer c.lastEventMutex.Unlock()
c.lastCapturedEvent = &event
}

func (c *client) GetLastCapturedEvent() *Capture {
c.lastEventMutex.RLock()
defer c.lastEventMutex.RUnlock()
if c.lastCapturedEvent == nil {
return nil
}
// Return a copy to avoid data races
eventCopy := *c.lastCapturedEvent
return &eventCopy
}
Comment on lines +253 to +268
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a handy method for tracking captured events, but I mostly only used it for testing. Perhaps I shouldn't expose it on the API client?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eh, should be fine. Doesn't seem too unsafe to expose


func (c *client) IsFeatureEnabled(flagConfig FeatureFlagPayload) (interface{}, error) {
if err := flagConfig.validate(); err != nil {
return false, err
}

if c.featureFlagsPoller == nil {
errorMessage := "specifying a PersonalApiKey is required for using feature flags"
c.Errorf(errorMessage)
return false, errors.New(errorMessage)
}
Comment on lines -246 to -250
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method falls back to GetFeatureFlag, which in turn falls back, so I don't need this guard clause.


result, err := c.GetFeatureFlag(flagConfig)
if err != nil {
return nil, err
Expand Down Expand Up @@ -278,12 +301,19 @@ func (c *client) GetFeatureFlag(flagConfig FeatureFlagPayload) (interface{}, err
return false, err
}

if c.featureFlagsPoller == nil {
errorMessage := "specifying a PersonalApiKey is required for using feature flags"
c.Errorf(errorMessage)
return "false", errors.New(errorMessage)
var flagValue interface{}
var err error

if c.featureFlagsPoller != nil {
// get feature flag from the poller, which uses the personal api key
// this is only available when using a PersonalApiKey
flagValue, err = c.featureFlagsPoller.GetFeatureFlag(flagConfig)
} else {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should I log that we're falling back here? Does it matter?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe, but only at a trace/info/debug level. Might be good for investigations.

// if there's no poller, get the feature flag from the decide endpoint
c.debugf("getting feature flag from decide endpoint")
flagValue, err = c.getFeatureFlagFromDecide(flagConfig.Key, flagConfig.DistinctId, flagConfig.Groups, flagConfig.PersonProperties, flagConfig.GroupProperties)
}
flagValue, err := c.featureFlagsPoller.GetFeatureFlag(flagConfig)

if *flagConfig.SendFeatureFlagEvents && !c.distinctIdsFeatureFlagsReported.contains(flagConfig.DistinctId, flagConfig.Key) {
c.Enqueue(Capture{
DistinctId: flagConfig.DistinctId,
Expand All @@ -296,6 +326,7 @@ func (c *client) GetFeatureFlag(flagConfig FeatureFlagPayload) (interface{}, err
})
c.distinctIdsFeatureFlagsReported.add(flagConfig.DistinctId, flagConfig.Key)
}

return flagValue, err
}

Expand All @@ -314,12 +345,20 @@ func (c *client) GetAllFlags(flagConfig FeatureFlagPayloadNoKey) (map[string]int
return nil, err
}

if c.featureFlagsPoller == nil {
errorMessage := "specifying a PersonalApiKey is required for using feature flags"
c.Errorf(errorMessage)
return nil, errors.New(errorMessage)
var flagsValue map[string]interface{}
var err error

if c.featureFlagsPoller != nil {
// get feature flags from the poller, which uses the personal api key
// this is only available when using a PersonalApiKey
flagsValue, err = c.featureFlagsPoller.GetAllFlags(flagConfig)
} else {
// if there's no poller, get the feature flags from the decide endpoint
c.debugf("getting all feature flags from decide endpoint")
flagsValue, err = c.getAllFeatureFlagsFromDecide(flagConfig.DistinctId, flagConfig.Groups, flagConfig.PersonProperties, flagConfig.GroupProperties)
}
return c.featureFlagsPoller.GetAllFlags(flagConfig)

return flagsValue, err
}

// Close and flush metrics.
Expand All @@ -336,7 +375,7 @@ func (c *client) Close() (err error) {
return
}

// Asychronously send a batched requests.
// Asynchronously send a batched requests.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drive-by spellcheck

func (c *client) sendAsync(msgs []message, wg *sync.WaitGroup, ex *executor) {
wg.Add(1)

Expand Down Expand Up @@ -558,3 +597,77 @@ func (c *client) getFeatureVariants(distinctId string, groups Groups, personProp
}
return featureVariants, nil
}

func (c *client) makeDecideRequest(distinctId string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (*DecideResponse, error) {
requestData := DecideRequestData{
ApiKey: c.key,
DistinctId: distinctId,
Groups: groups,
PersonProperties: personProperties,
GroupProperties: groupProperties,
}

requestDataBytes, err := json.Marshal(requestData)
if err != nil {
return nil, fmt.Errorf("unable to marshal decide endpoint request data: %v", err)
}

decideEndpoint := "decide/?v=2"
url, err := url.Parse(c.Endpoint + "/" + decideEndpoint)
if err != nil {
return nil, fmt.Errorf("creating url: %v", err)
}

req, err := http.NewRequest("POST", url.String(), bytes.NewReader(requestDataBytes))
if err != nil {
return nil, fmt.Errorf("creating request: %v", err)
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "posthog-go (version: "+Version+")")

res, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %v", err)
}
defer res.Body.Close()

if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code from /decide/: %d", res.StatusCode)
}

resBody, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("error reading response from /decide/: %v", err)
}

var decideResponse DecideResponse
err = json.Unmarshal(resBody, &decideResponse)
if err != nil {
return nil, fmt.Errorf("error parsing response from /decide/: %v", err)
}

return &decideResponse, nil
}

func (c *client) getFeatureFlagFromDecide(key string, distinctId string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (interface{}, error) {
decideResponse, err := c.makeDecideRequest(distinctId, groups, personProperties, groupProperties)
if err != nil {
return nil, err
}

if value, ok := decideResponse.FeatureFlags[key]; ok {
return value, nil
}

return false, nil
}

func (c *client) getAllFeatureFlagsFromDecide(distinctId string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (map[string]interface{}, error) {
decideResponse, err := c.makeDecideRequest(distinctId, groups, personProperties, groupProperties)
if err != nil {
return nil, err
}

return decideResponse.FeatureFlags, nil
}
Loading
Loading