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 1 commit
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
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
145 changes: 126 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,17 @@ 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 flags from the poller, which uses the personal api key
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
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 +324,7 @@ func (c *client) GetFeatureFlag(flagConfig FeatureFlagPayload) (interface{}, err
})
c.distinctIdsFeatureFlagsReported.add(flagConfig.DistinctId, flagConfig.Key)
}

return flagValue, err
}

Expand All @@ -314,12 +343,16 @@ 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 {
flagsValue, err = c.featureFlagsPoller.GetAllFlags(flagConfig)
} else {
flagsValue, err = c.getAllFeatureFlagsFromDecide(flagConfig.DistinctId, flagConfig.Groups, flagConfig.PersonProperties, flagConfig.GroupProperties)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

didn't add the comments here that the above method has, I'll add them back in for cleanliness

}
return c.featureFlagsPoller.GetAllFlags(flagConfig)

return flagsValue, err
}

// Close and flush metrics.
Expand All @@ -336,7 +369,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 +591,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