Skip to content

Commit

Permalink
feat(flags): fall back to /decide endpoint for GetFeatureFlag and…
Browse files Browse the repository at this point in the history
… `GetAllFlags` so that users can use this library without needing a personal API Key (#46)

* initial commit

* info about the personal api key

* adding docs

* logs and comments

* adding changelog
  • Loading branch information
dmarticus committed Aug 7, 2024
1 parent 87b23fe commit 76dfd13
Show file tree
Hide file tree
Showing 7 changed files with 629 additions and 52 deletions.
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"}})
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"
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)
}

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]

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"
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"
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"
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"
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")
}

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
}

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)
}

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 {
// 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.
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

0 comments on commit 76dfd13

Please sign in to comment.