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: add support for client-side prerequisite events #452

Merged
merged 16 commits into from
Oct 17, 2024
Merged
8 changes: 8 additions & 0 deletions integrationtests/all_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ func TestEndToEnd(t *testing.T) {
testStandardMode(t, manager)
})

t.Run("standard mode with prerequisites", func(t *testing.T) {
// The standard tests above use simple flags that don't contain prerequisite relationships. This
// test explicitly configures flags with prerequisites, and verifies that client side flag evals contain
// prerequisite metadata for each flag. This information needs to be passed to client-side SDKs so that they
// can generate prerequisite events to power LaunchDarkly SaaS features.
testStandardModeWithPrerequisites(t, manager)
})

t.Run("standard mode with payload filters", func(t *testing.T) {
t.Run("default filters", func(t *testing.T) {
// This case is similar to the "standard mode" test above, except with payload filtering in the picture.
Expand Down
86 changes: 85 additions & 1 deletion integrationtests/api_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func (a *apiHelper) logResult(desc string, err error) error {
return nil
}
addInfo := ""
if gse, ok := err.(ldapi.GenericOpenAPIError); ok {
if gse, ok := err.(*ldapi.GenericOpenAPIError); ok {
body := string(gse.Body())
addInfo = " - " + string(body)
}
Expand Down Expand Up @@ -368,6 +368,90 @@ func (a *apiHelper) createFlag(
return nil
}

// createFlagInEnvironment sets up a flag with two variations. The first
// is served when the flag is off, and the second is served when it is on.
func (a *apiHelper) createFlagWithVariations(
proj projectInfo,
env environmentInfo,
flagKey string,
on bool,
variation1 ldvalue.Value,
variation2 ldvalue.Value,
) error {
flagPost := ldapi.FeatureFlagBody{
Name: flagKey,
Key: flagKey,
}

for _, value := range []ldvalue.Value{variation1, variation2} {
valueAsInterface := value.AsArbitraryValue()
flagPost.Variations = append(flagPost.Variations, ldapi.Variation{Value: &valueAsInterface})
}

_, _, err := a.apiClient.FeatureFlagsApi.
PostFeatureFlag(a.apiContext, proj.key).
FeatureFlagBody(flagPost).
Execute()

err = a.logResult("Create flag "+flagKey+" in "+proj.key, err)
if err != nil {
return err
}

envPrefix := fmt.Sprintf("/environments/%s", env.key)
patch := ldapi.PatchWithComment{
Patch: []ldapi.PatchOperation{
makePatch("replace", envPrefix+"/offVariation", 0),
makePatch("replace", envPrefix+"/fallthrough/variation", 1),
makePatch("replace", envPrefix+"/on", on),
},
}
_, _, err = a.apiClient.FeatureFlagsApi.
PatchFeatureFlag(a.apiContext, proj.key, flagKey).
PatchWithComment(patch).
Execute()

err = a.logResult("Configure flag "+flagKey+" for "+env.key, err)
if err != nil {
return err
}

return nil
}

func (a *apiHelper) createFlagWithPrerequisites(
proj projectInfo,
env environmentInfo,
flagKey string,
on bool,
variation1 ldvalue.Value,
variation2 ldvalue.Value,
prerequisites []ldapi.Prerequisite,
) error {

if err := a.createFlagWithVariations(proj, env, flagKey, on, variation1, variation2); err != nil {
return err
}

envPrefix := fmt.Sprintf("/environments/%s", env.key)

_, _, err := a.apiClient.FeatureFlagsApi.PatchFeatureFlag(a.apiContext, proj.key, flagKey).PatchWithComment(ldapi.PatchWithComment{
Patch: []ldapi.PatchOperation{
makePatch("replace", envPrefix+"/prerequisites", prerequisites),
makePatch("replace", envPrefix+"/offVariation", 0),
makePatch("replace", envPrefix+"/on", on),
makePatch("replace", envPrefix+"/fallthrough/variation", 1),
},
}).Execute()

err = a.logResult(fmt.Sprintf("Configure flag %s in %s:%s", flagKey, proj.key, env.key), err)
if err != nil {
return err
}

return nil
}

func (a *apiHelper) createFlags(projsAndEnvs projsAndEnvs) error {
for proj, envs := range projsAndEnvs {
err := a.createFlag(proj, envs, flagKeyForProj(proj), flagValueForEnv)
Expand Down
76 changes: 76 additions & 0 deletions integrationtests/standard_mode_prerequisite_flags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//go:build integrationtests

package integrationtests

import (
ldapi "github.com/launchdarkly/api-client-go/v13"
"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
"github.com/stretchr/testify/require"
"testing"
)

func withStandardModePrerequisitesTestData(t *testing.T, manager *integrationTestManager, fn func(data standardModeTestData, prereqs map[string][]string)) {
project, envs, err := manager.apiHelper.createProject(1)
require.NoError(t, err)
defer manager.apiHelper.deleteProject(project)

flagKey := func(name string) string {
return name + "-" + flagKeyForProj(project)
}

env := envs[0]
toplevel1 := flagKey("toplevel1")
prereq1 := flagKey("prereq1")
prereq2 := flagKey("prereq2")

err = manager.apiHelper.createFlagWithVariations(project, env, prereq1, true, ldvalue.Bool(false), ldvalue.Bool(true))
require.NoError(t, err)

err = manager.apiHelper.createFlagWithVariations(project, env, prereq2, true, ldvalue.Bool(false), ldvalue.Bool(true))
require.NoError(t, err)

prerequisites := map[string][]string{
toplevel1: {prereq1, prereq2},
}

// The createFlagWithVariations call sets up two variations, with the second one being used if the flag is on.
// The test here is to see which prerequisites were evaluated for a given flag. If a prerequisite fails, the eval
// algorithm is going to short-circuit and we won't see the other prerequisite. So, we'll have two prerequisites,
// both of which are on, and both of which are satisfied. That way the evaluator will be forced to visit both,
// and we'll see the list of both when we query the eval endpoint.
const onVariation = 1
for flag, prereqs := range prerequisites {
var ps []ldapi.Prerequisite
for _, prereq := range prereqs {
ps = append(ps, ldapi.Prerequisite{Key: prereq, Variation: onVariation})
}
err = manager.apiHelper.createFlagWithPrerequisites(project, env, flag, true, ldvalue.Bool(false), ldvalue.Bool(true), ps)
require.NoError(t, err)
}

testData := standardModeTestData{
projsAndEnvs: projsAndEnvs{
{key: project.key, name: project.name}: envs,
},
}

fn(testData, prerequisites)
}

func testStandardModeWithPrerequisites(t *testing.T, manager *integrationTestManager) {
withStandardModePrerequisitesTestData(t, manager, func(testData standardModeTestData, prerequisites map[string][]string) {
envVars := make(map[string]string)
testData.projsAndEnvs.enumerateEnvs(func(proj projectInfo, env environmentInfo) {
envVars["LD_ENV_"+string(env.name)] = string(env.sdkKey)
envVars["LD_MOBILE_KEY_"+string(env.name)] = string(env.mobileKey)
envVars["LD_CLIENT_SIDE_ID_"+string(env.name)] = string(env.id)
})
manager.startRelay(t, envVars)
defer manager.stopRelay(t)

manager.awaitEnvironments(t, testData.projsAndEnvs, nil, func(proj projectInfo, env environmentInfo) string {
return env.name
})
manager.verifyFlagPrerequisites(t, testData.projsAndEnvs, prerequisites)
})
}
70 changes: 70 additions & 0 deletions integrationtests/test_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,34 @@ func (m *integrationTestManager) verifyFlagValues(t *testing.T, projsAndEnvs pro
})
}

func (m *integrationTestManager) verifyFlagPrerequisites(t *testing.T, projsAndEnvs projsAndEnvs, prereqs map[string][]string) {
userJSON := `{"key":"any-user-key"}`

projsAndEnvs.enumerateEnvs(func(proj projectInfo, env environmentInfo) {
prereqMap := m.getFlagPrerequisites(t, proj, env, userJSON)
for flagKey, prereqKeys := range prereqs {
prereqArray := prereqMap.GetByKey(flagKey).AsValueArray()
if !prereqArray.IsDefined() {
m.loggers.Errorf("Expected flag %s to have prerequisites, but it did not", flagKey)
t.Fail()
continue
}
if prereqArray.Count() != len(prereqKeys) {
m.loggers.Errorf("Expected flag %s to have %d prerequisites, but it had %d", flagKey, len(prereqKeys), prereqArray.Count())
t.Fail()
continue
}
for i, expectedPrereqKey := range prereqKeys {
actualPrereqKey := prereqArray.Get(i).StringValue()
if expectedPrereqKey != actualPrereqKey {
m.loggers.Errorf("Expected flag %s to have prerequisite %s at index %d, but it had %s",
flagKey, expectedPrereqKey, i, actualPrereqKey)
}
}
}
})
}

func (m *integrationTestManager) verifyEvenOddFlagKeys(t *testing.T, projsAndEnvs projsAndEnvs) {
userJSON := `{"key":"any-user-key"}`

Expand Down Expand Up @@ -452,6 +480,48 @@ func (m *integrationTestManager) getFlagValues(t *testing.T, proj projectInfo, e
return ldvalue.Null()
}

func (m *integrationTestManager) getFlagPrerequisites(t *testing.T, proj projectInfo, env environmentInfo, userJSON string) ldvalue.Value {
userBase64 := base64.URLEncoding.EncodeToString([]byte(userJSON))

u, err := url.Parse(m.relayBaseURL + "/sdk/evalx/users/" + userBase64)
if err != nil {
t.Fatalf("couldn't parse flag evaluation URL: %v", err)
}

if env.filterKey != config.DefaultFilter {
u.RawQuery = url.Values{
"filter": []string{string(env.filterKey)},
}.Encode()
}

req, err := http.NewRequest("GET", u.String(), nil)
require.NoError(t, err)
req.Header.Add("Authorization", string(env.sdkKey))
resp, err := m.makeHTTPRequestToRelay(req)
require.NoError(t, err)
if assert.Equal(t, 200, resp.StatusCode, "requested flags for environment "+env.key) {
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
flagData := ldvalue.Parse(data)
if !flagData.Equal(ldvalue.Null()) {
valuesObject := ldvalue.ObjectBuild()
for _, key := range flagData.Keys(nil) {
valuesObject.Set(key, flagData.GetByKey(key).GetByKey("prerequisites"))
}
return valuesObject.Build()
}
m.loggers.Errorf("Flags poll request returned invalid response for environment %s with SDK key %s: %s",
env.key, env.sdkKey, string(data))
t.FailNow()
} else {
m.loggers.Errorf("Flags poll request for environment %s with SDK key %s failed with status %d",
env.key, env.sdkKey, resp.StatusCode)
t.FailNow()
}
return ldvalue.Null()
}

func (m *integrationTestManager) withExtraContainer(
t *testing.T,
imageName string,
Expand Down
19 changes: 18 additions & 1 deletion relay/relay_endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"strconv"
"time"

ldeval "github.com/launchdarkly/go-server-sdk-evaluation/v3"

"github.com/launchdarkly/ld-relay/v8/internal/basictypes"
"github.com/launchdarkly/ld-relay/v8/internal/logging"
"github.com/launchdarkly/ld-relay/v8/internal/middleware"
Expand Down Expand Up @@ -247,7 +249,13 @@ func evaluateAllShared(w http.ResponseWriter, req *http.Request, sdkKind basicty
}
}

result := evaluator.Evaluate(flag, ldContext, nil)
var prerequisites []string
result := evaluator.Evaluate(flag, ldContext, func(event ldeval.PrerequisiteFlagEvent) {
if event.TargetFlagKey == flag.Key {
prerequisites = append(prerequisites, event.PrerequisiteFlag.Key)
}
})

detail := result.Detail
isExperiment := result.IsExperiment

Expand All @@ -262,6 +270,15 @@ func evaluateAllShared(w http.ResponseWriter, req *http.Request, sdkKind basicty
}
valueObj.Maybe("debugEventsUntilDate", flag.DebugEventsUntilDate != 0).
Float64(float64(flag.DebugEventsUntilDate))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

All of this to say.. here's the new behavior implemented by the PR.


if len(prerequisites) > 0 {
prereqArray := valueObj.Name("prerequisites").Array()
for _, p := range prerequisites {
prereqArray.String(p)
}
prereqArray.End()
}

valueObj.End()
}
}
Expand Down