From a54c2d132aeaec2bf5c714168bb280682a92bec7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 28 Mar 2024 15:07:24 -0700 Subject: [PATCH 01/31] feat: Implement supporting types for hooks. (#125) This PR should be reviewed before other hooks PRs. This PR adds supporting types for hooks, but does not use them from the client. --------- Co-authored-by: Casey Waldren --- internal/hooks/iterator.go | 62 +++++ internal/hooks/iterator_test.go | 63 +++++ internal/hooks/package_info.go | 2 + internal/hooks/runner.go | 141 +++++++++++ internal/hooks/runner_test.go | 330 +++++++++++++++++++++++++ internal/sharedtest/hooks.go | 168 +++++++++++++ ldhooks/evaluation_series_context.go | 46 ++++ ldhooks/evaluation_series_data.go | 77 ++++++ ldhooks/evaluation_series_data_test.go | 75 ++++++ ldhooks/hooks.go | 93 +++++++ ldhooks/metadata.go | 33 +++ ldhooks/package_info.go | 5 + 12 files changed, 1095 insertions(+) create mode 100644 internal/hooks/iterator.go create mode 100644 internal/hooks/iterator_test.go create mode 100644 internal/hooks/package_info.go create mode 100644 internal/hooks/runner.go create mode 100644 internal/hooks/runner_test.go create mode 100644 internal/sharedtest/hooks.go create mode 100644 ldhooks/evaluation_series_context.go create mode 100644 ldhooks/evaluation_series_data.go create mode 100644 ldhooks/evaluation_series_data_test.go create mode 100644 ldhooks/hooks.go create mode 100644 ldhooks/metadata.go create mode 100644 ldhooks/package_info.go diff --git a/internal/hooks/iterator.go b/internal/hooks/iterator.go new file mode 100644 index 00000000..2bfe15bf --- /dev/null +++ b/internal/hooks/iterator.go @@ -0,0 +1,62 @@ +package hooks + +import ( + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" +) + +type iterator struct { + reverse bool + cursor int + collection []ldhooks.Hook +} + +// newIterator creates a new hook iterator which can iterate hooks forward or reverse. +// +// The collection being iterated should not be modified during iteration. +// +// Example: +// it := newIterator(false, hooks) +// +// for it.hasNext() { +// hook := it.getNext() +// } +func newIterator(reverse bool, hooks []ldhooks.Hook) *iterator { + cursor := -1 + if reverse { + cursor = len(hooks) + } + return &iterator{ + reverse: reverse, + cursor: cursor, + collection: hooks, + } +} + +func (it *iterator) hasNext() bool { + nextCursor := it.getNextIndex() + return it.inBounds(nextCursor) +} + +func (it *iterator) inBounds(nextCursor int) bool { + inBounds := nextCursor < len(it.collection) && nextCursor >= 0 + return inBounds +} + +func (it *iterator) getNextIndex() int { + var nextCursor int + if it.reverse { + nextCursor = it.cursor - 1 + } else { + nextCursor = it.cursor + 1 + } + return nextCursor +} + +func (it *iterator) getNext() (int, ldhooks.Hook) { + i := it.getNextIndex() + if it.inBounds(i) { + it.cursor = i + return it.cursor, it.collection[it.cursor] + } + return it.cursor, nil +} diff --git a/internal/hooks/iterator_test.go b/internal/hooks/iterator_test.go new file mode 100644 index 00000000..bb592f2a --- /dev/null +++ b/internal/hooks/iterator_test.go @@ -0,0 +1,63 @@ +package hooks + +import ( + "fmt" + "testing" + + "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" + "github.com/stretchr/testify/assert" +) + +func TestIterator(t *testing.T) { + testCases := []bool{false, true} + for _, reverse := range testCases { + t.Run(fmt.Sprintf("reverse: %v", reverse), func(t *testing.T) { + t.Run("empty collection", func(t *testing.T) { + + var hooks []ldhooks.Hook + it := newIterator(reverse, hooks) + + assert.False(t, it.hasNext()) + + _, value := it.getNext() + assert.Zero(t, value) + + }) + + t.Run("collection with items", func(t *testing.T) { + hooks := []ldhooks.Hook{ + sharedtest.NewTestHook("a"), + sharedtest.NewTestHook("b"), + sharedtest.NewTestHook("c"), + } + + it := newIterator(reverse, hooks) + + var cursor int + count := 0 + if reverse { + cursor = 2 + } else { + cursor += 0 + } + for it.hasNext() { + index, value := it.getNext() + assert.Equal(t, cursor, index) + assert.Equal(t, hooks[cursor].Metadata().Name(), value.Metadata().Name()) + + count += 1 + + if reverse { + cursor -= 1 + } else { + cursor += 1 + } + + } + assert.Equal(t, 3, count) + assert.False(t, it.hasNext()) + }) + }) + } +} diff --git a/internal/hooks/package_info.go b/internal/hooks/package_info.go new file mode 100644 index 00000000..64645f46 --- /dev/null +++ b/internal/hooks/package_info.go @@ -0,0 +1,2 @@ +// Package hooks is an internal package containing implementations to run hooks. +package hooks diff --git a/internal/hooks/runner.go b/internal/hooks/runner.go new file mode 100644 index 00000000..27053292 --- /dev/null +++ b/internal/hooks/runner.go @@ -0,0 +1,141 @@ +package hooks + +import ( + "context" + "sync" + + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/launchdarkly/go-sdk-common/v3/ldlog" + "github.com/launchdarkly/go-sdk-common/v3/ldreason" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" +) + +// Runner manages the registration and execution of hooks. +type Runner struct { + hooks []ldhooks.Hook + loggers ldlog.Loggers + mutex *sync.RWMutex +} + +// EvaluationExecution represents the state of a running series of evaluation stages. +type EvaluationExecution struct { + hooks []ldhooks.Hook + data []ldhooks.EvaluationSeriesData + context ldhooks.EvaluationSeriesContext +} + +func (e EvaluationExecution) withData(data []ldhooks.EvaluationSeriesData) EvaluationExecution { + return EvaluationExecution{ + hooks: e.hooks, + context: e.context, + data: data, + } +} + +// NewRunner creates a new hook runner. +func NewRunner(loggers ldlog.Loggers, hooks []ldhooks.Hook) *Runner { + return &Runner{ + loggers: loggers, + hooks: hooks, + mutex: &sync.RWMutex{}, + } +} + +// AddHooks adds hooks to the runner. +func (h *Runner) AddHooks(hooks ...ldhooks.Hook) { + h.mutex.Lock() + defer h.mutex.Unlock() + + h.hooks = append(h.hooks, hooks...) +} + +// getHooks returns a copy of the hooks. This copy is suitable for use when executing a series. This keeps the set +// of hooks stable for the duration of the series. This prevents things like calling the AfterEvaluation method for +// a hook that didn't have the BeforeEvaluation method called. +func (h *Runner) getHooks() []ldhooks.Hook { + h.mutex.RLock() + defer h.mutex.RUnlock() + copiedHooks := make([]ldhooks.Hook, len(h.hooks)) + copy(copiedHooks, h.hooks) + return copiedHooks +} + +// PrepareEvaluationSeries creates an EvaluationExecution suitable for executing evaluation stages and gets a copy +// of hooks to use during series execution. +// +// For an invocation of a series the same set of hooks should be used. For instance a hook added mid-evaluation should +// not be executed during the "AfterEvaluation" stage of that evaluation. +func (h *Runner) PrepareEvaluationSeries( + flagKey string, + evalContext ldcontext.Context, + defaultVal ldvalue.Value, + method string, +) EvaluationExecution { + hooksForEval := h.getHooks() + + returnData := make([]ldhooks.EvaluationSeriesData, len(hooksForEval)) + for i := range hooksForEval { + returnData[i] = ldhooks.EmptyEvaluationSeriesData() + } + return EvaluationExecution{ + hooks: hooksForEval, + data: returnData, + context: ldhooks.NewEvaluationSeriesContext(flagKey, evalContext, defaultVal, method), + } +} + +// BeforeEvaluation executes the BeforeEvaluation stage of registered hooks. +func (h *Runner) BeforeEvaluation(ctx context.Context, execution EvaluationExecution) EvaluationExecution { + return h.executeStage( + execution, + false, + "BeforeEvaluation", + func(hook ldhooks.Hook, data ldhooks.EvaluationSeriesData) (ldhooks.EvaluationSeriesData, error) { + return hook.BeforeEvaluation(ctx, execution.context, data) + }) +} + +// AfterEvaluation executes the AfterEvaluation stage of registered hooks. +func (h *Runner) AfterEvaluation( + ctx context.Context, + execution EvaluationExecution, + detail ldreason.EvaluationDetail, +) EvaluationExecution { + return h.executeStage( + execution, + true, + "AfterEvaluation", + func(hook ldhooks.Hook, data ldhooks.EvaluationSeriesData) (ldhooks.EvaluationSeriesData, error) { + return hook.AfterEvaluation(ctx, execution.context, data, detail) + }) +} + +func (h *Runner) executeStage( + execution EvaluationExecution, + reverse bool, + stageName string, + fn func( + hook ldhooks.Hook, + data ldhooks.EvaluationSeriesData, + ) (ldhooks.EvaluationSeriesData, error)) EvaluationExecution { + returnData := make([]ldhooks.EvaluationSeriesData, len(execution.hooks)) + iterator := newIterator(reverse, execution.hooks) + for iterator.hasNext() { + i, hook := iterator.getNext() + + outData, err := fn(hook, execution.data[i]) + if err != nil { + returnData[i] = execution.data[i] + h.loggers.Errorf( + "During evaluation of flag \"%s\", an error was encountered in \"%s\" of the \"%s\" hook: %s", + execution.context.FlagKey(), + stageName, + hook.Metadata().Name(), + err.Error()) + continue + } + returnData[i] = outData + } + return execution.withData(returnData) +} diff --git a/internal/hooks/runner_test.go b/internal/hooks/runner_test.go new file mode 100644 index 00000000..33604faa --- /dev/null +++ b/internal/hooks/runner_test.go @@ -0,0 +1,330 @@ +package hooks + +import ( + "context" + "errors" + "testing" + + "github.com/launchdarkly/go-sdk-common/v3/ldlog" + "github.com/launchdarkly/go-sdk-common/v3/ldlogtest" + "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest" + + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/launchdarkly/go-sdk-common/v3/ldreason" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" + "github.com/stretchr/testify/assert" +) + +func emptyExecutionAssertions(t *testing.T, res EvaluationExecution, ldContext ldcontext.Context) { + assert.Empty(t, res.hooks) + assert.Empty(t, res.data) + assert.Equal(t, ldContext, res.context.Context()) + assert.Equal(t, "test-flag", res.context.FlagKey()) + assert.Equal(t, "testMethod", res.context.Method()) + assert.Equal(t, ldvalue.Bool(false), res.context.DefaultValue()) +} + +type orderTracker struct { + orderBefore []string + orderAfter []string +} + +func newOrderTracker() *orderTracker { + return &orderTracker{ + orderBefore: make([]string, 0), + orderAfter: make([]string, 0), + } +} + +func TestHookRunner(t *testing.T) { + falseValue := ldvalue.Bool(false) + ldContext := ldcontext.New("test-context") + + t.Run("with no hooks", func(t *testing.T) { + runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{}) + + t.Run("prepare evaluation series", func(t *testing.T) { + res := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, "testMethod") + emptyExecutionAssertions(t, res, ldContext) + }) + + t.Run("run before evaluation", func(t *testing.T) { + execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + res := runner.BeforeEvaluation(context.Background(), execution) + emptyExecutionAssertions(t, res, ldContext) + }) + + t.Run("run after evaluation", func(t *testing.T) { + execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + res := runner.AfterEvaluation(context.Background(), execution, + ldreason.NewEvaluationDetail(falseValue, 0, + ldreason.NewEvalReasonFallthrough())) + emptyExecutionAssertions(t, res, ldContext) + }) + }) + + t.Run("with hooks", func(t *testing.T) { + t.Run("prepare evaluation series", func(t *testing.T) { + hookA := sharedtest.NewTestHook("a") + hookB := sharedtest.NewTestHook("b") + runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA, hookB}) + + ldContext := ldcontext.New("test-context") + res := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, "testMethod") + + assert.Len(t, res.hooks, 2) + assert.Len(t, res.data, 2) + assert.Equal(t, ldContext, res.context.Context()) + assert.Equal(t, "test-flag", res.context.FlagKey()) + assert.Equal(t, "testMethod", res.context.Method()) + assert.Equal(t, falseValue, res.context.DefaultValue()) + assert.Equal(t, res.data[0], ldhooks.EmptyEvaluationSeriesData()) + assert.Equal(t, res.data[1], ldhooks.EmptyEvaluationSeriesData()) + }) + + t.Run("verify execution order", func(t *testing.T) { + testCases := []struct { + name string + method func(runner *Runner, execution EvaluationExecution) + expectedBeforeOrder []string + expectedAfterOrder []string + }{ + {name: "BeforeEvaluation", + method: func(runner *Runner, execution EvaluationExecution) { + _ = runner.BeforeEvaluation(context.Background(), execution) + }, + expectedBeforeOrder: []string{"a", "b"}, + expectedAfterOrder: make([]string, 0), + }, + {name: "AfterEvaluation", + method: func(runner *Runner, execution EvaluationExecution) { + detail := ldreason.NewEvaluationDetail(falseValue, 0, + ldreason.NewEvalReasonFallthrough()) + _ = runner.AfterEvaluation(context.Background(), execution, detail) + }, + expectedBeforeOrder: make([]string, 0), + expectedAfterOrder: []string{"b", "a"}}, + } + + t.Run("with hooks registered at config time", func(t *testing.T) { + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + tracker := newOrderTracker() + hookA := createOrderTrackingHook("a", tracker) + hookB := createOrderTrackingHook("b", tracker) + runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA, hookB}) + + execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + testCase.method(runner, execution) + + // BeforeEvaluation should execute in registration order. + assert.Equal(t, testCase.expectedBeforeOrder, tracker.orderBefore) + assert.Equal(t, testCase.expectedAfterOrder, tracker.orderAfter) + }) + } + }) + + t.Run("with hooks registered at run time", func(t *testing.T) { + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + tracker := newOrderTracker() + hookA := createOrderTrackingHook("a", tracker) + hookB := createOrderTrackingHook("b", tracker) + runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA}) + runner.AddHooks(hookB) + + execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + testCase.method(runner, execution) + + // BeforeEvaluation should execute in registration order. + assert.Equal(t, testCase.expectedBeforeOrder, tracker.orderBefore) + assert.Equal(t, testCase.expectedAfterOrder, tracker.orderAfter) + }) + } + }) + }) + + t.Run("run before evaluation", func(t *testing.T) { + hookA := sharedtest.NewTestHook("a") + hookB := sharedtest.NewTestHook("b") + runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA, hookB}) + + execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + _ = runner.BeforeEvaluation(context.Background(), execution) + + hookA.Verify(t, sharedtest.HookExpectedCall{ + HookStage: sharedtest.HookStageBeforeEvaluation, + EvalCapture: sharedtest.HookEvalCapture{ + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext("test-flag", ldContext, + falseValue, "testMethod"), + EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), + GoContext: context.Background(), + }}) + + hookB.Verify(t, sharedtest.HookExpectedCall{ + HookStage: sharedtest.HookStageBeforeEvaluation, + EvalCapture: sharedtest.HookEvalCapture{ + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext("test-flag", ldContext, + falseValue, "testMethod"), + EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), + GoContext: context.Background(), + }}) + }) + + t.Run("run after evaluation", func(t *testing.T) { + hookA := sharedtest.NewTestHook("a") + hookB := sharedtest.NewTestHook("b") + runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA, hookB}) + + execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + detail := ldreason.NewEvaluationDetail(falseValue, 0, + ldreason.NewEvalReasonFallthrough()) + _ = runner.AfterEvaluation(context.Background(), execution, detail) + + hookA.Verify(t, sharedtest.HookExpectedCall{ + HookStage: sharedtest.HookStageAfterEvaluation, + EvalCapture: sharedtest.HookEvalCapture{ + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext("test-flag", ldContext, + falseValue, "testMethod"), + EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), + Detail: detail, + GoContext: context.Background(), + }}) + + hookB.Verify(t, sharedtest.HookExpectedCall{ + HookStage: sharedtest.HookStageAfterEvaluation, + EvalCapture: sharedtest.HookEvalCapture{ + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext("test-flag", ldContext, + falseValue, "testMethod"), + EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), + Detail: detail, + GoContext: context.Background(), + }}) + }) + + t.Run("run before evaluation with an error", func(t *testing.T) { + mockLog := ldlogtest.NewMockLog() + hookA := sharedtest.NewTestHook("a") + hookA.BeforeInject = func( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + ) (ldhooks.EvaluationSeriesData, error) { + return ldhooks.NewEvaluationSeriesBuilder(data). + Set("testA", "A"). + Build(), errors.New("something bad") + } + hookB := sharedtest.NewTestHook("b") + hookB.BeforeInject = func( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + ) (ldhooks.EvaluationSeriesData, error) { + return ldhooks.NewEvaluationSeriesBuilder(data). + Set("testB", "testB"). + Build(), nil + } + + runner := NewRunner(mockLog.Loggers, []ldhooks.Hook{hookA, hookB}) + execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + + res := runner.BeforeEvaluation(context.Background(), execution) + assert.Len(t, res.hooks, 2) + assert.Len(t, res.data, 2) + assert.Equal(t, ldContext, res.context.Context()) + assert.Equal(t, "test-flag", res.context.FlagKey()) + assert.Equal(t, "testMethod", res.context.Method()) + assert.Equal(t, ldhooks.EmptyEvaluationSeriesData(), res.data[0]) + assert.Equal(t, + ldhooks.NewEvaluationSeriesBuilder( + ldhooks.EmptyEvaluationSeriesData()). + Set("testB", "testB"). + Build(), res.data[1]) + assert.Equal(t, falseValue, res.context.DefaultValue()) + + assert.Equal(t, []string{"During evaluation of flag \"test-flag\", an error was encountered in \"BeforeEvaluation\" of the \"a\" hook: something bad"}, + mockLog.GetOutput(ldlog.Error)) + }) + + t.Run("run after evaluation with an error", func(t *testing.T) { + mockLog := ldlogtest.NewMockLog() + hookA := sharedtest.NewTestHook("a") + // The hooks execute in reverse order, so we have an error in B and check that A still executes. + hookA.AfterInject = func( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + detail ldreason.EvaluationDetail, + ) (ldhooks.EvaluationSeriesData, error) { + return ldhooks.NewEvaluationSeriesBuilder(data). + Set("testA", "testA"). + Build(), nil + } + hookB := sharedtest.NewTestHook("b") + hookB.AfterInject = func( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + detail ldreason.EvaluationDetail, + ) (ldhooks.EvaluationSeriesData, error) { + return ldhooks.NewEvaluationSeriesBuilder(data). + Set("testB", "B"). + Build(), errors.New("something bad") + + } + + runner := NewRunner(mockLog.Loggers, []ldhooks.Hook{hookA, hookB}) + execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + detail := ldreason.NewEvaluationDetail(falseValue, 0, + ldreason.NewEvalReasonFallthrough()) + + res := runner.AfterEvaluation(context.Background(), execution, detail) + assert.Len(t, res.hooks, 2) + assert.Len(t, res.data, 2) + assert.Equal(t, ldContext, res.context.Context()) + assert.Equal(t, "test-flag", res.context.FlagKey()) + assert.Equal(t, "testMethod", res.context.Method()) + assert.Equal(t, ldhooks.EmptyEvaluationSeriesData(), res.data[1]) + assert.Equal(t, + ldhooks.NewEvaluationSeriesBuilder( + ldhooks.EmptyEvaluationSeriesData()). + Set("testA", "testA"). + Build(), res.data[0]) + assert.Equal(t, falseValue, res.context.DefaultValue()) + assert.Equal(t, []string{"During evaluation of flag \"test-flag\", an error was encountered in \"AfterEvaluation\" of the \"b\" hook: something bad"}, + mockLog.GetOutput(ldlog.Error)) + }) + }) +} + +func createOrderTrackingHook(name string, tracker *orderTracker) sharedtest.TestHook { + h := sharedtest.NewTestHook(name) + h.BeforeInject = func( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + ) (ldhooks.EvaluationSeriesData, error) { + tracker.orderBefore = append(tracker.orderBefore, name) + return data, nil + } + h.AfterInject = func( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + detail ldreason.EvaluationDetail, + ) (ldhooks.EvaluationSeriesData, error) { + tracker.orderAfter = append(tracker.orderAfter, name) + return data, nil + } + + return h +} diff --git a/internal/sharedtest/hooks.go b/internal/sharedtest/hooks.go new file mode 100644 index 00000000..59890b7b --- /dev/null +++ b/internal/sharedtest/hooks.go @@ -0,0 +1,168 @@ +package sharedtest + +import ( + "context" + "reflect" + "testing" + + "golang.org/x/exp/slices" + + "github.com/stretchr/testify/assert" + + "github.com/launchdarkly/go-sdk-common/v3/ldreason" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" +) + +// HookStage is the stage of a hook being executed. +type HookStage string + +const ( + // HookStageBeforeEvaluation is the stage executed before evaluation. + HookStageBeforeEvaluation = HookStage("before") + // HookStageAfterEvaluation is the stage executed after evaluation. + HookStageAfterEvaluation = HookStage("after") +) + +// HookEvalCapture is used to capture the information provided to a hook during execution. +type HookEvalCapture struct { + GoContext context.Context + EvaluationSeriesContext ldhooks.EvaluationSeriesContext + EvaluationSeriesData ldhooks.EvaluationSeriesData + Detail ldreason.EvaluationDetail +} + +// HookExpectedCall represents an expected call to a hook. +type HookExpectedCall struct { + HookStage HookStage + EvalCapture HookEvalCapture +} + +type hookTestData struct { + captureBefore []HookEvalCapture + captureAfter []HookEvalCapture +} + +// TestHook is a hook for testing to be used only by the SDK tests. +type TestHook struct { + testData *hookTestData + metadata ldhooks.Metadata + BeforeInject func(context.Context, ldhooks.EvaluationSeriesContext, + ldhooks.EvaluationSeriesData) (ldhooks.EvaluationSeriesData, error) + + AfterInject func(context.Context, ldhooks.EvaluationSeriesContext, + ldhooks.EvaluationSeriesData, ldreason.EvaluationDetail) (ldhooks.EvaluationSeriesData, error) +} + +// NewTestHook creates a new test hook. +func NewTestHook(name string) TestHook { + return TestHook{ + testData: &hookTestData{ + captureBefore: make([]HookEvalCapture, 0), + captureAfter: make([]HookEvalCapture, 0), + }, + BeforeInject: func(ctx context.Context, seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData) (ldhooks.EvaluationSeriesData, error) { + return data, nil + }, + AfterInject: func(ctx context.Context, seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, detail ldreason.EvaluationDetail) (ldhooks.EvaluationSeriesData, error) { + return data, nil + }, + metadata: ldhooks.NewMetadata(name), + } +} + +// Metadata gets the meta-data for the hook. +func (h TestHook) Metadata() ldhooks.Metadata { + return h.metadata +} + +// BeforeEvaluation testing implementation of the BeforeEvaluation stage. +func (h TestHook) BeforeEvaluation( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, +) (ldhooks.EvaluationSeriesData, error) { + h.testData.captureBefore = append(h.testData.captureBefore, HookEvalCapture{ + EvaluationSeriesContext: seriesContext, + EvaluationSeriesData: data, + GoContext: ctx, + }) + return h.BeforeInject(ctx, seriesContext, data) +} + +// AfterEvaluation testing implementation of the AfterEvaluation stage. +func (h TestHook) AfterEvaluation( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + detail ldreason.EvaluationDetail, +) (ldhooks.EvaluationSeriesData, error) { + h.testData.captureAfter = append(h.testData.captureAfter, HookEvalCapture{ + EvaluationSeriesContext: seriesContext, + EvaluationSeriesData: data, + Detail: detail, + GoContext: ctx, + }) + return h.AfterInject(ctx, seriesContext, data, detail) +} + +// Verify is used to verify that the hook received calls it expected. +func (h TestHook) Verify(t *testing.T, calls ...HookExpectedCall) { + localBeforeCalls := make([]HookEvalCapture, len(h.testData.captureBefore)) + localAfterCalls := make([]HookEvalCapture, len(h.testData.captureAfter)) + + copy(localBeforeCalls, h.testData.captureBefore) + copy(localAfterCalls, h.testData.captureAfter) + + for _, call := range calls { + found := false + switch call.HookStage { + case HookStageBeforeEvaluation: + for i, beforeCall := range localBeforeCalls { + if reflect.DeepEqual(beforeCall, call.EvalCapture) { + localBeforeCalls = slices.Delete(localBeforeCalls, i, i+1) + found = true + } else { + logDebugData(t, beforeCall, call) + } + } + case HookStageAfterEvaluation: + for i, afterCall := range localAfterCalls { + if reflect.DeepEqual(afterCall, call.EvalCapture) { + localAfterCalls = slices.Delete(localAfterCalls, i, i+1) + found = true + } else { + logDebugData(t, afterCall, call) + } + } + default: + assert.FailNowf(t, "Unhandled hook stage", "stage: %v", call.HookStage) + } + if !found { + assert.FailNowf(t, "Unable to find matching call", "details: %+v", call) + } + } +} + +// VerifyNoCalls will assert if the hook has received any calls. +func (h TestHook) VerifyNoCalls(t *testing.T) { + assert.Empty(t, h.testData.captureBefore) + assert.Empty(t, h.testData.captureAfter) +} + +func logDebugData(t *testing.T, afterCall HookEvalCapture, call HookExpectedCall) { + // Log some information to help understand test failures. + if !reflect.DeepEqual(afterCall.GoContext, call.EvalCapture.GoContext) { + t.Log("Go context not equal") + } + if !reflect.DeepEqual(afterCall.Detail, call.EvalCapture.Detail) { + t.Log("Evaluation detail not equal") + } + if !reflect.DeepEqual(afterCall.EvaluationSeriesData, call.EvalCapture.EvaluationSeriesData) { + t.Log("Evaluation series data not equal") + } + if !reflect.DeepEqual(afterCall.EvaluationSeriesContext, call.EvalCapture.EvaluationSeriesContext) { + t.Log("Evaluation series context not equal") + } +} diff --git a/ldhooks/evaluation_series_context.go b/ldhooks/evaluation_series_context.go new file mode 100644 index 00000000..1cfeec0f --- /dev/null +++ b/ldhooks/evaluation_series_context.go @@ -0,0 +1,46 @@ +package ldhooks + +import ( + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" +) + +// EvaluationSeriesContext contains contextual information for the execution of stages in the evaluation series. +type EvaluationSeriesContext struct { + flagKey string + context ldcontext.Context + defaultValue ldvalue.Value + method string +} + +// NewEvaluationSeriesContext create a new EvaluationSeriesContext. Hook implementations do not need to use this +// function. +func NewEvaluationSeriesContext(flagKey string, evalContext ldcontext.Context, + defaultValue ldvalue.Value, method string) EvaluationSeriesContext { + return EvaluationSeriesContext{ + flagKey: flagKey, + context: evalContext, + defaultValue: defaultValue, + method: method, + } +} + +// FlagKey gets the key of the flag being evaluated. +func (c EvaluationSeriesContext) FlagKey() string { + return c.flagKey +} + +// Context gets the evaluation context the flag is being evaluated for. +func (c EvaluationSeriesContext) Context() ldcontext.Context { + return c.context +} + +// DefaultValue gets the default value for the evaluation. +func (c EvaluationSeriesContext) DefaultValue() ldvalue.Value { + return c.defaultValue +} + +// Method gets a string represent of the LDClient method being executed. +func (c EvaluationSeriesContext) Method() string { + return c.method +} diff --git a/ldhooks/evaluation_series_data.go b/ldhooks/evaluation_series_data.go new file mode 100644 index 00000000..80fd8375 --- /dev/null +++ b/ldhooks/evaluation_series_data.go @@ -0,0 +1,77 @@ +package ldhooks + +// EvaluationSeriesData is an immutable data type used for passing implementation-specific data between stages in the +// evaluation series. +type EvaluationSeriesData struct { + data map[string]any +} + +// EvaluationSeriesDataBuilder should be used by hook implementers to append data +type EvaluationSeriesDataBuilder struct { + data map[string]any +} + +// EmptyEvaluationSeriesData returns empty series data. This function is not intended for use by hook implementors. +// Hook implementations should always use NewEvaluationSeriesBuilder. +func EmptyEvaluationSeriesData() EvaluationSeriesData { + return EvaluationSeriesData{ + data: make(map[string]any), + } +} + +// Get gets the value associated with the given key. If there is no value, then ok will be false. +func (b EvaluationSeriesData) Get(key string) (value any, ok bool) { + val, ok := b.data[key] + return val, ok +} + +// AsAnyMap returns a copy of the contents of the series data as a map. +func (b EvaluationSeriesData) AsAnyMap() map[string]any { + ret := make(map[string]any) + for key, value := range b.data { + ret[key] = value + } + return ret +} + +// NewEvaluationSeriesBuilder creates an EvaluationSeriesDataBuilder based on the provided EvaluationSeriesData. +// +// func(h MyHook) BeforeEvaluation(seriesContext EvaluationSeriesContext, +// data EvaluationSeriesData) EvaluationSeriesData { +// // Some hook functionality. +// return NewEvaluationSeriesBuilder(data).Set("my-key", myValue).Build() +// } +func NewEvaluationSeriesBuilder(data EvaluationSeriesData) *EvaluationSeriesDataBuilder { + newData := make(map[string]any, len(data.data)) + for k, v := range data.data { + newData[k] = v + } + return &EvaluationSeriesDataBuilder{ + data: newData, + } +} + +// Set sets the given key to the given value. +func (b *EvaluationSeriesDataBuilder) Set(key string, value any) *EvaluationSeriesDataBuilder { + b.data[key] = value + return b +} + +// Merge copies the keys and values from the given map to the builder. +func (b *EvaluationSeriesDataBuilder) Merge(newValues map[string]any) *EvaluationSeriesDataBuilder { + for k, v := range newValues { + b.data[k] = v + } + return b +} + +// Build builds an EvaluationSeriesData based on the contents of the builder. +func (b *EvaluationSeriesDataBuilder) Build() EvaluationSeriesData { + newData := make(map[string]any, len(b.data)) + for k, v := range b.data { + newData[k] = v + } + return EvaluationSeriesData{ + data: newData, + } +} diff --git a/ldhooks/evaluation_series_data_test.go b/ldhooks/evaluation_series_data_test.go new file mode 100644 index 00000000..949fa512 --- /dev/null +++ b/ldhooks/evaluation_series_data_test.go @@ -0,0 +1,75 @@ +package ldhooks + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCanCreateEmptyData(t *testing.T) { + empty := EmptyEvaluationSeriesData() + assert.Empty(t, empty.data) +} + +func TestCanCreateNewDataWithSetFields(t *testing.T) { + withEntries := NewEvaluationSeriesBuilder(EmptyEvaluationSeriesData()). + Set("A", "a"). + Set("B", "b").Build() + + assert.Len(t, withEntries.data, 2) + + aVal, aPresent := withEntries.Get("A") + assert.True(t, aPresent) + assert.Equal(t, "a", aVal) + + bVal, bPresent := withEntries.Get("B") + assert.True(t, bPresent) + assert.Equal(t, "b", bVal) +} + +func TestCanAccessAMissingEntry(t *testing.T) { + empty := EmptyEvaluationSeriesData() + val, present := empty.Get("something") + assert.Zero(t, val) + assert.False(t, present) +} + +func TestDataBuiltFromOtherDataDoesNotAffectOriginal(t *testing.T) { + original := NewEvaluationSeriesBuilder(EmptyEvaluationSeriesData()). + Set("A", "a"). + Set("B", "b").Build() + + derivative := NewEvaluationSeriesBuilder(original). + Set("A", "AAA").Build() + + originalA, _ := original.Get("A") + assert.Equal(t, "a", originalA) + + derivativeA, _ := derivative.Get("A") + assert.Equal(t, "AAA", derivativeA) +} + +func TestCanMergeDataFromMap(t *testing.T) { + original := NewEvaluationSeriesBuilder(EmptyEvaluationSeriesData()). + Set("A", "a"). + Set("B", "b").Build() + + merged := NewEvaluationSeriesBuilder(original). + Merge(map[string]any{ + "A": "AAA", + "C": "c", + }).Build() + + originalA, _ := original.Get("A") + assert.Equal(t, "a", originalA) + + originalC, originalCPresent := original.Get("C") + assert.Zero(t, originalC) + assert.False(t, originalCPresent) + + derivativeA, _ := merged.Get("A") + assert.Equal(t, "AAA", derivativeA) + + derivativeC, _ := merged.Get("C") + assert.Equal(t, "c", derivativeC) +} diff --git a/ldhooks/hooks.go b/ldhooks/hooks.go new file mode 100644 index 00000000..816a452f --- /dev/null +++ b/ldhooks/hooks.go @@ -0,0 +1,93 @@ +package ldhooks + +import ( + "context" + + "github.com/launchdarkly/go-sdk-common/v3/ldreason" +) + +// Implementation Note: The Unimplemented struct is provided to simplify hook implementation. It should always +// contain an implementation of all series and handler interfaces. It should not contain the Hook interface directly +// because the implementer should be required to implement Metadata. + +// A Hook is used to extend the functionality of the SDK. +// +// In order to avoid implementing unused methods, as well as easing maintenance of compatibility, implementors should +// compose the `Unimplemented`. +// +// type MyHook struct { +// ldhooks.Unimplemented +// } +type Hook interface { + Metadata() Metadata + EvaluationSeries +} + +// The EvaluationSeries is composed of stages, methods that are called during the evaluation of flags. +type EvaluationSeries interface { + // BeforeEvaluation is called during the execution of a variation Method before the flag value has been determined. + // The Method returns EvaluationSeriesData that will be passed to the next stage in the evaluation + // series. + // + // The EvaluationSeriesData returned should always contain the previous data as well as any new data which is + // required for subsequent stage execution. + BeforeEvaluation( + ctx context.Context, + seriesContext EvaluationSeriesContext, + data EvaluationSeriesData, + ) (EvaluationSeriesData, error) + + // AfterEvaluation is called during the execution of the variation Method after the flag value has been determined. + // The Method returns EvaluationSeriesData that will be passed to the next stage in the evaluation + // series. + // + // The EvaluationSeriesData returned should always contain the previous data as well as any new data which is + // required for subsequent stage execution. + AfterEvaluation(ctx context.Context, + seriesContext EvaluationSeriesContext, + data EvaluationSeriesData, + detail ldreason.EvaluationDetail, + ) (EvaluationSeriesData, error) +} + +// hookInterfaces is an interface for implementation by the Unimplemented +type hookInterfaces interface { + EvaluationSeries +} + +// Unimplemented implements all Hook methods with empty functions. +// Hook implementors should use this to avoid having to implement empty methods and to ease updates when the Hook +// interface is extended. +// +// type MyHook struct { +// ldhooks.Unimplemented +// } +// +// The hook should implement at least one stage/handler as well as the Metadata function. +type Unimplemented struct { +} + +// BeforeEvaluation is a default implementation of the BeforeEvaluation stage. +func (h Unimplemented) BeforeEvaluation( + _ context.Context, + _ EvaluationSeriesContext, + data EvaluationSeriesData, +) (EvaluationSeriesData, error) { + return data, nil +} + +// AfterEvaluation is a default implementation of the AfterEvaluation stage. +func (h Unimplemented) AfterEvaluation( + _ context.Context, + _ EvaluationSeriesContext, + data EvaluationSeriesData, + _ ldreason.EvaluationDetail, +) (EvaluationSeriesData, error) { + return data, nil +} + +// Implementation note: Unimplemented does not implement GetMetaData because that must be implemented by hook +// implementors. + +// Ensure Unimplemented implements required interfaces. +var _ hookInterfaces = Unimplemented{} diff --git a/ldhooks/metadata.go b/ldhooks/metadata.go new file mode 100644 index 00000000..19dc4610 --- /dev/null +++ b/ldhooks/metadata.go @@ -0,0 +1,33 @@ +package ldhooks + +// Metadata contains information about a specific hook implementation. +type Metadata struct { + name string +} + +// HookMetadataOption represents a functional means of setting additional, optional, attributes of the Metadata. +type HookMetadataOption func(hook *Metadata) + +// Implementation note: Currently the hook metadata only contains a name, but it may contain additional, and likely +// optional, fields in the future. The HookMetadataOption will allow for additional options to be added without +// breaking callsites. +// +// Example: +// NewMetadata("my-hook", WithVendorName("LaunchDarkly")) +// + +// NewMetadata creates Metadata with the provided name. +func NewMetadata(name string, opts ...HookMetadataOption) Metadata { + metadata := Metadata{ + name: name, + } + for _, opt := range opts { + opt(&metadata) + } + return metadata +} + +// Name gets the name of the hook implementation. +func (m Metadata) Name() string { + return m.name +} diff --git a/ldhooks/package_info.go b/ldhooks/package_info.go new file mode 100644 index 00000000..e647615d --- /dev/null +++ b/ldhooks/package_info.go @@ -0,0 +1,5 @@ +// Package ldhooks allows for writing extensions to the LaunchDarkly client functionality for purposes such as +// telemetry. +// +// A developer does not need to use this package in the typical operation of the LaunchDarkly SDK. +package ldhooks From f93d96c4b76e3d7b7c4dac15338490ae955b4e1e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 28 Mar 2024 15:54:26 -0700 Subject: [PATCH 02/31] feat: Add support for hooks. (#126) Second PR. This connects hooks to the ldclient. It does not add the `Ex` methods to any interface, and it does not add `AddHooks` to any interface. --------- Co-authored-by: Casey Waldren --- config.go | 9 + ldclient.go | 325 ++++++++++++++++++++++++++++++++++-- ldclient_evaluation_test.go | 171 +++++++++++++++++++ ldclient_events.go | 131 ++++++++++++++- ldclient_hooks_test.go | 291 ++++++++++++++++++++++++++++++++ ldclient_migration_test.go | 94 +++++++---- 6 files changed, 970 insertions(+), 51 deletions(-) create mode 100644 ldclient_hooks_test.go diff --git a/config.go b/config.go index 7038b526..5be351b1 100644 --- a/config.go +++ b/config.go @@ -3,6 +3,7 @@ package ldclient import ( ldevents "github.com/launchdarkly/go-sdk-events/v3" "github.com/launchdarkly/go-server-sdk/v7/interfaces" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" "github.com/launchdarkly/go-server-sdk/v7/subsystems" ) @@ -188,4 +189,12 @@ type Config struct { // Application metadata may be used in LaunchDarkly analytics or other product features, but does not // affect feature flag evaluations. ApplicationInfo interfaces.ApplicationInfo + + // Initial set of hooks for the client. + // + // Hooks provide entrypoints which allow for observation of SDK functions. + // + // LaunchDarkly provides integration packages, and most applications will not + // need to implement their own hooks. + Hooks []ldhooks.Hook } diff --git a/ldclient.go b/ldclient.go index f9216ebd..430b86e1 100644 --- a/ldclient.go +++ b/ldclient.go @@ -1,6 +1,7 @@ package ldclient import ( + gocontext "context" "crypto/hmac" "crypto/sha256" "encoding/hex" @@ -24,7 +25,9 @@ import ( "github.com/launchdarkly/go-server-sdk/v7/internal/datakinds" "github.com/launchdarkly/go-server-sdk/v7/internal/datasource" "github.com/launchdarkly/go-server-sdk/v7/internal/datastore" + "github.com/launchdarkly/go-server-sdk/v7/internal/hooks" "github.com/launchdarkly/go-server-sdk/v7/ldcomponents" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" "github.com/launchdarkly/go-server-sdk/v7/subsystems" "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoreimpl" ) @@ -32,6 +35,35 @@ import ( // Version is the SDK version. const Version = internal.SDKVersion +const ( + boolVarFuncName = "LDClient.BoolVariation" + intVarFuncName = "LDClient.IntVariation" + floatVarFuncName = "LDClient.Float64Variation" + stringVarFuncName = "LDClient.StringVariation" + jsonVarFuncName = "LDClient.JSONVariation" + + boolVarExFuncName = "LDClient.BoolVariationCtx" + intVarExFuncName = "LDClient.IntVariationCtx" + floatVarExFuncName = "LDClient.Float64VariationCtx" + stringVarExFuncName = "LDClient.StringVariationCtx" + jsonVarExFuncName = "LDClient.JSONVariationCtx" + + boolVarDetailFuncName = "LDClient.BoolVariationDetail" + intVarDetailFuncName = "LDClient.IntVariationDetail" + floatVarDetailFuncName = "LDClient.Float64VariationDetail" + stringVarDetailFuncName = "LDClient.StringVariationDetail" + jsonVarDetailFuncName = "LDClient.JSONVariationDetail" + + boolVarDetailExFuncName = "LDClient.BoolVariationDetailCtx" + intVarDetailExFuncName = "LDClient.IntVariationDetailCtx" + floatVarDetailExFuncName = "LDClient.Float64VariationDetailCtx" + stringVarDetailExFuncName = "LDClient.StringVariationDetailCtx" + jsonVarDetailExFuncName = "LDClient.JSONVariationDetailCtx" + + migrationVarFuncName = "LDClient.MigrationVariation" + migrationVarExFuncName = "LDClient.MigrationVariationCtx" +) + // LDClient is the LaunchDarkly client. // // This object evaluates feature flags, generates analytics events, and communicates with @@ -68,6 +100,7 @@ type LDClient struct { withEventsDisabled interfaces.LDClientInterface logEvaluationErrors bool offline bool + hookRunner *hooks.Runner } // Initialization errors @@ -288,6 +321,8 @@ func MakeCustomClient(sdkKey string, config Config, waitFor time.Duration) (*LDC }, ) + client.hookRunner = hooks.NewRunner(loggers, config.Hooks) + clientValid = true client.dataSource.Start(closeWhenReady) if waitFor > 0 && client.dataSource != datasource.NewNullDataSource() { @@ -341,13 +376,34 @@ func createDataSource( func (client *LDClient) MigrationVariation( key string, context ldcontext.Context, defaultStage ldmigration.Stage, ) (ldmigration.Stage, interfaces.LDMigrationOpTracker, error) { - return client.migrationVariation(key, context, defaultStage, client.eventsDefault) + return client.migrationVariation(gocontext.TODO(), key, context, defaultStage, client.eventsDefault, + migrationVarFuncName) +} + +// MigrationVariationCtx returns the migration stage of the migration feature flag for the given evaluation context. +// +// Cancelling the context.Context will not cause the evaluation to be cancelled. The context.Context is used +// by hook implementations refer to [ldhooks.Hook]. +// +// Returns defaultStage if there is an error or if the flag doesn't exist. +func (client *LDClient) MigrationVariationCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultStage ldmigration.Stage, +) (ldmigration.Stage, interfaces.LDMigrationOpTracker, error) { + return client.migrationVariation(ctx, key, context, defaultStage, client.eventsDefault, migrationVarExFuncName) } func (client *LDClient) migrationVariation( - key string, context ldcontext.Context, defaultStage ldmigration.Stage, eventsScope eventsScope, + ctx gocontext.Context, + key string, context ldcontext.Context, defaultStage ldmigration.Stage, eventsScope eventsScope, method string, ) (ldmigration.Stage, interfaces.LDMigrationOpTracker, error) { - detail, flag, err := client.variationAndFlag(key, context, ldvalue.String(string(defaultStage)), true, eventsScope) + stageValue := ldvalue.String(string(defaultStage)) + hookExecution := client.hookRunner.PrepareEvaluationSeries(key, context, stageValue, method) + hookExecution = client.hookRunner.BeforeEvaluation(ctx, hookExecution) + detail, flag, err := client.variationAndFlag(key, context, stageValue, true, + eventsScope) tracker := NewMigrationOpTracker(key, flag, context, detail, defaultStage) if err != nil { @@ -357,10 +413,12 @@ func (client *LDClient) migrationVariation( stage, err := ldmigration.ParseStage(detail.Value.StringValue()) if err != nil { detail = ldreason.NewEvaluationDetailForError(ldreason.EvalErrorWrongType, ldvalue.String(string(defaultStage))) + client.hookRunner.AfterEvaluation(ctx, hookExecution, detail) tracker := NewMigrationOpTracker(key, flag, context, detail, defaultStage) return defaultStage, tracker, fmt.Errorf("%s; returning default stage %s", err, defaultStage) } + client.hookRunner.AfterEvaluation(ctx, hookExecution, detail) return stage, tracker, nil } @@ -665,7 +723,8 @@ func (client *LDClient) AllFlagsState(context ldcontext.Context, options ...flag // // For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluating#go func (client *LDClient) BoolVariation(key string, context ldcontext.Context, defaultVal bool) (bool, error) { - detail, err := client.variation(key, context, ldvalue.Bool(defaultVal), true, client.eventsDefault) + detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Bool(defaultVal), true, + client.eventsDefault, boolVarFuncName) return detail.Value.BoolValue(), err } @@ -678,7 +737,46 @@ func (client *LDClient) BoolVariationDetail( context ldcontext.Context, defaultVal bool, ) (bool, ldreason.EvaluationDetail, error) { - detail, err := client.variation(key, context, ldvalue.Bool(defaultVal), true, client.eventsWithReasons) + detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Bool(defaultVal), true, + client.eventsWithReasons, boolVarDetailFuncName) + return detail.Value.BoolValue(), detail, err +} + +// BoolVariationCtx is the same as [LDClient.BoolVariation], but accepts a context.Context. +// +// Cancelling the context.Context will not cause the evaluation to be cancelled. The context.Context is used +// by hook implementations refer to [ldhooks.Hook]. +// +// Returns defaultVal if there is an error, if the flag doesn't exist, or the feature is turned off and +// has no off variation. +// +// For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluating#go +func (client *LDClient) BoolVariationCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal bool, +) (bool, error) { + detail, err := client.variationWithHooks(ctx, key, context, ldvalue.Bool(defaultVal), true, + client.eventsDefault, boolVarExFuncName) + return detail.Value.BoolValue(), err +} + +// BoolVariationDetailCtx is the same as [LDClient.BoolVariationCtx], but also returns further information about how +// the value was calculated. The "reason" data will also be included in analytics events. +// +// Cancelling the context.Context will not cause the evaluation to be cancelled. The context.Context is used +// by hook implementations refer to [ldhooks.Hook]. +// +// For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluation-reasons#go +func (client *LDClient) BoolVariationDetailCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal bool, +) (bool, ldreason.EvaluationDetail, error) { + detail, err := client.variationWithHooks(ctx, key, context, ldvalue.Bool(defaultVal), true, + client.eventsWithReasons, boolVarDetailExFuncName) return detail.Value.BoolValue(), detail, err } @@ -692,7 +790,8 @@ func (client *LDClient) BoolVariationDetail( // // For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluating#go func (client *LDClient) IntVariation(key string, context ldcontext.Context, defaultVal int) (int, error) { - detail, err := client.variation(key, context, ldvalue.Int(defaultVal), true, client.eventsDefault) + detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Int(defaultVal), true, + client.eventsDefault, intVarFuncName) return detail.Value.IntValue(), err } @@ -705,7 +804,48 @@ func (client *LDClient) IntVariationDetail( context ldcontext.Context, defaultVal int, ) (int, ldreason.EvaluationDetail, error) { - detail, err := client.variation(key, context, ldvalue.Int(defaultVal), true, client.eventsWithReasons) + detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Int(defaultVal), true, + client.eventsWithReasons, intVarDetailFuncName) + return detail.Value.IntValue(), detail, err +} + +// IntVariationCtx is the same as [LDClient.IntVariation], but accepts a context.Context. +// +// Cancelling the context.Context will not cause the evaluation to be cancelled. The context.Context is used +// by hook implementations refer to [ldhooks.Hook]. +// +// Returns defaultVal if there is an error, if the flag doesn't exist, or the feature is turned off and +// has no off variation. +// +// If the flag variation has a numeric value that is not an integer, it is rounded toward zero (truncated). +// +// For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluating#go +func (client *LDClient) IntVariationCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal int, +) (int, error) { + detail, err := client.variationWithHooks(ctx, key, context, ldvalue.Int(defaultVal), true, + client.eventsDefault, intVarExFuncName) + return detail.Value.IntValue(), err +} + +// IntVariationDetailCtx is the same as [LDClient.IntVariationCtx], but also returns further information about how +// the value was calculated. The "reason" data will also be included in analytics events. +// +// Cancelling the context.Context will not cause the evaluation to be cancelled. The context.Context is used +// by hook implementations refer to [ldhooks.Hook]. +// +// For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluation-reasons#go +func (client *LDClient) IntVariationDetailCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal int, +) (int, ldreason.EvaluationDetail, error) { + detail, err := client.variationWithHooks(ctx, key, context, ldvalue.Int(defaultVal), true, + client.eventsWithReasons, intVarDetailExFuncName) return detail.Value.IntValue(), detail, err } @@ -717,7 +857,8 @@ func (client *LDClient) IntVariationDetail( // // For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluating#go func (client *LDClient) Float64Variation(key string, context ldcontext.Context, defaultVal float64) (float64, error) { - detail, err := client.variation(key, context, ldvalue.Float64(defaultVal), true, client.eventsDefault) + detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Float64(defaultVal), + true, client.eventsDefault, floatVarFuncName) return detail.Value.Float64Value(), err } @@ -730,7 +871,46 @@ func (client *LDClient) Float64VariationDetail( context ldcontext.Context, defaultVal float64, ) (float64, ldreason.EvaluationDetail, error) { - detail, err := client.variation(key, context, ldvalue.Float64(defaultVal), true, client.eventsWithReasons) + detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Float64(defaultVal), + true, client.eventsWithReasons, floatVarDetailFuncName) + return detail.Value.Float64Value(), detail, err +} + +// Float64VariationCtx is the same as [LDClient.Float64Variation], but accepts a context.Context. +// +// Cancelling the context.Context will not cause the evaluation to be cancelled. The context.Context is used +// by hook implementations refer to [ldhooks.Hook]. +// +// Returns defaultVal if there is an error, if the flag doesn't exist, or the feature is turned off and +// has no off variation. +// +// For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluating#go +func (client *LDClient) Float64VariationCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal float64, +) (float64, error) { + detail, err := client.variationWithHooks(ctx, key, context, ldvalue.Float64(defaultVal), true, + client.eventsDefault, floatVarExFuncName) + return detail.Value.Float64Value(), err +} + +// Float64VariationDetailCtx is the same as [LDClient.Float64VariationCtx], but also returns further information about +// how the value was calculated. The "reason" data will also be included in analytics events. +// +// Cancelling the context.Context will not cause the evaluation to be cancelled. The context.Context is used +// by hook implementations refer to [ldhooks.Hook]. +// +// For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluation-reasons#go +func (client *LDClient) Float64VariationDetailCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal float64, +) (float64, ldreason.EvaluationDetail, error) { + detail, err := client.variationWithHooks(ctx, key, context, ldvalue.Float64(defaultVal), true, + client.eventsWithReasons, floatVarDetailExFuncName) return detail.Value.Float64Value(), detail, err } @@ -742,7 +922,8 @@ func (client *LDClient) Float64VariationDetail( // // For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluating#go func (client *LDClient) StringVariation(key string, context ldcontext.Context, defaultVal string) (string, error) { - detail, err := client.variation(key, context, ldvalue.String(defaultVal), true, client.eventsDefault) + detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.String(defaultVal), true, + client.eventsDefault, stringVarFuncName) return detail.Value.StringValue(), err } @@ -755,7 +936,46 @@ func (client *LDClient) StringVariationDetail( context ldcontext.Context, defaultVal string, ) (string, ldreason.EvaluationDetail, error) { - detail, err := client.variation(key, context, ldvalue.String(defaultVal), true, client.eventsWithReasons) + detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.String(defaultVal), true, + client.eventsWithReasons, stringVarDetailFuncName) + return detail.Value.StringValue(), detail, err +} + +// StringVariationCtx is the same as [LDClient.StringVariation], but accepts a context.Context. +// +// Cancelling the context.Context will not cause the evaluation to be cancelled. The context.Context is used +// by hook implementations refer to [ldhooks.Hook]. +// +// Returns defaultVal if there is an error, if the flag doesn't exist, or the feature is turned off and has +// no off variation. +// +// For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluating#go +func (client *LDClient) StringVariationCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal string, +) (string, error) { + detail, err := client.variationWithHooks(ctx, key, context, ldvalue.String(defaultVal), true, + client.eventsDefault, stringVarExFuncName) + return detail.Value.StringValue(), err +} + +// StringVariationDetailCtx is the same as [LDClient.StringVariationCtx], but also returns further information about how +// the value was calculated. The "reason" data will also be included in analytics events. +// +// Cancelling the context.Context will not cause the evaluation to be cancelled. The context.Context is used +// by hook implementations refer to [ldhooks.Hook]. +// +// For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluation-reasons#go +func (client *LDClient) StringVariationDetailCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal string, +) (string, ldreason.EvaluationDetail, error) { + detail, err := client.variationWithHooks(ctx, key, context, ldvalue.String(defaultVal), true, + client.eventsWithReasons, stringVarDetailExFuncName) return detail.Value.StringValue(), detail, err } @@ -787,7 +1007,8 @@ func (client *LDClient) JSONVariation( context ldcontext.Context, defaultVal ldvalue.Value, ) (ldvalue.Value, error) { - detail, err := client.variation(key, context, defaultVal, false, client.eventsDefault) + detail, err := client.variationWithHooks(gocontext.TODO(), key, context, defaultVal, false, client.eventsDefault, + jsonVarFuncName) return detail.Value, err } @@ -800,7 +1021,62 @@ func (client *LDClient) JSONVariationDetail( context ldcontext.Context, defaultVal ldvalue.Value, ) (ldvalue.Value, ldreason.EvaluationDetail, error) { - detail, err := client.variation(key, context, defaultVal, false, client.eventsWithReasons) + detail, err := client.variationWithHooks(gocontext.TODO(), key, context, defaultVal, false, client.eventsWithReasons, + jsonVarDetailFuncName) + return detail.Value, detail, err +} + +// JSONVariationCtx is the same as [LDClient.JSONVariation], but accepts a context.Context. +// +// Cancelling the context.Context will not cause the evaluation to be cancelled. The context.Context is used +// by hook implementations refer to [ldhooks.Hook]. +// +// The value is returned as an [ldvalue.Value], which can be inspected or converted to other types using +// methods such as [ldvalue.Value.GetType] and [ldvalue.Value.BoolValue]. The defaultVal parameter also uses this +// type. For instance, if the values for this flag are JSON arrays: +// +// defaultValAsArray := ldvalue.BuildArray(). +// Add(ldvalue.String("defaultFirstItem")). +// Add(ldvalue.String("defaultSecondItem")). +// Build() +// result, err := client.JSONVariationCtx(ctx, flagKey, context, defaultValAsArray) +// firstItemAsString := result.GetByIndex(0).StringValue() // "defaultFirstItem", etc. +// +// You can also use unparsed json.RawMessage values: +// +// defaultValAsRawJSON := ldvalue.Raw(json.RawMessage(`{"things":[1,2,3]}`)) +// result, err := client.JSONVariation(flagKey, context, defaultValAsJSON +// resultAsRawJSON := result.AsRaw() +// +// Returns defaultVal if there is an error, if the flag doesn't exist, or the feature is turned off. +// +// For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluating#go +func (client *LDClient) JSONVariationCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal ldvalue.Value, +) (ldvalue.Value, error) { + detail, err := client.variationWithHooks(ctx, key, context, defaultVal, false, client.eventsDefault, + jsonVarExFuncName) + return detail.Value, err +} + +// JSONVariationDetailCtx is the same as [LDClient.JSONVariationCtx], but also returns further information about how +// the value was calculated. The "reason" data will also be included in analytics events. +// +// Cancelling the context.Context will not cause the evaluation to be cancelled. The context.Context is used +// by hook implementations refer to [ldhooks.Hook]. +// +// For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluation-reasons#go +func (client *LDClient) JSONVariationDetailCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal ldvalue.Value, +) (ldvalue.Value, ldreason.EvaluationDetail, error) { + detail, err := client.variationWithHooks(ctx, key, context, defaultVal, false, client.eventsWithReasons, + jsonVarDetailExFuncName) return detail.Value, detail, err } @@ -880,6 +1156,22 @@ func (client *LDClient) variation( return detail, err } +func (client *LDClient) variationWithHooks( + context gocontext.Context, + key string, + evalContext ldcontext.Context, + defaultVal ldvalue.Value, + checkType bool, + eventsScope eventsScope, + method string, +) (ldreason.EvaluationDetail, error) { + execution := client.hookRunner.PrepareEvaluationSeries(key, evalContext, defaultVal, method) + execution = client.hookRunner.BeforeEvaluation(context, execution) + detail, err := client.variation(key, evalContext, defaultVal, checkType, eventsScope) + client.hookRunner.AfterEvaluation(context, execution, detail) + return detail, err +} + // Generic method for evaluating a feature flag for a given evaluation context, // returning both the result and the flag. func (client *LDClient) variationAndFlag( @@ -1005,6 +1297,13 @@ func (client *LDClient) evaluateInternal( return result, feature, nil } +// AddHooks allows adding a hook after creating the LDClient. +// +// If a hook can be registered at LDClient initialization, then the Hooks field of Config can be used instead. +func (client *LDClient) AddHooks(hooks ...ldhooks.Hook) { + client.hookRunner.AddHooks(hooks...) +} + func newEvaluationError(jsonValue ldvalue.Value, errorKind ldreason.EvalErrorKind) ldreason.EvaluationDetail { return ldreason.EvaluationDetail{ Value: jsonValue, diff --git a/ldclient_evaluation_test.go b/ldclient_evaluation_test.go index d93b0f65..b541f26f 100644 --- a/ldclient_evaluation_test.go +++ b/ldclient_evaluation_test.go @@ -1,6 +1,7 @@ package ldclient import ( + gocontext "context" "encoding/json" "errors" "testing" @@ -189,6 +190,19 @@ func TestBoolVariation(t *testing.T) { }) }) + t.Run("simpleCtx", func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.setupSingleValueFlag(evalFlagKey, ldvalue.Bool(true)) + + actual, err := p.client.BoolVariationCtx(gocontext.TODO(), evalFlagKey, evalTestUser, defaultVal) + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + + p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.Bool(expected), ldvalue.Bool(defaultVal), noReason) + }) + }) + t.Run("detail", func(t *testing.T) { withClientEvalTestParams(func(p clientEvalTestParams) { p.setupSingleValueFlag(evalFlagKey, ldvalue.Bool(true)) @@ -203,6 +217,21 @@ func TestBoolVariation(t *testing.T) { p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.Bool(expected), ldvalue.Bool(defaultVal), detail.Reason) }) }) + + t.Run("detailCtx", func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.setupSingleValueFlag(evalFlagKey, ldvalue.Bool(true)) + + actual, detail, err := p.client.BoolVariationDetailCtx(gocontext.TODO(), evalFlagKey, evalTestUser, defaultVal) + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + assert.Equal(t, ldreason.NewEvaluationDetail(ldvalue.Bool(expected), expectedVariationForSingleValueFlag, + expectedReasonForSingleValueFlag), detail) + + p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.Bool(expected), ldvalue.Bool(defaultVal), detail.Reason) + }) + }) } func TestIntVariation(t *testing.T) { @@ -234,6 +263,34 @@ func TestIntVariation(t *testing.T) { p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.Int(expected), ldvalue.Int(defaultVal), detail.Reason) }) + + t.Run("simpleCtx", func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.setupSingleValueFlag(evalFlagKey, ldvalue.Int(expected)) + + actual, err := p.client.IntVariationCtx(gocontext.TODO(), evalFlagKey, evalTestUser, defaultVal) + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + + p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.Int(expected), ldvalue.Int(defaultVal), noReason) + }) + }) + + t.Run("detailCtx", func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.setupSingleValueFlag(evalFlagKey, ldvalue.Int(expected)) + + actual, detail, err := p.client.IntVariationDetailCtx(gocontext.TODO(), evalFlagKey, evalTestUser, defaultVal) + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + assert.Equal(t, ldreason.NewEvaluationDetail(ldvalue.Int(expected), expectedVariationForSingleValueFlag, + expectedReasonForSingleValueFlag), detail) + + p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.Int(expected), ldvalue.Int(defaultVal), detail.Reason) + }) + }) }) t.Run("rounds float toward zero", func(t *testing.T) { @@ -293,6 +350,34 @@ func TestFloat64Variation(t *testing.T) { p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.Float64(expected), ldvalue.Float64(defaultVal), detail.Reason) }) }) + + t.Run("simpleCtx", func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.setupSingleValueFlag(evalFlagKey, ldvalue.Float64(expected)) + + actual, err := p.client.Float64VariationCtx(gocontext.TODO(), evalFlagKey, evalTestUser, defaultVal) + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + + p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.Float64(expected), ldvalue.Float64(defaultVal), noReason) + }) + }) + + t.Run("detailCtx", func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.setupSingleValueFlag(evalFlagKey, ldvalue.Float64(expected)) + + actual, detail, err := p.client.Float64VariationDetailCtx(gocontext.TODO(), evalFlagKey, evalTestUser, defaultVal) + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + assert.Equal(t, ldreason.NewEvaluationDetail(ldvalue.Float64(expected), expectedVariationForSingleValueFlag, + expectedReasonForSingleValueFlag), detail) + + p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.Float64(expected), ldvalue.Float64(defaultVal), detail.Reason) + }) + }) } func TestStringVariation(t *testing.T) { @@ -311,6 +396,19 @@ func TestStringVariation(t *testing.T) { }) }) + t.Run("simpleCtx", func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.setupSingleValueFlag(evalFlagKey, ldvalue.String(expected)) + + actual, err := p.client.StringVariationCtx(gocontext.TODO(), evalFlagKey, evalTestUser, defaultVal) + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + + p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.String(expected), ldvalue.String(defaultVal), noReason) + }) + }) + t.Run("sampling ratios are not defined by default", func(t *testing.T) { withClientEvalTestParams(func(p clientEvalTestParams) { p.setupSingleValueFlag(evalFlagKey, ldvalue.String(expected)) @@ -361,6 +459,21 @@ func TestStringVariation(t *testing.T) { p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.String(expected), ldvalue.String(defaultVal), detail.Reason) }) }) + + t.Run("detailCtx", func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.setupSingleValueFlag(evalFlagKey, ldvalue.String(expected)) + + actual, detail, err := p.client.StringVariationDetailCtx(gocontext.TODO(), evalFlagKey, evalTestUser, defaultVal) + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + assert.Equal(t, ldreason.NewEvaluationDetail(ldvalue.String(expected), expectedVariationForSingleValueFlag, + expectedReasonForSingleValueFlag), detail) + + p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.String(expected), ldvalue.String(defaultVal), detail.Reason) + }) + }) } func TestJSONRawVariation(t *testing.T) { @@ -398,6 +511,36 @@ func TestJSONRawVariation(t *testing.T) { ldvalue.CopyArbitraryValue(defaultVal), detail.Reason) }) }) + + t.Run("simpleCtx", func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.setupSingleValueFlag(evalFlagKey, ldvalue.CopyArbitraryValue(expectedValue)) + + actual, err := p.client.JSONVariationCtx(gocontext.TODO(), evalFlagKey, evalTestUser, ldvalue.Raw(defaultVal)) + + assert.NoError(t, err) + assert.Equal(t, expectedRaw, actual.AsRaw()) + + p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.CopyArbitraryValue(expectedValue), + ldvalue.CopyArbitraryValue(defaultVal), noReason) + }) + }) + + t.Run("detailCtx", func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.setupSingleValueFlag(evalFlagKey, ldvalue.CopyArbitraryValue(expectedValue)) + + actual, detail, err := p.client.JSONVariationDetailCtx(gocontext.TODO(), evalFlagKey, evalTestUser, ldvalue.Raw(defaultVal)) + + assert.NoError(t, err) + assert.Equal(t, expectedRaw, actual.AsRaw()) + assert.Equal(t, ldreason.NewEvaluationDetail(ldvalue.Parse(expectedRaw), expectedVariationForSingleValueFlag, + expectedReasonForSingleValueFlag), detail) + + p.expectSingleEvaluationEvent(t, evalFlagKey, ldvalue.CopyArbitraryValue(expectedValue), + ldvalue.CopyArbitraryValue(defaultVal), detail.Reason) + }) + }) } func TestJSONVariation(t *testing.T) { @@ -431,6 +574,34 @@ func TestJSONVariation(t *testing.T) { p.expectSingleEvaluationEvent(t, evalFlagKey, expected, defaultVal, detail.Reason) }) }) + + t.Run("simpleCtx", func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.setupSingleValueFlag(evalFlagKey, expected) + + actual, err := p.client.JSONVariationCtx(gocontext.TODO(), evalFlagKey, evalTestUser, defaultVal) + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + + p.expectSingleEvaluationEvent(t, evalFlagKey, expected, defaultVal, noReason) + }) + }) + + t.Run("detailCtx", func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.setupSingleValueFlag(evalFlagKey, expected) + + actual, detail, err := p.client.JSONVariationDetailCtx(gocontext.TODO(), evalFlagKey, evalTestUser, defaultVal) + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + assert.Equal(t, ldreason.NewEvaluationDetail(expected, expectedVariationForSingleValueFlag, + expectedReasonForSingleValueFlag), detail) + + p.expectSingleEvaluationEvent(t, evalFlagKey, expected, defaultVal, detail.Reason) + }) + }) } func TestEvaluatingUnknownFlagReturnsDefault(t *testing.T) { diff --git a/ldclient_events.go b/ldclient_events.go index 07fe1140..936268e5 100644 --- a/ldclient_events.go +++ b/ldclient_events.go @@ -1,6 +1,8 @@ package ldclient import ( + gocontext "context" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" "github.com/launchdarkly/go-sdk-common/v3/ldmigration" "github.com/launchdarkly/go-sdk-common/v3/ldreason" @@ -104,13 +106,38 @@ func (c *clientEventsDisabledDecorator) BoolVariation( context ldcontext.Context, defaultVal bool, ) (bool, error) { - detail, err := c.client.variation(key, context, ldvalue.Bool(defaultVal), true, c.scope) + detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Bool(defaultVal), + true, c.scope, boolVarFuncName) return detail.Value.BoolValue(), err } func (c *clientEventsDisabledDecorator) BoolVariationDetail(key string, context ldcontext.Context, defaultVal bool) ( bool, ldreason.EvaluationDetail, error) { - detail, err := c.client.variation(key, context, ldvalue.Bool(defaultVal), true, c.scope) + detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Bool(defaultVal), + true, c.scope, boolVarDetailFuncName) + return detail.Value.BoolValue(), detail, err +} + +func (c *clientEventsDisabledDecorator) BoolVariationCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal bool, +) (bool, error) { + detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Bool(defaultVal), + true, c.scope, boolVarExFuncName) + return detail.Value.BoolValue(), err +} + +func (c *clientEventsDisabledDecorator) BoolVariationDetailCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal bool, +) ( + bool, ldreason.EvaluationDetail, error) { + detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Bool(defaultVal), + true, c.scope, boolVarDetailExFuncName) return detail.Value.BoolValue(), detail, err } @@ -119,19 +146,45 @@ func (c *clientEventsDisabledDecorator) IntVariation( context ldcontext.Context, defaultVal int, ) (int, error) { - detail, err := c.client.variation(key, context, ldvalue.Int(defaultVal), true, c.scope) + detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Int(defaultVal), + true, c.scope, intVarFuncName) return detail.Value.IntValue(), err } func (c *clientEventsDisabledDecorator) IntVariationDetail(key string, context ldcontext.Context, defaultVal int) ( int, ldreason.EvaluationDetail, error) { - detail, err := c.client.variation(key, context, ldvalue.Int(defaultVal), true, c.scope) + detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Int(defaultVal), + true, c.scope, intVarDetailFuncName) + return detail.Value.IntValue(), detail, err +} + +func (c *clientEventsDisabledDecorator) IntVariationCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal int, +) (int, error) { + detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Int(defaultVal), + true, c.scope, intVarExFuncName) + return detail.Value.IntValue(), err +} + +func (c *clientEventsDisabledDecorator) IntVariationDetailCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal int, +) ( + int, ldreason.EvaluationDetail, error) { + detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Int(defaultVal), + true, c.scope, intVarDetailExFuncName) return detail.Value.IntValue(), detail, err } func (c *clientEventsDisabledDecorator) Float64Variation(key string, context ldcontext.Context, defaultVal float64) ( float64, error) { - detail, err := c.client.variation(key, context, ldvalue.Float64(defaultVal), true, c.scope) + detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Float64(defaultVal), + true, c.scope, floatVarFuncName) return detail.Value.Float64Value(), err } @@ -141,13 +194,39 @@ func (c *clientEventsDisabledDecorator) Float64VariationDetail( defaultVal float64, ) ( float64, ldreason.EvaluationDetail, error) { - detail, err := c.client.variation(key, context, ldvalue.Float64(defaultVal), true, c.scope) + detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Float64(defaultVal), + true, c.scope, floatVarDetailFuncName) + return detail.Value.Float64Value(), detail, err +} + +func (c *clientEventsDisabledDecorator) Float64VariationCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal float64, +) ( + float64, error) { + detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Float64(defaultVal), + true, c.scope, floatVarExFuncName) + return detail.Value.Float64Value(), err +} + +func (c *clientEventsDisabledDecorator) Float64VariationDetailCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal float64, +) ( + float64, ldreason.EvaluationDetail, error) { + detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Float64(defaultVal), + true, c.scope, floatVarDetailExFuncName) return detail.Value.Float64Value(), detail, err } func (c *clientEventsDisabledDecorator) StringVariation(key string, context ldcontext.Context, defaultVal string) ( string, error) { - detail, err := c.client.variation(key, context, ldvalue.String(defaultVal), true, c.scope) + detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.String(defaultVal), + true, c.scope, stringVarExFuncName) return detail.Value.StringValue(), err } @@ -157,14 +236,48 @@ func (c *clientEventsDisabledDecorator) StringVariationDetail( defaultVal string, ) ( string, ldreason.EvaluationDetail, error) { - detail, err := c.client.variation(key, context, ldvalue.String(defaultVal), true, c.scope) + detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.String(defaultVal), + true, c.scope, stringVarDetailFuncName) + return detail.Value.StringValue(), detail, err +} + +func (c *clientEventsDisabledDecorator) StringVariationCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal string, +) ( + string, error) { + detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.String(defaultVal), true, c.scope, + stringVarExFuncName) + return detail.Value.StringValue(), err +} + +func (c *clientEventsDisabledDecorator) StringVariationDetailCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultVal string, +) ( + string, ldreason.EvaluationDetail, error) { + detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.String(defaultVal), true, c.scope, + stringVarDetailExFuncName) return detail.Value.StringValue(), detail, err } func (c *clientEventsDisabledDecorator) MigrationVariation( key string, context ldcontext.Context, defaultStage ldmigration.Stage, ) (ldmigration.Stage, interfaces.LDMigrationOpTracker, error) { - return c.client.migrationVariation(key, context, defaultStage, c.scope) + return c.client.migrationVariation(gocontext.TODO(), key, context, defaultStage, c.scope, migrationVarFuncName) +} + +func (c *clientEventsDisabledDecorator) MigrationVariationCtx( + ctx gocontext.Context, + key string, + context ldcontext.Context, + defaultStage ldmigration.Stage, +) (ldmigration.Stage, interfaces.LDMigrationOpTracker, error) { + return c.client.migrationVariation(ctx, key, context, defaultStage, c.scope, migrationVarExFuncName) } func (c *clientEventsDisabledDecorator) JSONVariation(key string, context ldcontext.Context, defaultVal ldvalue.Value) ( diff --git a/ldclient_hooks_test.go b/ldclient_hooks_test.go new file mode 100644 index 00000000..092e6e54 --- /dev/null +++ b/ldclient_hooks_test.go @@ -0,0 +1,291 @@ +package ldclient + +import ( + gocontext "context" + "fmt" + "testing" + + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/launchdarkly/go-sdk-common/v3/ldmigration" + "github.com/launchdarkly/go-sdk-common/v3/ldreason" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" +) + +// The execution of hooks is mostly tested in hook_runner_test. The tests here are to test the usage of the hook runner +// by the client, but not the implementation of the hook runner itself. + +type parameterizedVariation struct { + variationCall func(client *LDClient) + methodName string + defaultValue ldvalue.Value + context gocontext.Context +} + +const hookTestFlag string = "test-flag" + +func TestHooksAreExecutedForAllVariationMethods(t *testing.T) { + testContext := ldcontext.New("test-context") + testGoContext := gocontext.WithValue(gocontext.TODO(), "test-key", "test-value") + falseValue := ldvalue.Bool(false) + fortyTwoValue := ldvalue.Int(42) + piValue := ldvalue.Float64(3.14) + stringValue := ldvalue.String("test-string") + + beforeCapture := func(testCase parameterizedVariation) sharedtest.HookExpectedCall { + return sharedtest.HookExpectedCall{ + HookStage: sharedtest.HookStageBeforeEvaluation, + EvalCapture: sharedtest.HookEvalCapture{ + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext("test-flag", testContext, + testCase.defaultValue, testCase.methodName), + EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), + GoContext: testCase.context, + }} + } + + afterCapture := func(testCase parameterizedVariation) sharedtest.HookExpectedCall { + return sharedtest.HookExpectedCall{ + HookStage: sharedtest.HookStageAfterEvaluation, + EvalCapture: sharedtest.HookEvalCapture{ + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext(hookTestFlag, testContext, + testCase.defaultValue, testCase.methodName), + EvaluationSeriesData: ldhooks.NewEvaluationSeriesBuilder(ldhooks.EmptyEvaluationSeriesData()). + Set("test-key", "test-value").Build(), + Detail: ldreason.NewEvaluationDetailForError( + ldreason.EvalErrorClientNotReady, + ldvalue.CopyArbitraryValue(testCase.defaultValue), + ), + GoContext: testCase.context, + }} + } + + testCases := []parameterizedVariation{ + // Bool variations + { + variationCall: func(client *LDClient) { + _, _ = client.BoolVariation(hookTestFlag, testContext, false) + }, + methodName: "LDClient.BoolVariation", + defaultValue: falseValue, + context: gocontext.TODO(), + }, + { + variationCall: func(client *LDClient) { + _, _ = client.BoolVariationCtx(testGoContext, hookTestFlag, testContext, false) + }, + methodName: "LDClient.BoolVariationCtx", + defaultValue: falseValue, + context: testGoContext, + }, + { + variationCall: func(client *LDClient) { + _, _, _ = client.BoolVariationDetail(hookTestFlag, testContext, false) + }, + methodName: "LDClient.BoolVariationDetail", + defaultValue: falseValue, + context: gocontext.TODO(), + }, + { + variationCall: func(client *LDClient) { + _, _, _ = client.BoolVariationDetailCtx(testGoContext, hookTestFlag, testContext, false) + }, + methodName: "LDClient.BoolVariationDetailCtx", + defaultValue: falseValue, + context: testGoContext, + }, + // Int variations + { + variationCall: func(client *LDClient) { + _, _ = client.IntVariation(hookTestFlag, testContext, 42) + }, + methodName: "LDClient.IntVariation", + defaultValue: fortyTwoValue, + context: gocontext.TODO(), + }, + { + variationCall: func(client *LDClient) { + _, _ = client.IntVariationCtx(testGoContext, hookTestFlag, testContext, 42) + }, + methodName: "LDClient.IntVariationCtx", + defaultValue: fortyTwoValue, + context: testGoContext, + }, + { + variationCall: func(client *LDClient) { + _, _, _ = client.IntVariationDetail(hookTestFlag, testContext, 42) + }, + methodName: "LDClient.IntVariationDetail", + defaultValue: fortyTwoValue, + context: gocontext.TODO(), + }, + { + variationCall: func(client *LDClient) { + _, _, _ = client.IntVariationDetailCtx(testGoContext, hookTestFlag, testContext, 42) + }, + methodName: "LDClient.IntVariationDetailCtx", + defaultValue: fortyTwoValue, + context: testGoContext, + }, + // Float variations + { + variationCall: func(client *LDClient) { + _, _ = client.Float64Variation(hookTestFlag, testContext, 3.14) + }, + methodName: "LDClient.Float64Variation", + defaultValue: piValue, + context: gocontext.TODO(), + }, + { + variationCall: func(client *LDClient) { + _, _ = client.Float64VariationCtx(testGoContext, hookTestFlag, testContext, 3.14) + }, + methodName: "LDClient.Float64VariationCtx", + defaultValue: piValue, + context: testGoContext, + }, + { + variationCall: func(client *LDClient) { + _, _, _ = client.Float64VariationDetail(hookTestFlag, testContext, 3.14) + }, + methodName: "LDClient.Float64VariationDetail", + defaultValue: piValue, + context: gocontext.TODO(), + }, + { + variationCall: func(client *LDClient) { + _, _, _ = client.Float64VariationDetailCtx(testGoContext, hookTestFlag, testContext, 3.14) + }, + methodName: "LDClient.Float64VariationDetailCtx", + defaultValue: piValue, + context: testGoContext, + }, + // String variations + { + variationCall: func(client *LDClient) { + _, _ = client.StringVariation(hookTestFlag, testContext, "test-string") + }, + methodName: "LDClient.StringVariation", + defaultValue: stringValue, + context: gocontext.TODO(), + }, + { + variationCall: func(client *LDClient) { + _, _ = client.StringVariationCtx(testGoContext, hookTestFlag, testContext, "test-string") + }, + methodName: "LDClient.StringVariationCtx", + defaultValue: stringValue, + context: testGoContext, + }, + { + variationCall: func(client *LDClient) { + _, _, _ = client.StringVariationDetail(hookTestFlag, testContext, "test-string") + }, + methodName: "LDClient.StringVariationDetail", + defaultValue: stringValue, + context: gocontext.TODO(), + }, + { + variationCall: func(client *LDClient) { + _, _, _ = client.StringVariationDetailCtx( + testGoContext, hookTestFlag, testContext, "test-string") + }, + methodName: "LDClient.StringVariationDetailCtx", + defaultValue: stringValue, + context: testGoContext, + }, + // JSON variations + { + variationCall: func(client *LDClient) { + _, _ = client.JSONVariation(hookTestFlag, testContext, ldvalue.String("test-string")) + }, + methodName: "LDClient.JSONVariation", + defaultValue: stringValue, + context: gocontext.TODO(), + }, + { + variationCall: func(client *LDClient) { + _, _ = client.JSONVariationCtx( + testGoContext, hookTestFlag, testContext, ldvalue.String("test-string")) + }, + methodName: "LDClient.JSONVariationCtx", + defaultValue: stringValue, + context: testGoContext, + }, + { + variationCall: func(client *LDClient) { + _, _, _ = client.JSONVariationDetail(hookTestFlag, testContext, ldvalue.String("test-string")) + }, + methodName: "LDClient.JSONVariationDetail", + defaultValue: stringValue, + context: gocontext.TODO(), + }, + { + variationCall: func(client *LDClient) { + _, _, _ = client.JSONVariationDetailCtx( + testGoContext, hookTestFlag, testContext, ldvalue.String("test-string")) + }, + methodName: "LDClient.JSONVariationDetailCtx", + defaultValue: stringValue, + context: testGoContext, + }, + // Migration variation + { + variationCall: func(client *LDClient) { + _, _, _ = client.MigrationVariation(hookTestFlag, testContext, ldmigration.Off) + }, + methodName: "LDClient.MigrationVariation", + defaultValue: ldvalue.String(string(ldmigration.Off)), + context: gocontext.TODO(), + }, + { + variationCall: func(client *LDClient) { + _, _, _ = client.MigrationVariationCtx(testGoContext, hookTestFlag, testContext, ldmigration.Off) + }, + methodName: "LDClient.MigrationVariationCtx", + defaultValue: ldvalue.String(string(ldmigration.Off)), + context: testGoContext, + }, + } + + for _, testCase := range testCases { + t.Run(fmt.Sprintf("for method %v", testCase.methodName), func(t *testing.T) { + hook := sharedtest.NewTestHook("test-hook") + hook.BeforeInject = func( + ctx gocontext.Context, + context ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + ) (ldhooks.EvaluationSeriesData, error) { + return ldhooks.NewEvaluationSeriesBuilder(data).Set("test-key", "test-value").Build(), nil + } + client, _ := MakeCustomClient("", Config{Offline: true, Hooks: []ldhooks.Hook{hook}}, 0) + testCase.variationCall(client) + hook.Verify( + t, + beforeCapture(testCase), + afterCapture(testCase), + ) + }) + } +} + +func TestUsesAStableSetOfHooksDuringEvaluation(t *testing.T) { + client, _ := MakeCustomClient("", Config{Offline: true, Hooks: []ldhooks.Hook{}}, 0) + + hook := sharedtest.NewTestHook("test-hook") + sneaky := sharedtest.NewTestHook("sneaky") + hook.BeforeInject = func( + ctx gocontext.Context, + context ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + ) (ldhooks.EvaluationSeriesData, error) { + client.AddHooks(sneaky) + return ldhooks.NewEvaluationSeriesBuilder(data).Set("test-key", "test-value").Build(), nil + } + + client.AddHooks(hook) + + _, _ = client.BoolVariation("flag-key", ldcontext.New("test-context"), false) + + sneaky.VerifyNoCalls(t) +} diff --git a/ldclient_migration_test.go b/ldclient_migration_test.go index 258b0db8..8f20a87e 100644 --- a/ldclient_migration_test.go +++ b/ldclient_migration_test.go @@ -1,61 +1,97 @@ package ldclient import ( + gocontext "context" "testing" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" "github.com/launchdarkly/go-sdk-common/v3/ldmigration" "github.com/launchdarkly/go-sdk-common/v3/lduser" "github.com/launchdarkly/go-sdk-common/v3/ldvalue" "github.com/launchdarkly/go-server-sdk-evaluation/v3/ldbuilders" + "github.com/launchdarkly/go-server-sdk/v7/interfaces" "github.com/stretchr/testify/assert" ) var migrationTestUser = lduser.NewUser("userkey") -func TestDefaultIsReturnedIfFlagEvaluatesToNonStringType(t *testing.T) { - flag := ldbuilders.NewFlagBuilder("migration-key").Build() // flag is off and we haven't defined an off variation +type MigrationVariationMethod = func( + client *LDClient, + key string, + context ldcontext.Context, + stage ldmigration.Stage, +) (ldmigration.Stage, interfaces.LDMigrationOpTracker, error) + +func TestMigrationVariation(t *testing.T) { + + t.Run("with MigrationVariation", func(t *testing.T) { + runMigrationTests(t, func(client *LDClient, + key string, + context ldcontext.Context, + stage ldmigration.Stage, + ) (ldmigration.Stage, interfaces.LDMigrationOpTracker, error) { + return client.MigrationVariation(key, context, stage) + }) + }) + + t.Run("with MigrationVariationCtx", func(t *testing.T) { + runMigrationTests(t, func(client *LDClient, + key string, + context ldcontext.Context, + stage ldmigration.Stage, + ) (ldmigration.Stage, interfaces.LDMigrationOpTracker, error) { + return client.MigrationVariationCtx(gocontext.TODO(), key, context, stage) + }) + }) + +} - withClientEvalTestParams(func(p clientEvalTestParams) { - p.data.UsePreconfiguredFlag(flag) +func runMigrationTests(t *testing.T, method MigrationVariationMethod) { + t.Run("default is returned if flag evaluates to non string type", func(t *testing.T) { + flag := ldbuilders.NewFlagBuilder("migration-key").Build() // flag is off and we haven't defined an off variation - stage, _, err := p.client.MigrationVariation("migration-key", migrationTestUser, ldmigration.Live) + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(flag) - assert.NoError(t, err) - assert.EqualValues(t, ldmigration.Live, stage) + stage, _, err := method(p.client, "migration-key", migrationTestUser, ldmigration.Live) + + assert.NoError(t, err) + assert.EqualValues(t, ldmigration.Live, stage) + }) }) -} -func TestDefaultIsReturnedIfMigrationFlagDoesNotExist(t *testing.T) { - withClientEvalTestParams(func(p clientEvalTestParams) { - stage, _, err := p.client.MigrationVariation("migration-key", migrationTestUser, ldmigration.Live) + t.Run("default is returned if migration flag does not exist", func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + stage, _, err := method(p.client, "migration-key", migrationTestUser, ldmigration.Live) - assert.NoError(t, err) - assert.EqualValues(t, ldmigration.Live, stage) + assert.NoError(t, err) + assert.EqualValues(t, ldmigration.Live, stage) + }) }) -} -func TestDefaultIsReturnedFlagEvaluatesToInvalidStageValue(t *testing.T) { - flag := ldbuilders.NewFlagBuilder("migration-key").Variations(ldvalue.String("invalid-stage")).OffVariation(0).On(false).Build() + t.Run("default is returned if flag evaluates to an invalid stage", func(t *testing.T) { + flag := ldbuilders.NewFlagBuilder("migration-key").Variations(ldvalue.String("invalid-stage")).OffVariation(0).On(false).Build() - withClientEvalTestParams(func(p clientEvalTestParams) { - p.data.UsePreconfiguredFlag(flag) + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(flag) - stage, _, err := p.client.MigrationVariation("migration-key", migrationTestUser, ldmigration.Live) + stage, _, err := method(p.client, "migration-key", migrationTestUser, ldmigration.Live) - assert.Error(t, err) - assert.EqualValues(t, ldmigration.Live, stage) + assert.Error(t, err) + assert.EqualValues(t, ldmigration.Live, stage) + }) }) -} -func TestCorrectStageCanBeDeterminedFromFlag(t *testing.T) { - flag := ldbuilders.NewFlagBuilder("migration-key").Variations(ldvalue.String("off"), ldvalue.String("dualwrite")).OffVariation(0).On(true).FallthroughVariation(1).Build() + t.Run("correct stage can be returned from flag", func(t *testing.T) { + flag := ldbuilders.NewFlagBuilder("migration-key").Variations(ldvalue.String("off"), ldvalue.String("dualwrite")).OffVariation(0).On(true).FallthroughVariation(1).Build() - withClientEvalTestParams(func(p clientEvalTestParams) { - p.data.UsePreconfiguredFlag(flag) + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(flag) - stage, _, err := p.client.MigrationVariation("migration-key", migrationTestUser, ldmigration.Live) + stage, _, err := method(p.client, "migration-key", migrationTestUser, ldmigration.Live) - assert.NoError(t, err) - assert.EqualValues(t, ldmigration.DualWrite, stage) + assert.NoError(t, err) + assert.EqualValues(t, ldmigration.DualWrite, stage) + }) }) } From 1c56855e978f21023a2459a2cce5c71d331d7643 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 28 Mar 2024 15:58:51 -0700 Subject: [PATCH 03/31] feat: Implement otel tracing hook. --- .gitignore | 2 + Makefile | 10 ++- ldotel/go.mod | 21 +++++ ldotel/go.sum | 34 +++++++ ldotel/package_info.go | 13 +++ ldotel/tracing_hook.go | 109 +++++++++++++++++++++++ ldotel/tracing_hook_test.go | 171 ++++++++++++++++++++++++++++++++++++ release-please-config.json | 10 +++ 8 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 ldotel/go.mod create mode 100644 ldotel/go.sum create mode 100644 ldotel/package_info.go create mode 100644 ldotel/tracing_hook.go create mode 100644 ldotel/tracing_hook_test.go diff --git a/.gitignore b/.gitignore index 95ea7aed..d23ddea7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ go-server-sdk.test allocations.out .idea .vscode +go.work +go.work.sum diff --git a/Makefile b/Makefile index c53b0b53..f1ce24aa 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,7 @@ COVERAGE_ENFORCER_FLAGS=-package github.com/launchdarkly/go-server-sdk/v7 \ build: go build ./... + go build ./ldotel clean: go clean @@ -35,6 +36,7 @@ test: @# build tags to isolate these tests from the main test run so that if you do "go test ./..." you won't @# get unexpected errors. for tag in proxytest1 proxytest2; do go test -race -v -tags=$$tag ./proxytest; done + go test ./ldotel test-coverage: $(COVERAGE_PROFILE_RAW) go run github.com/launchdarkly-labs/go-coverage-enforcer@latest $(COVERAGE_ENFORCER_FLAGS) -outprofile $(COVERAGE_PROFILE_FILTERED) $(COVERAGE_PROFILE_RAW) @@ -79,8 +81,14 @@ TEMP_TEST_OUTPUT=/tmp/sdk-contract-test-service.log # TEST_HARNESS_PARAMS can be set to add -skip parameters for any contract tests that cannot yet pass TEST_HARNESS_PARAMS= +workspace: + rm -f go.work + go work init ./ + go work use ./ldotel + go work use ./testservice + build-contract-tests: - @cd testservice && go mod tidy && go build + @go build -o ./testservice/testservice ./testservice start-contract-test-service: build-contract-tests @./testservice/testservice diff --git a/ldotel/go.mod b/ldotel/go.mod new file mode 100644 index 00000000..f45bada3 --- /dev/null +++ b/ldotel/go.mod @@ -0,0 +1,21 @@ +module ldotel + +go 1.21 + +require ( + github.com/launchdarkly/go-sdk-common/v3 v3.1.0 + github.com/launchdarkly/go-server-sdk/v7 v7.2.0 + go.opentelemetry.io/otel/sdk v1.24.0 + go.opentelemetry.io/otel/trace v1.22.0 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/launchdarkly/go-jsonstream/v3 v3.0.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + go.opentelemetry.io/otel/metric v1.22.0 // indirect + golang.org/x/exp v0.0.0-20220823124025-807a23277127 // indirect +) diff --git a/ldotel/go.sum b/ldotel/go.sum new file mode 100644 index 00000000..fce8c56a --- /dev/null +++ b/ldotel/go.sum @@ -0,0 +1,34 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/launchdarkly/go-jsonstream/v3 v3.0.0 h1:qJF/WI09EUJ7kSpmP5d1Rhc81NQdYUhP17McKfUq17E= +github.com/launchdarkly/go-jsonstream/v3 v3.0.0/go.mod h1:/1Gyml6fnD309JOvunOSfyysWbZ/ZzcA120gF/cQtC4= +github.com/launchdarkly/go-sdk-common/v3 v3.1.0 h1:KNCP5rfkOt/25oxGLAVgaU1BgrZnzH9Y/3Z6I8bMwDg= +github.com/launchdarkly/go-sdk-common/v3 v3.1.0/go.mod h1:mXFmDGEh4ydK3QilRhrAyKuf9v44VZQWnINyhqbbOd0= +github.com/launchdarkly/go-server-sdk/v7 v7.2.0/go.mod h1:EY2ag+p9HnNXiG4pJ+y7QG2gqCYEoYD+NJgwkhmUUqk= +github.com/launchdarkly/go-test-helpers/v3 v3.0.2 h1:rh0085g1rVJM5qIukdaQ8z1XTWZztbJ49vRZuveqiuU= +github.com/launchdarkly/go-test-helpers/v3 v3.0.2/go.mod h1:u2ZvJlc/DDJTFrshWW50tWMZHLVYXofuSHUfTU/eIwM= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.opentelemetry.io/otel v1.22.0 h1:xS7Ku+7yTFvDfDraDIJVpw7XPyuHlB9MCiqqX5mcJ6Y= +go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI= +go.opentelemetry.io/otel/metric v1.22.0 h1:lypMQnGyJYeuYPhOM/bgjbFM6WE44W1/T45er4d8Hhg= +go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY= +go.opentelemetry.io/otel/trace v1.22.0 h1:Hg6pPujv0XG9QaVbGOBVHunyuLcCC3jN7WEhPx83XD0= +go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo= +golang.org/x/exp v0.0.0-20220823124025-807a23277127 h1:S4NrSKDfihhl3+4jSTgwoIevKxX9p7Iv9x++OEIptDo= +golang.org/x/exp v0.0.0-20220823124025-807a23277127/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ldotel/package_info.go b/ldotel/package_info.go new file mode 100644 index 00000000..0f732055 --- /dev/null +++ b/ldotel/package_info.go @@ -0,0 +1,13 @@ +// Package ldotel contains OpenTelemetry specific implementations of hooks. +// +// For instance, to use LaunchDarkly with OpenTelemetry tracing, one would use the TracingHook: +// +// client, _ = ld.MakeCustomClient("sdk-47698c22-f258-4cd1-8e66-f2bd9bd1fc2a", +// +// ld.Config{ +// Hooks: []ldhooks.Hook{ldotel.NewTracingHook()}, +// }, 5*time.Second) +package ldotel + +// Version is the current version string of the ldotel package. This is updated by our release scripts. +const Version = "0.0.1" // {{ x-release-please-version }} diff --git a/ldotel/tracing_hook.go b/ldotel/tracing_hook.go new file mode 100644 index 00000000..12d39039 --- /dev/null +++ b/ldotel/tracing_hook.go @@ -0,0 +1,109 @@ +package ldotel + +import ( + "context" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + semconv "go.opentelemetry.io/otel/semconv/v1.18.0" + "go.opentelemetry.io/otel/trace" + + "github.com/launchdarkly/go-sdk-common/v3/ldreason" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" +) + +const eventName = "feature_flag" +const contextKeyAttributeName = "feature_flag.context.key" + +// TracingHookOption is used to implement functional options for the TracingHook. +type TracingHookOption func(hook *TracingHook) + +// WithSpans option enables generation of child spans for each variation call. +func WithSpans() TracingHookOption { + return func(h *TracingHook) { + h.spans = true + } +} + +// WithVariant option enables putting a stringified version of the flag value in the feature_flag span event. +func WithVariant() TracingHookOption { + return func(h *TracingHook) { + h.includeVariant = true + } +} + +// A TracingHook adds OpenTelemetry support to the LaunchDarkly SDK. +// +// By default, span events will be added for each call to a "Variation" method. Variation methods without "Ctx" will not +// be able to access a parent span, so no span events can be attached. If WithSpans is used, then root spans will be +// created from the non-"Ctx" methods. +// +// The span event will include the FullyQualifiedKey of the ldcontext, the provider of the evaluation (LaunchDarkly), +// and the key of the flag being evaluated. +type TracingHook struct { + ldhooks.Unimplemented + metadata ldhooks.Metadata + spans bool + includeVariant bool + tracer trace.Tracer +} + +// GetMetadata returns meta-data about the tracing hook. +func (h TracingHook) Metadata() ldhooks.Metadata { + return h.metadata +} + +// NewTracingHook creates a new TracingHook instance. The TracingHook can be provided to the LaunchDarkly client +// in order to add OpenTelemetry support. +func NewTracingHook(opts ...TracingHookOption) TracingHook { + hook := TracingHook{ + metadata: ldhooks.NewMetadata("LaunchDarkly Tracing Hook"), + tracer: otel.Tracer("launchdarkly-client"), + } + for _, opt := range opts { + opt(&hook) + } + return hook +} + +// BeforeEvaluation implements the BeforeEvaluation evaluation stage. +func (h TracingHook) BeforeEvaluation(ctx context.Context, seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData) (ldhooks.EvaluationSeriesData, error) { + if h.spans { + _, span := h.tracer.Start(ctx, seriesContext.Method()) + + span.SetAttributes(semconv.FeatureFlagKey(seriesContext.FlagKey()), + attribute.String(contextKeyAttributeName, seriesContext.Context().FullyQualifiedKey())) + + return ldhooks.NewEvaluationSeriesBuilder(data).Set("variationSpan", span).Build(), nil + } + return data, nil +} + +// AfterEvaluation implements the AfterEvaluation evaluation stage. +func (h TracingHook) AfterEvaluation(ctx context.Context, seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, detail ldreason.EvaluationDetail) (ldhooks.EvaluationSeriesData, error) { + variationSpan, present := data.Get("variationSpan") + if present { + asSpan, ok := variationSpan.(trace.Span) + if ok { + asSpan.End() + } + } + + attribs := []attribute.KeyValue{ + semconv.FeatureFlagKey(seriesContext.FlagKey()), + semconv.FeatureFlagProviderName("LaunchDarkly"), + attribute.String(contextKeyAttributeName, seriesContext.Context().FullyQualifiedKey()), + } + if h.includeVariant { + attribs = append(attribs, semconv.FeatureFlagVariant(detail.Value.JSONString())) + } + + span := trace.SpanFromContext(ctx) + span.AddEvent(eventName, trace.WithAttributes(attribs...)) + return data, nil +} + +// Ensure that TracingHook conforms to the ldhooks.Hook interface. +var _ ldhooks.Hook = TracingHook{} diff --git a/ldotel/tracing_hook_test.go b/ldotel/tracing_hook_test.go new file mode 100644 index 00000000..13ab2aef --- /dev/null +++ b/ldotel/tracing_hook_test.go @@ -0,0 +1,171 @@ +package ldotel + +import ( + gocontext "context" + "testing" + + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + ldclient "github.com/launchdarkly/go-server-sdk/v7" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/trace" +) +import "go.opentelemetry.io/otel/sdk/trace/tracetest" + +func configureMemoryExporter() *tracetest.InMemoryExporter { + exporter := tracetest.NewInMemoryExporter() + sp := trace.NewSimpleSpanProcessor(exporter) + provider := trace.NewTracerProvider( + trace.WithSpanProcessor(sp), + ) + otel.SetTracerProvider(provider) + return exporter +} + +func createClientWithTracing(options ...TracingHookOption) *ldclient.LDClient { + client, _ := ldclient.MakeCustomClient("", ldclient.Config{ + Offline: true, + Hooks: []ldhooks.Hook{NewTracingHook(options...)}, + }, 0) + return client +} + +const flagKey = "test-flag" +const spanName = "test-span" + +func TestBasicSpanEventsEvents(t *testing.T) { + exporter := configureMemoryExporter() + tracer := otel.Tracer("launchdarkly-client") + client := createClientWithTracing() + context := ldcontext.New("test-context") + + ctx := gocontext.Background() + + ctx, span := tracer.Start(ctx, spanName) + + _, _ = client.BoolVariationCtx(ctx, flagKey, context, false) + + span.End() + + exportedSpans := exporter.GetSpans().Snapshots() + assert.Len(t, exportedSpans, 1) + events := exportedSpans[0].Events() + assert.Len(t, events, 1) + flagEvent := events[0] + assert.Equal(t, "feature_flag", flagEvent.Name) + + attributes := attribute.NewSet(flagEvent.Attributes...) + attributeFlagKey, _ := (&attributes).Value("feature_flag.key") + assert.Equal(t, flagKey, attributeFlagKey.AsString()) + attributeProviderName, _ := (&attributes).Value("feature_flag.provider_name") + assert.Equal(t, "LaunchDarkly", attributeProviderName.AsString()) + attributeContextKey, _ := (&attributes).Value("feature_flag.context.key") + assert.Equal(t, context.FullyQualifiedKey(), attributeContextKey.AsString()) +} + +func TestSpanEventsWithVariant(t *testing.T) { + exporter := configureMemoryExporter() + tracer := otel.Tracer("launchdarkly-client") + client := createClientWithTracing(WithVariant()) + context := ldcontext.New("test-context") + + ctx := gocontext.Background() + + ctx, span := tracer.Start(ctx, spanName) + + _, _ = client.BoolVariationCtx(ctx, flagKey, context, false) + + span.End() + + exportedSpans := exporter.GetSpans().Snapshots() + events := exportedSpans[0].Events() + flagEvent := events[0] + + attributes := attribute.NewSet(flagEvent.Attributes...) + attributeVariant, _ := (&attributes).Value("feature_flag.variant") + assert.Equal(t, "false", attributeVariant.AsString()) +} + +func TestMultipleSpanEvents(t *testing.T) { + exporter := configureMemoryExporter() + tracer := otel.Tracer("launchdarkly-client") + client := createClientWithTracing() + context := ldcontext.New("test-context") + + ctx := gocontext.Background() + + ctx, span := tracer.Start(ctx, spanName) + + _, _ = client.BoolVariationCtx(ctx, flagKey, context, false) + _, _ = client.StringVariationCtx(ctx, flagKey, context, "default") + + span.End() + + exportedSpans := exporter.GetSpans().Snapshots() + assert.Len(t, exportedSpans, 1) + events := exportedSpans[0].Events() + assert.Len(t, events, 2) + flagEventBool := events[0] + assert.Equal(t, "feature_flag", flagEventBool.Name) + + boolFlagEventAttributes := attribute.NewSet(flagEventBool.Attributes...) + boolAttributeFlagKey, _ := (&boolFlagEventAttributes).Value("feature_flag.key") + assert.Equal(t, flagKey, boolAttributeFlagKey.AsString()) + boolAttributeProviderName, _ := (&boolFlagEventAttributes).Value("feature_flag.provider_name") + assert.Equal(t, "LaunchDarkly", boolAttributeProviderName.AsString()) + boolAttributeContextKey, _ := (&boolFlagEventAttributes).Value("feature_flag.context.key") + assert.Equal(t, context.FullyQualifiedKey(), boolAttributeContextKey.AsString()) + + flagEventString := events[1] + assert.Equal(t, "feature_flag", flagEventString.Name) + + stringFlagEventAttributes := attribute.NewSet(flagEventString.Attributes...) + stringAttributeFlagKey, _ := (&stringFlagEventAttributes).Value("feature_flag.key") + assert.Equal(t, flagKey, stringAttributeFlagKey.AsString()) + stringAttributeProviderName, _ := (&boolFlagEventAttributes).Value("feature_flag.provider_name") + assert.Equal(t, "LaunchDarkly", stringAttributeProviderName.AsString()) + stringAttributeContextKey, _ := (&stringFlagEventAttributes).Value("feature_flag.context.key") + assert.Equal(t, context.FullyQualifiedKey(), stringAttributeContextKey.AsString()) +} + +func TestSpanCreationWithParent(t *testing.T) { + exporter := configureMemoryExporter() + tracer := otel.Tracer("launchdarkly-client") + client := createClientWithTracing(WithSpans()) + context := ldcontext.New("test-context") + + ctx := gocontext.Background() + + ctx, span := tracer.Start(ctx, spanName) + + _, _ = client.BoolVariationCtx(ctx, flagKey, context, false) + + span.End() + + exportedSpans := exporter.GetSpans().Snapshots() + assert.Len(t, exportedSpans, 2) + + exportedSpan := exportedSpans[0] + assert.Equal(t, "LDClient.BoolVariationCtx", exportedSpan.Name()) + + attributes := attribute.NewSet(exportedSpan.Attributes()...) + attributeFlagKey, _ := (&attributes).Value("feature_flag.key") + assert.Equal(t, flagKey, attributeFlagKey.AsString()) + attributeContextKey, _ := (&attributes).Value("feature_flag.context.key") + assert.Equal(t, context.FullyQualifiedKey(), attributeContextKey.AsString()) +} + +func TestSpanCreationWithoutParent(t *testing.T) { + exporter := configureMemoryExporter() + client := createClientWithTracing(WithSpans()) + context := ldcontext.New("test-context") + + _, _ = client.BoolVariation(flagKey, context, false) + + exportedSpans := exporter.GetSpans().Snapshots() + assert.Len(t, exportedSpans, 1) + exportedSpan := exportedSpans[0] + assert.Equal(t, "LDClient.BoolVariation", exportedSpan.Name()) +} diff --git a/release-please-config.json b/release-please-config.json index 0bfafea5..8eca0426 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -9,6 +9,16 @@ "extra-files" : [ "internal/version.go" ] + }, + "ldotel" : { + "release-type" : "go", + "bump-minor-pre-major" : true, + "tag-separator": "/", + "versioning" : "default", + "include-component-in-tag" : true, + "extra-files" : [ + "internal/version.go" + ] } } } From b7357446c0babaaeafdb882a5bc45cae004c8aee Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 29 Mar 2024 10:53:30 -0700 Subject: [PATCH 04/31] chore(refactor): Remove AddHooks, refactor execution, add contract tests. (#131) This is a "chore" because I plan on rebase merging this feature branch and this is not a release-note change. --- internal/hooks/evaluation_execution.go | 68 ++++ internal/hooks/evaluation_execution_test.go | 305 ++++++++++++++++ internal/hooks/runner.go | 131 ++----- internal/hooks/runner_test.go | 381 +++++--------------- ldclient.go | 135 +++---- ldclient_events.go | 52 ++- ldclient_hooks_test.go | 21 -- testservice/go.sum | 3 + testservice/sdk_client_entity.go | 10 + testservice/service.go | 1 + testservice/servicedef/command_params.go | 21 ++ testservice/servicedef/sdk_config.go | 13 + testservice/servicedef/service_params.go | 1 + testservice/test_hook.go | 107 ++++++ 14 files changed, 747 insertions(+), 502 deletions(-) create mode 100644 internal/hooks/evaluation_execution.go create mode 100644 internal/hooks/evaluation_execution_test.go create mode 100644 testservice/test_hook.go diff --git a/internal/hooks/evaluation_execution.go b/internal/hooks/evaluation_execution.go new file mode 100644 index 00000000..2046a0da --- /dev/null +++ b/internal/hooks/evaluation_execution.go @@ -0,0 +1,68 @@ +package hooks + +import ( + gocontext "context" + + "github.com/launchdarkly/go-sdk-common/v3/ldlog" + "github.com/launchdarkly/go-sdk-common/v3/ldreason" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" +) + +// EvaluationExecution represents the state of a running series of evaluation stages. +type EvaluationExecution struct { + hooks []ldhooks.Hook + data []ldhooks.EvaluationSeriesData + context ldhooks.EvaluationSeriesContext + loggers *ldlog.Loggers +} + +// BeforeEvaluation executes the BeforeEvaluation stage of registered hooks. +func (e *EvaluationExecution) BeforeEvaluation(ctx gocontext.Context) { + e.executeStage( + false, + "BeforeEvaluation", + func(hook ldhooks.Hook, data ldhooks.EvaluationSeriesData) (ldhooks.EvaluationSeriesData, error) { + return hook.BeforeEvaluation(ctx, e.context, data) + }) +} + +// AfterEvaluation executes the AfterEvaluation stage of registered hooks. +func (e *EvaluationExecution) AfterEvaluation( + ctx gocontext.Context, + detail ldreason.EvaluationDetail, +) { + e.executeStage( + true, + "AfterEvaluation", + func(hook ldhooks.Hook, data ldhooks.EvaluationSeriesData) (ldhooks.EvaluationSeriesData, error) { + return hook.AfterEvaluation(ctx, e.context, data, detail) + }) +} + +func (e *EvaluationExecution) executeStage( + reverse bool, + stageName string, + fn func( + hook ldhooks.Hook, + data ldhooks.EvaluationSeriesData, + ) (ldhooks.EvaluationSeriesData, error)) { + returnData := make([]ldhooks.EvaluationSeriesData, len(e.hooks)) + iterator := newIterator(reverse, e.hooks) + for iterator.hasNext() { + i, hook := iterator.getNext() + + outData, err := fn(hook, e.data[i]) + if err != nil { + returnData[i] = e.data[i] + e.loggers.Errorf( + "During evaluation of flag \"%s\", an error was encountered in \"%s\" of the \"%s\" hook: %s", + e.context.FlagKey(), + stageName, + hook.Metadata().Name(), + err.Error()) + continue + } + returnData[i] = outData + } + e.data = returnData +} diff --git a/internal/hooks/evaluation_execution_test.go b/internal/hooks/evaluation_execution_test.go new file mode 100644 index 00000000..0c186f28 --- /dev/null +++ b/internal/hooks/evaluation_execution_test.go @@ -0,0 +1,305 @@ +package hooks + +import ( + "context" + "errors" + "testing" + + "github.com/launchdarkly/go-sdk-common/v3/ldlog" + "github.com/launchdarkly/go-sdk-common/v3/ldlogtest" + "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest" + + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/launchdarkly/go-sdk-common/v3/ldreason" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" + "github.com/stretchr/testify/assert" +) + +func emptyExecutionAssertions(t *testing.T, res *EvaluationExecution, ldContext ldcontext.Context) { + assert.Empty(t, res.hooks) + assert.Empty(t, res.data) + assert.Equal(t, ldContext, res.context.Context()) + assert.Equal(t, "test-flag", res.context.FlagKey()) + assert.Equal(t, "testMethod", res.context.Method()) + assert.Equal(t, ldvalue.Bool(false), res.context.DefaultValue()) +} + +type orderTracker struct { + orderBefore []string + orderAfter []string +} + +func newOrderTracker() *orderTracker { + return &orderTracker{ + orderBefore: make([]string, 0), + orderAfter: make([]string, 0), + } +} + +func TestEvaluationExecution(t *testing.T) { + falseValue := ldvalue.Bool(false) + ldContext := ldcontext.New("test-context") + + t.Run("with no hooks", func(t *testing.T) { + runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{}) + + t.Run("run before evaluation", func(t *testing.T) { + execution := runner.prepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + execution.BeforeEvaluation(context.Background()) + emptyExecutionAssertions(t, execution, ldContext) + }) + + t.Run("run after evaluation", func(t *testing.T) { + execution := runner.prepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + execution.AfterEvaluation(context.Background(), + ldreason.NewEvaluationDetail(falseValue, 0, + ldreason.NewEvalReasonFallthrough())) + emptyExecutionAssertions(t, execution, ldContext) + }) + }) + + t.Run("with hooks", func(t *testing.T) { + t.Run("prepare evaluation series", func(t *testing.T) { + hookA := sharedtest.NewTestHook("a") + hookB := sharedtest.NewTestHook("b") + runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA, hookB}) + + ldContext := ldcontext.New("test-context") + res := runner.prepareEvaluationSeries("test-flag", ldContext, falseValue, "testMethod") + + assert.Len(t, res.hooks, 2) + assert.Len(t, res.data, 2) + assert.Equal(t, ldContext, res.context.Context()) + assert.Equal(t, "test-flag", res.context.FlagKey()) + assert.Equal(t, "testMethod", res.context.Method()) + assert.Equal(t, falseValue, res.context.DefaultValue()) + assert.Equal(t, res.data[0], ldhooks.EmptyEvaluationSeriesData()) + assert.Equal(t, res.data[1], ldhooks.EmptyEvaluationSeriesData()) + }) + + t.Run("verify execution order", func(t *testing.T) { + testCases := []struct { + name string + method func(execution *EvaluationExecution) + expectedBeforeOrder []string + expectedAfterOrder []string + }{ + {name: "BeforeEvaluation", + method: func(execution *EvaluationExecution) { + execution.BeforeEvaluation(context.Background()) + }, + expectedBeforeOrder: []string{"a", "b"}, + expectedAfterOrder: make([]string, 0), + }, + {name: "AfterEvaluation", + method: func(execution *EvaluationExecution) { + detail := ldreason.NewEvaluationDetail(falseValue, 0, + ldreason.NewEvalReasonFallthrough()) + execution.AfterEvaluation(context.Background(), detail) + }, + expectedBeforeOrder: make([]string, 0), + expectedAfterOrder: []string{"b", "a"}}, + } + + t.Run("with hooks registered at config time", func(t *testing.T) { + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + tracker := newOrderTracker() + hookA := createOrderTrackingHook("a", tracker) + hookB := createOrderTrackingHook("b", tracker) + runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA, hookB}) + + execution := runner.prepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + testCase.method(execution) + + // BeforeEvaluation should execute in registration order. + assert.Equal(t, testCase.expectedBeforeOrder, tracker.orderBefore) + assert.Equal(t, testCase.expectedAfterOrder, tracker.orderAfter) + }) + } + }) + + t.Run("run before evaluation", func(t *testing.T) { + hookA := sharedtest.NewTestHook("a") + hookB := sharedtest.NewTestHook("b") + runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA, hookB}) + + execution := runner.prepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + execution.BeforeEvaluation(context.Background()) + + hookA.Verify(t, sharedtest.HookExpectedCall{ + HookStage: sharedtest.HookStageBeforeEvaluation, + EvalCapture: sharedtest.HookEvalCapture{ + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext("test-flag", ldContext, + falseValue, "testMethod"), + EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), + GoContext: context.Background(), + }}) + + hookB.Verify(t, sharedtest.HookExpectedCall{ + HookStage: sharedtest.HookStageBeforeEvaluation, + EvalCapture: sharedtest.HookEvalCapture{ + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext("test-flag", ldContext, + falseValue, "testMethod"), + EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), + GoContext: context.Background(), + }}) + }) + + t.Run("run after evaluation", func(t *testing.T) { + hookA := sharedtest.NewTestHook("a") + hookB := sharedtest.NewTestHook("b") + runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA, hookB}) + + execution := runner.prepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + detail := ldreason.NewEvaluationDetail(falseValue, 0, + ldreason.NewEvalReasonFallthrough()) + execution.AfterEvaluation(context.Background(), detail) + + hookA.Verify(t, sharedtest.HookExpectedCall{ + HookStage: sharedtest.HookStageAfterEvaluation, + EvalCapture: sharedtest.HookEvalCapture{ + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext("test-flag", ldContext, + falseValue, "testMethod"), + EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), + Detail: detail, + GoContext: context.Background(), + }}) + + hookB.Verify(t, sharedtest.HookExpectedCall{ + HookStage: sharedtest.HookStageAfterEvaluation, + EvalCapture: sharedtest.HookEvalCapture{ + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext("test-flag", ldContext, + falseValue, "testMethod"), + EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), + Detail: detail, + GoContext: context.Background(), + }}) + }) + + t.Run("run before evaluation with an error", func(t *testing.T) { + mockLog := ldlogtest.NewMockLog() + hookA := sharedtest.NewTestHook("a") + hookA.BeforeInject = func( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + ) (ldhooks.EvaluationSeriesData, error) { + return ldhooks.NewEvaluationSeriesBuilder(data). + Set("testA", "A"). + Build(), errors.New("something bad") + } + hookB := sharedtest.NewTestHook("b") + hookB.BeforeInject = func( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + ) (ldhooks.EvaluationSeriesData, error) { + return ldhooks.NewEvaluationSeriesBuilder(data). + Set("testB", "testB"). + Build(), nil + } + + runner := NewRunner(mockLog.Loggers, []ldhooks.Hook{hookA, hookB}) + execution := runner.prepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + + execution.BeforeEvaluation(context.Background()) + assert.Len(t, execution.hooks, 2) + assert.Len(t, execution.data, 2) + assert.Equal(t, ldContext, execution.context.Context()) + assert.Equal(t, "test-flag", execution.context.FlagKey()) + assert.Equal(t, "testMethod", execution.context.Method()) + assert.Equal(t, ldhooks.EmptyEvaluationSeriesData(), execution.data[0]) + assert.Equal(t, + ldhooks.NewEvaluationSeriesBuilder( + ldhooks.EmptyEvaluationSeriesData()). + Set("testB", "testB"). + Build(), execution.data[1]) + assert.Equal(t, falseValue, execution.context.DefaultValue()) + + assert.Equal(t, []string{"During evaluation of flag \"test-flag\", an error was encountered in \"BeforeEvaluation\" of the \"a\" hook: something bad"}, + mockLog.GetOutput(ldlog.Error)) + }) + + t.Run("run after evaluation with an error", func(t *testing.T) { + mockLog := ldlogtest.NewMockLog() + hookA := sharedtest.NewTestHook("a") + // The hooks execute in reverse order, so we have an error in B and check that A still executes. + hookA.AfterInject = func( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + detail ldreason.EvaluationDetail, + ) (ldhooks.EvaluationSeriesData, error) { + return ldhooks.NewEvaluationSeriesBuilder(data). + Set("testA", "testA"). + Build(), nil + } + hookB := sharedtest.NewTestHook("b") + hookB.AfterInject = func( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + detail ldreason.EvaluationDetail, + ) (ldhooks.EvaluationSeriesData, error) { + return ldhooks.NewEvaluationSeriesBuilder(data). + Set("testB", "B"). + Build(), errors.New("something bad") + + } + + runner := NewRunner(mockLog.Loggers, []ldhooks.Hook{hookA, hookB}) + execution := runner.prepareEvaluationSeries("test-flag", ldContext, falseValue, + "testMethod") + detail := ldreason.NewEvaluationDetail(falseValue, 0, + ldreason.NewEvalReasonFallthrough()) + + execution.AfterEvaluation(context.Background(), detail) + assert.Len(t, execution.hooks, 2) + assert.Len(t, execution.data, 2) + assert.Equal(t, ldContext, execution.context.Context()) + assert.Equal(t, "test-flag", execution.context.FlagKey()) + assert.Equal(t, "testMethod", execution.context.Method()) + assert.Equal(t, ldhooks.EmptyEvaluationSeriesData(), execution.data[1]) + assert.Equal(t, + ldhooks.NewEvaluationSeriesBuilder( + ldhooks.EmptyEvaluationSeriesData()). + Set("testA", "testA"). + Build(), execution.data[0]) + assert.Equal(t, falseValue, execution.context.DefaultValue()) + assert.Equal(t, []string{"During evaluation of flag \"test-flag\", an error was encountered in \"AfterEvaluation\" of the \"b\" hook: something bad"}, + mockLog.GetOutput(ldlog.Error)) + }) + }) + }) +} + +func createOrderTrackingHook(name string, tracker *orderTracker) sharedtest.TestHook { + h := sharedtest.NewTestHook(name) + h.BeforeInject = func( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + ) (ldhooks.EvaluationSeriesData, error) { + tracker.orderBefore = append(tracker.orderBefore, name) + return data, nil + } + h.AfterInject = func( + ctx context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + detail ldreason.EvaluationDetail, + ) (ldhooks.EvaluationSeriesData, error) { + tracker.orderAfter = append(tracker.orderAfter, name) + return data, nil + } + + return h +} diff --git a/internal/hooks/runner.go b/internal/hooks/runner.go index 27053292..59f54aef 100644 --- a/internal/hooks/runner.go +++ b/internal/hooks/runner.go @@ -1,13 +1,13 @@ package hooks import ( - "context" - "sync" + gocontext "context" "github.com/launchdarkly/go-sdk-common/v3/ldcontext" "github.com/launchdarkly/go-sdk-common/v3/ldlog" "github.com/launchdarkly/go-sdk-common/v3/ldreason" "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/go-server-sdk-evaluation/v3/ldmodel" "github.com/launchdarkly/go-server-sdk/v7/ldhooks" ) @@ -15,22 +15,6 @@ import ( type Runner struct { hooks []ldhooks.Hook loggers ldlog.Loggers - mutex *sync.RWMutex -} - -// EvaluationExecution represents the state of a running series of evaluation stages. -type EvaluationExecution struct { - hooks []ldhooks.Hook - data []ldhooks.EvaluationSeriesData - context ldhooks.EvaluationSeriesContext -} - -func (e EvaluationExecution) withData(data []ldhooks.EvaluationSeriesData) EvaluationExecution { - return EvaluationExecution{ - hooks: e.hooks, - context: e.context, - data: data, - } } // NewRunner creates a new hook runner. @@ -38,104 +22,43 @@ func NewRunner(loggers ldlog.Loggers, hooks []ldhooks.Hook) *Runner { return &Runner{ loggers: loggers, hooks: hooks, - mutex: &sync.RWMutex{}, } } -// AddHooks adds hooks to the runner. -func (h *Runner) AddHooks(hooks ...ldhooks.Hook) { - h.mutex.Lock() - defer h.mutex.Unlock() - - h.hooks = append(h.hooks, hooks...) -} - -// getHooks returns a copy of the hooks. This copy is suitable for use when executing a series. This keeps the set -// of hooks stable for the duration of the series. This prevents things like calling the AfterEvaluation method for -// a hook that didn't have the BeforeEvaluation method called. -func (h *Runner) getHooks() []ldhooks.Hook { - h.mutex.RLock() - defer h.mutex.RUnlock() - copiedHooks := make([]ldhooks.Hook, len(h.hooks)) - copy(copiedHooks, h.hooks) - return copiedHooks +// RunEvaluation runs the evaluation series surrounding the given evaluation function. +func (h *Runner) RunEvaluation( + ctx gocontext.Context, + flagKey string, + evalContext ldcontext.Context, + defaultVal ldvalue.Value, + method string, + fn func() (ldreason.EvaluationDetail, *ldmodel.FeatureFlag, error), +) (ldreason.EvaluationDetail, *ldmodel.FeatureFlag, error) { + if len(h.hooks) == 0 { + return fn() + } + e := h.prepareEvaluationSeries(flagKey, evalContext, defaultVal, method) + e.BeforeEvaluation(ctx) + detail, flag, err := fn() + e.AfterEvaluation(ctx, detail) + return detail, flag, err } -// PrepareEvaluationSeries creates an EvaluationExecution suitable for executing evaluation stages and gets a copy -// of hooks to use during series execution. -// -// For an invocation of a series the same set of hooks should be used. For instance a hook added mid-evaluation should -// not be executed during the "AfterEvaluation" stage of that evaluation. -func (h *Runner) PrepareEvaluationSeries( +// PrepareEvaluationSeries creates an EvaluationExecution suitable for executing evaluation stages. +func (h *Runner) prepareEvaluationSeries( flagKey string, evalContext ldcontext.Context, defaultVal ldvalue.Value, method string, -) EvaluationExecution { - hooksForEval := h.getHooks() - - returnData := make([]ldhooks.EvaluationSeriesData, len(hooksForEval)) - for i := range hooksForEval { +) *EvaluationExecution { + returnData := make([]ldhooks.EvaluationSeriesData, len(h.hooks)) + for i := range h.hooks { returnData[i] = ldhooks.EmptyEvaluationSeriesData() } - return EvaluationExecution{ - hooks: hooksForEval, + return &EvaluationExecution{ + hooks: h.hooks, data: returnData, context: ldhooks.NewEvaluationSeriesContext(flagKey, evalContext, defaultVal, method), + loggers: &h.loggers, } } - -// BeforeEvaluation executes the BeforeEvaluation stage of registered hooks. -func (h *Runner) BeforeEvaluation(ctx context.Context, execution EvaluationExecution) EvaluationExecution { - return h.executeStage( - execution, - false, - "BeforeEvaluation", - func(hook ldhooks.Hook, data ldhooks.EvaluationSeriesData) (ldhooks.EvaluationSeriesData, error) { - return hook.BeforeEvaluation(ctx, execution.context, data) - }) -} - -// AfterEvaluation executes the AfterEvaluation stage of registered hooks. -func (h *Runner) AfterEvaluation( - ctx context.Context, - execution EvaluationExecution, - detail ldreason.EvaluationDetail, -) EvaluationExecution { - return h.executeStage( - execution, - true, - "AfterEvaluation", - func(hook ldhooks.Hook, data ldhooks.EvaluationSeriesData) (ldhooks.EvaluationSeriesData, error) { - return hook.AfterEvaluation(ctx, execution.context, data, detail) - }) -} - -func (h *Runner) executeStage( - execution EvaluationExecution, - reverse bool, - stageName string, - fn func( - hook ldhooks.Hook, - data ldhooks.EvaluationSeriesData, - ) (ldhooks.EvaluationSeriesData, error)) EvaluationExecution { - returnData := make([]ldhooks.EvaluationSeriesData, len(execution.hooks)) - iterator := newIterator(reverse, execution.hooks) - for iterator.hasNext() { - i, hook := iterator.getNext() - - outData, err := fn(hook, execution.data[i]) - if err != nil { - returnData[i] = execution.data[i] - h.loggers.Errorf( - "During evaluation of flag \"%s\", an error was encountered in \"%s\" of the \"%s\" hook: %s", - execution.context.FlagKey(), - stageName, - hook.Metadata().Name(), - err.Error()) - continue - } - returnData[i] = outData - } - return execution.withData(returnData) -} diff --git a/internal/hooks/runner_test.go b/internal/hooks/runner_test.go index 33604faa..8915eec0 100644 --- a/internal/hooks/runner_test.go +++ b/internal/hooks/runner_test.go @@ -1,330 +1,123 @@ package hooks import ( - "context" - "errors" + gocontext "context" "testing" - "github.com/launchdarkly/go-sdk-common/v3/ldlog" - "github.com/launchdarkly/go-sdk-common/v3/ldlogtest" - "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest" - "github.com/launchdarkly/go-sdk-common/v3/ldcontext" "github.com/launchdarkly/go-sdk-common/v3/ldreason" "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/go-server-sdk-evaluation/v3/ldmodel" + "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest" "github.com/launchdarkly/go-server-sdk/v7/ldhooks" "github.com/stretchr/testify/assert" ) -func emptyExecutionAssertions(t *testing.T, res EvaluationExecution, ldContext ldcontext.Context) { - assert.Empty(t, res.hooks) - assert.Empty(t, res.data) - assert.Equal(t, ldContext, res.context.Context()) - assert.Equal(t, "test-flag", res.context.FlagKey()) - assert.Equal(t, "testMethod", res.context.Method()) - assert.Equal(t, ldvalue.Bool(false), res.context.DefaultValue()) -} - -type orderTracker struct { - orderBefore []string - orderAfter []string -} - -func newOrderTracker() *orderTracker { - return &orderTracker{ - orderBefore: make([]string, 0), - orderAfter: make([]string, 0), - } -} - func TestHookRunner(t *testing.T) { falseValue := ldvalue.Bool(false) ldContext := ldcontext.New("test-context") + flagKey := "test-flag" + testMethod := "testMethod" + defaultDetail := ldreason.NewEvaluationDetail(falseValue, 0, ldreason.NewEvalReasonFallthrough()) + basicResult := func() (ldreason.EvaluationDetail, *ldmodel.FeatureFlag, error) { + return defaultDetail, nil, nil + } t.Run("with no hooks", func(t *testing.T) { runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{}) t.Run("prepare evaluation series", func(t *testing.T) { - res := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, "testMethod") - emptyExecutionAssertions(t, res, ldContext) - }) - - t.Run("run before evaluation", func(t *testing.T) { - execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, - "testMethod") - res := runner.BeforeEvaluation(context.Background(), execution) + res := runner.prepareEvaluationSeries(flagKey, ldContext, falseValue, testMethod) emptyExecutionAssertions(t, res, ldContext) }) - t.Run("run after evaluation", func(t *testing.T) { - execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, - "testMethod") - res := runner.AfterEvaluation(context.Background(), execution, - ldreason.NewEvaluationDetail(falseValue, 0, - ldreason.NewEvalReasonFallthrough())) - emptyExecutionAssertions(t, res, ldContext) + t.Run("run execution", func(t *testing.T) { + detail, flag, err := runner.RunEvaluation( + gocontext.Background(), + flagKey, + ldContext, + falseValue, + testMethod, + basicResult, + ) + assert.Equal(t, defaultDetail, detail) + assert.Nil(t, flag) + assert.Nil(t, err) }) }) - t.Run("with hooks", func(t *testing.T) { - t.Run("prepare evaluation series", func(t *testing.T) { - hookA := sharedtest.NewTestHook("a") - hookB := sharedtest.NewTestHook("b") - runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA, hookB}) - - ldContext := ldcontext.New("test-context") - res := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, "testMethod") - - assert.Len(t, res.hooks, 2) - assert.Len(t, res.data, 2) - assert.Equal(t, ldContext, res.context.Context()) - assert.Equal(t, "test-flag", res.context.FlagKey()) - assert.Equal(t, "testMethod", res.context.Method()) - assert.Equal(t, falseValue, res.context.DefaultValue()) - assert.Equal(t, res.data[0], ldhooks.EmptyEvaluationSeriesData()) - assert.Equal(t, res.data[1], ldhooks.EmptyEvaluationSeriesData()) - }) - - t.Run("verify execution order", func(t *testing.T) { - testCases := []struct { - name string - method func(runner *Runner, execution EvaluationExecution) - expectedBeforeOrder []string - expectedAfterOrder []string - }{ - {name: "BeforeEvaluation", - method: func(runner *Runner, execution EvaluationExecution) { - _ = runner.BeforeEvaluation(context.Background(), execution) - }, - expectedBeforeOrder: []string{"a", "b"}, - expectedAfterOrder: make([]string, 0), - }, - {name: "AfterEvaluation", - method: func(runner *Runner, execution EvaluationExecution) { - detail := ldreason.NewEvaluationDetail(falseValue, 0, - ldreason.NewEvalReasonFallthrough()) - _ = runner.AfterEvaluation(context.Background(), execution, detail) - }, - expectedBeforeOrder: make([]string, 0), - expectedAfterOrder: []string{"b", "a"}}, - } - - t.Run("with hooks registered at config time", func(t *testing.T) { - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - tracker := newOrderTracker() - hookA := createOrderTrackingHook("a", tracker) - hookB := createOrderTrackingHook("b", tracker) - runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA, hookB}) - - execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, - "testMethod") - testCase.method(runner, execution) - - // BeforeEvaluation should execute in registration order. - assert.Equal(t, testCase.expectedBeforeOrder, tracker.orderBefore) - assert.Equal(t, testCase.expectedAfterOrder, tracker.orderAfter) - }) - } - }) - - t.Run("with hooks registered at run time", func(t *testing.T) { - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - tracker := newOrderTracker() - hookA := createOrderTrackingHook("a", tracker) - hookB := createOrderTrackingHook("b", tracker) - runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA}) - runner.AddHooks(hookB) - - execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, - "testMethod") - testCase.method(runner, execution) - - // BeforeEvaluation should execute in registration order. - assert.Equal(t, testCase.expectedBeforeOrder, tracker.orderBefore) - assert.Equal(t, testCase.expectedAfterOrder, tracker.orderAfter) - }) - } - }) - }) - - t.Run("run before evaluation", func(t *testing.T) { - hookA := sharedtest.NewTestHook("a") - hookB := sharedtest.NewTestHook("b") - runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA, hookB}) - - execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, - "testMethod") - _ = runner.BeforeEvaluation(context.Background(), execution) - - hookA.Verify(t, sharedtest.HookExpectedCall{ - HookStage: sharedtest.HookStageBeforeEvaluation, - EvalCapture: sharedtest.HookEvalCapture{ - EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext("test-flag", ldContext, - falseValue, "testMethod"), - EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), - GoContext: context.Background(), - }}) - - hookB.Verify(t, sharedtest.HookExpectedCall{ - HookStage: sharedtest.HookStageBeforeEvaluation, - EvalCapture: sharedtest.HookEvalCapture{ - EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext("test-flag", ldContext, - falseValue, "testMethod"), - EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), - GoContext: context.Background(), - }}) - }) - - t.Run("run after evaluation", func(t *testing.T) { - hookA := sharedtest.NewTestHook("a") - hookB := sharedtest.NewTestHook("b") - runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA, hookB}) - - execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, - "testMethod") - detail := ldreason.NewEvaluationDetail(falseValue, 0, - ldreason.NewEvalReasonFallthrough()) - _ = runner.AfterEvaluation(context.Background(), execution, detail) - - hookA.Verify(t, sharedtest.HookExpectedCall{ + t.Run("verify execution and order", func(t *testing.T) { + tracker := newOrderTracker() + hookA := createOrderTrackingHook("a", tracker) + hookB := createOrderTrackingHook("b", tracker) + runner := NewRunner(sharedtest.NewTestLoggers(), []ldhooks.Hook{hookA, hookB}) + + _, _, _ = runner.RunEvaluation( + gocontext.Background(), + flagKey, + ldContext, + falseValue, + testMethod, + basicResult, + ) + + hookA.Verify(t, sharedtest.HookExpectedCall{ + HookStage: sharedtest.HookStageBeforeEvaluation, + EvalCapture: sharedtest.HookEvalCapture{ + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext( + flagKey, + ldContext, + falseValue, + testMethod, + ), + EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), + GoContext: gocontext.Background(), + }, + }, + sharedtest.HookExpectedCall{ HookStage: sharedtest.HookStageAfterEvaluation, EvalCapture: sharedtest.HookEvalCapture{ - EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext("test-flag", ldContext, - falseValue, "testMethod"), + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext( + flagKey, + ldContext, + falseValue, + testMethod, + ), EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), - Detail: detail, - GoContext: context.Background(), - }}) + GoContext: gocontext.Background(), + Detail: defaultDetail, + }, + }) - hookB.Verify(t, sharedtest.HookExpectedCall{ + hookB.Verify(t, sharedtest.HookExpectedCall{ + HookStage: sharedtest.HookStageBeforeEvaluation, + EvalCapture: sharedtest.HookEvalCapture{ + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext( + flagKey, + ldContext, + falseValue, + testMethod, + ), + EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), + GoContext: gocontext.Background(), + }}, + sharedtest.HookExpectedCall{ HookStage: sharedtest.HookStageAfterEvaluation, EvalCapture: sharedtest.HookEvalCapture{ - EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext("test-flag", ldContext, - falseValue, "testMethod"), + EvaluationSeriesContext: ldhooks.NewEvaluationSeriesContext( + flagKey, + ldContext, + falseValue, + testMethod, + ), EvaluationSeriesData: ldhooks.EmptyEvaluationSeriesData(), - Detail: detail, - GoContext: context.Background(), - }}) - }) - - t.Run("run before evaluation with an error", func(t *testing.T) { - mockLog := ldlogtest.NewMockLog() - hookA := sharedtest.NewTestHook("a") - hookA.BeforeInject = func( - ctx context.Context, - seriesContext ldhooks.EvaluationSeriesContext, - data ldhooks.EvaluationSeriesData, - ) (ldhooks.EvaluationSeriesData, error) { - return ldhooks.NewEvaluationSeriesBuilder(data). - Set("testA", "A"). - Build(), errors.New("something bad") - } - hookB := sharedtest.NewTestHook("b") - hookB.BeforeInject = func( - ctx context.Context, - seriesContext ldhooks.EvaluationSeriesContext, - data ldhooks.EvaluationSeriesData, - ) (ldhooks.EvaluationSeriesData, error) { - return ldhooks.NewEvaluationSeriesBuilder(data). - Set("testB", "testB"). - Build(), nil - } - - runner := NewRunner(mockLog.Loggers, []ldhooks.Hook{hookA, hookB}) - execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, - "testMethod") - - res := runner.BeforeEvaluation(context.Background(), execution) - assert.Len(t, res.hooks, 2) - assert.Len(t, res.data, 2) - assert.Equal(t, ldContext, res.context.Context()) - assert.Equal(t, "test-flag", res.context.FlagKey()) - assert.Equal(t, "testMethod", res.context.Method()) - assert.Equal(t, ldhooks.EmptyEvaluationSeriesData(), res.data[0]) - assert.Equal(t, - ldhooks.NewEvaluationSeriesBuilder( - ldhooks.EmptyEvaluationSeriesData()). - Set("testB", "testB"). - Build(), res.data[1]) - assert.Equal(t, falseValue, res.context.DefaultValue()) - - assert.Equal(t, []string{"During evaluation of flag \"test-flag\", an error was encountered in \"BeforeEvaluation\" of the \"a\" hook: something bad"}, - mockLog.GetOutput(ldlog.Error)) - }) - - t.Run("run after evaluation with an error", func(t *testing.T) { - mockLog := ldlogtest.NewMockLog() - hookA := sharedtest.NewTestHook("a") - // The hooks execute in reverse order, so we have an error in B and check that A still executes. - hookA.AfterInject = func( - ctx context.Context, - seriesContext ldhooks.EvaluationSeriesContext, - data ldhooks.EvaluationSeriesData, - detail ldreason.EvaluationDetail, - ) (ldhooks.EvaluationSeriesData, error) { - return ldhooks.NewEvaluationSeriesBuilder(data). - Set("testA", "testA"). - Build(), nil - } - hookB := sharedtest.NewTestHook("b") - hookB.AfterInject = func( - ctx context.Context, - seriesContext ldhooks.EvaluationSeriesContext, - data ldhooks.EvaluationSeriesData, - detail ldreason.EvaluationDetail, - ) (ldhooks.EvaluationSeriesData, error) { - return ldhooks.NewEvaluationSeriesBuilder(data). - Set("testB", "B"). - Build(), errors.New("something bad") - - } - - runner := NewRunner(mockLog.Loggers, []ldhooks.Hook{hookA, hookB}) - execution := runner.PrepareEvaluationSeries("test-flag", ldContext, falseValue, - "testMethod") - detail := ldreason.NewEvaluationDetail(falseValue, 0, - ldreason.NewEvalReasonFallthrough()) + GoContext: gocontext.Background(), + Detail: defaultDetail, + }, + }) - res := runner.AfterEvaluation(context.Background(), execution, detail) - assert.Len(t, res.hooks, 2) - assert.Len(t, res.data, 2) - assert.Equal(t, ldContext, res.context.Context()) - assert.Equal(t, "test-flag", res.context.FlagKey()) - assert.Equal(t, "testMethod", res.context.Method()) - assert.Equal(t, ldhooks.EmptyEvaluationSeriesData(), res.data[1]) - assert.Equal(t, - ldhooks.NewEvaluationSeriesBuilder( - ldhooks.EmptyEvaluationSeriesData()). - Set("testA", "testA"). - Build(), res.data[0]) - assert.Equal(t, falseValue, res.context.DefaultValue()) - assert.Equal(t, []string{"During evaluation of flag \"test-flag\", an error was encountered in \"AfterEvaluation\" of the \"b\" hook: something bad"}, - mockLog.GetOutput(ldlog.Error)) - }) + // BeforeEvaluation should execute in registration order. + assert.Equal(t, []string{"a", "b"}, tracker.orderBefore) + assert.Equal(t, []string{"b", "a"}, tracker.orderAfter) }) } - -func createOrderTrackingHook(name string, tracker *orderTracker) sharedtest.TestHook { - h := sharedtest.NewTestHook(name) - h.BeforeInject = func( - ctx context.Context, - seriesContext ldhooks.EvaluationSeriesContext, - data ldhooks.EvaluationSeriesData, - ) (ldhooks.EvaluationSeriesData, error) { - tracker.orderBefore = append(tracker.orderBefore, name) - return data, nil - } - h.AfterInject = func( - ctx context.Context, - seriesContext ldhooks.EvaluationSeriesContext, - data ldhooks.EvaluationSeriesData, - detail ldreason.EvaluationDetail, - ) (ldhooks.EvaluationSeriesData, error) { - tracker.orderAfter = append(tracker.orderAfter, name) - return data, nil - } - - return h -} diff --git a/ldclient.go b/ldclient.go index 430b86e1..1ad50390 100644 --- a/ldclient.go +++ b/ldclient.go @@ -27,7 +27,6 @@ import ( "github.com/launchdarkly/go-server-sdk/v7/internal/datastore" "github.com/launchdarkly/go-server-sdk/v7/internal/hooks" "github.com/launchdarkly/go-server-sdk/v7/ldcomponents" - "github.com/launchdarkly/go-server-sdk/v7/ldhooks" "github.com/launchdarkly/go-server-sdk/v7/subsystems" "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoreimpl" ) @@ -399,27 +398,39 @@ func (client *LDClient) migrationVariation( ctx gocontext.Context, key string, context ldcontext.Context, defaultStage ldmigration.Stage, eventsScope eventsScope, method string, ) (ldmigration.Stage, interfaces.LDMigrationOpTracker, error) { - stageValue := ldvalue.String(string(defaultStage)) - hookExecution := client.hookRunner.PrepareEvaluationSeries(key, context, stageValue, method) - hookExecution = client.hookRunner.BeforeEvaluation(ctx, hookExecution) - detail, flag, err := client.variationAndFlag(key, context, stageValue, true, - eventsScope) - tracker := NewMigrationOpTracker(key, flag, context, detail, defaultStage) + defaultStageAsValue := ldvalue.String(string(defaultStage)) + + detail, flag, err := client.hookRunner.RunEvaluation( + ctx, + key, + context, + defaultStageAsValue, + method, + func() (ldreason.EvaluationDetail, *ldmodel.FeatureFlag, error) { + detail, flag, err := client.variationAndFlag(key, context, defaultStageAsValue, true, + eventsScope) + + if err != nil { + // Detail will already contain the default. + // We do not have an error on the flag-not-found case. + return detail, flag, nil + } - if err != nil { - return defaultStage, tracker, nil - } + _, err = ldmigration.ParseStage(detail.Value.StringValue()) + if err != nil { + detail = ldreason.NewEvaluationDetailForError(ldreason.EvalErrorWrongType, ldvalue.String(string(defaultStage))) + return detail, flag, fmt.Errorf("%s; returning default stage %s", err, defaultStage) + } - stage, err := ldmigration.ParseStage(detail.Value.StringValue()) - if err != nil { - detail = ldreason.NewEvaluationDetailForError(ldreason.EvalErrorWrongType, ldvalue.String(string(defaultStage))) - client.hookRunner.AfterEvaluation(ctx, hookExecution, detail) - tracker := NewMigrationOpTracker(key, flag, context, detail, defaultStage) - return defaultStage, tracker, fmt.Errorf("%s; returning default stage %s", err, defaultStage) - } + return detail, flag, err + }, + ) + + tracker := NewMigrationOpTracker(key, flag, context, detail, defaultStage) + // Stage will have already been parsed and defaulted. + stage, _ := ldmigration.ParseStage(detail.Value.StringValue()) - client.hookRunner.AfterEvaluation(ctx, hookExecution, detail) - return stage, tracker, nil + return stage, tracker, err } // Identify reports details about an evaluation context. @@ -723,7 +734,7 @@ func (client *LDClient) AllFlagsState(context ldcontext.Context, options ...flag // // For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluating#go func (client *LDClient) BoolVariation(key string, context ldcontext.Context, defaultVal bool) (bool, error) { - detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Bool(defaultVal), true, + detail, _, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Bool(defaultVal), true, client.eventsDefault, boolVarFuncName) return detail.Value.BoolValue(), err } @@ -737,7 +748,7 @@ func (client *LDClient) BoolVariationDetail( context ldcontext.Context, defaultVal bool, ) (bool, ldreason.EvaluationDetail, error) { - detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Bool(defaultVal), true, + detail, _, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Bool(defaultVal), true, client.eventsWithReasons, boolVarDetailFuncName) return detail.Value.BoolValue(), detail, err } @@ -757,7 +768,7 @@ func (client *LDClient) BoolVariationCtx( context ldcontext.Context, defaultVal bool, ) (bool, error) { - detail, err := client.variationWithHooks(ctx, key, context, ldvalue.Bool(defaultVal), true, + detail, _, err := client.variationWithHooks(ctx, key, context, ldvalue.Bool(defaultVal), true, client.eventsDefault, boolVarExFuncName) return detail.Value.BoolValue(), err } @@ -775,7 +786,7 @@ func (client *LDClient) BoolVariationDetailCtx( context ldcontext.Context, defaultVal bool, ) (bool, ldreason.EvaluationDetail, error) { - detail, err := client.variationWithHooks(ctx, key, context, ldvalue.Bool(defaultVal), true, + detail, _, err := client.variationWithHooks(ctx, key, context, ldvalue.Bool(defaultVal), true, client.eventsWithReasons, boolVarDetailExFuncName) return detail.Value.BoolValue(), detail, err } @@ -790,7 +801,7 @@ func (client *LDClient) BoolVariationDetailCtx( // // For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluating#go func (client *LDClient) IntVariation(key string, context ldcontext.Context, defaultVal int) (int, error) { - detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Int(defaultVal), true, + detail, _, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Int(defaultVal), true, client.eventsDefault, intVarFuncName) return detail.Value.IntValue(), err } @@ -804,7 +815,7 @@ func (client *LDClient) IntVariationDetail( context ldcontext.Context, defaultVal int, ) (int, ldreason.EvaluationDetail, error) { - detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Int(defaultVal), true, + detail, _, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Int(defaultVal), true, client.eventsWithReasons, intVarDetailFuncName) return detail.Value.IntValue(), detail, err } @@ -826,7 +837,7 @@ func (client *LDClient) IntVariationCtx( context ldcontext.Context, defaultVal int, ) (int, error) { - detail, err := client.variationWithHooks(ctx, key, context, ldvalue.Int(defaultVal), true, + detail, _, err := client.variationWithHooks(ctx, key, context, ldvalue.Int(defaultVal), true, client.eventsDefault, intVarExFuncName) return detail.Value.IntValue(), err } @@ -844,7 +855,7 @@ func (client *LDClient) IntVariationDetailCtx( context ldcontext.Context, defaultVal int, ) (int, ldreason.EvaluationDetail, error) { - detail, err := client.variationWithHooks(ctx, key, context, ldvalue.Int(defaultVal), true, + detail, _, err := client.variationWithHooks(ctx, key, context, ldvalue.Int(defaultVal), true, client.eventsWithReasons, intVarDetailExFuncName) return detail.Value.IntValue(), detail, err } @@ -857,7 +868,7 @@ func (client *LDClient) IntVariationDetailCtx( // // For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluating#go func (client *LDClient) Float64Variation(key string, context ldcontext.Context, defaultVal float64) (float64, error) { - detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Float64(defaultVal), + detail, _, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Float64(defaultVal), true, client.eventsDefault, floatVarFuncName) return detail.Value.Float64Value(), err } @@ -871,7 +882,7 @@ func (client *LDClient) Float64VariationDetail( context ldcontext.Context, defaultVal float64, ) (float64, ldreason.EvaluationDetail, error) { - detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Float64(defaultVal), + detail, _, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Float64(defaultVal), true, client.eventsWithReasons, floatVarDetailFuncName) return detail.Value.Float64Value(), detail, err } @@ -891,7 +902,7 @@ func (client *LDClient) Float64VariationCtx( context ldcontext.Context, defaultVal float64, ) (float64, error) { - detail, err := client.variationWithHooks(ctx, key, context, ldvalue.Float64(defaultVal), true, + detail, _, err := client.variationWithHooks(ctx, key, context, ldvalue.Float64(defaultVal), true, client.eventsDefault, floatVarExFuncName) return detail.Value.Float64Value(), err } @@ -909,7 +920,7 @@ func (client *LDClient) Float64VariationDetailCtx( context ldcontext.Context, defaultVal float64, ) (float64, ldreason.EvaluationDetail, error) { - detail, err := client.variationWithHooks(ctx, key, context, ldvalue.Float64(defaultVal), true, + detail, _, err := client.variationWithHooks(ctx, key, context, ldvalue.Float64(defaultVal), true, client.eventsWithReasons, floatVarDetailExFuncName) return detail.Value.Float64Value(), detail, err } @@ -922,7 +933,7 @@ func (client *LDClient) Float64VariationDetailCtx( // // For more information, see the Reference Guide: https://docs.launchdarkly.com/sdk/features/evaluating#go func (client *LDClient) StringVariation(key string, context ldcontext.Context, defaultVal string) (string, error) { - detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.String(defaultVal), true, + detail, _, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.String(defaultVal), true, client.eventsDefault, stringVarFuncName) return detail.Value.StringValue(), err } @@ -936,7 +947,7 @@ func (client *LDClient) StringVariationDetail( context ldcontext.Context, defaultVal string, ) (string, ldreason.EvaluationDetail, error) { - detail, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.String(defaultVal), true, + detail, _, err := client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.String(defaultVal), true, client.eventsWithReasons, stringVarDetailFuncName) return detail.Value.StringValue(), detail, err } @@ -956,7 +967,7 @@ func (client *LDClient) StringVariationCtx( context ldcontext.Context, defaultVal string, ) (string, error) { - detail, err := client.variationWithHooks(ctx, key, context, ldvalue.String(defaultVal), true, + detail, _, err := client.variationWithHooks(ctx, key, context, ldvalue.String(defaultVal), true, client.eventsDefault, stringVarExFuncName) return detail.Value.StringValue(), err } @@ -974,7 +985,7 @@ func (client *LDClient) StringVariationDetailCtx( context ldcontext.Context, defaultVal string, ) (string, ldreason.EvaluationDetail, error) { - detail, err := client.variationWithHooks(ctx, key, context, ldvalue.String(defaultVal), true, + detail, _, err := client.variationWithHooks(ctx, key, context, ldvalue.String(defaultVal), true, client.eventsWithReasons, stringVarDetailExFuncName) return detail.Value.StringValue(), detail, err } @@ -1007,7 +1018,7 @@ func (client *LDClient) JSONVariation( context ldcontext.Context, defaultVal ldvalue.Value, ) (ldvalue.Value, error) { - detail, err := client.variationWithHooks(gocontext.TODO(), key, context, defaultVal, false, client.eventsDefault, + detail, _, err := client.variationWithHooks(gocontext.TODO(), key, context, defaultVal, false, client.eventsDefault, jsonVarFuncName) return detail.Value, err } @@ -1021,8 +1032,15 @@ func (client *LDClient) JSONVariationDetail( context ldcontext.Context, defaultVal ldvalue.Value, ) (ldvalue.Value, ldreason.EvaluationDetail, error) { - detail, err := client.variationWithHooks(gocontext.TODO(), key, context, defaultVal, false, client.eventsWithReasons, - jsonVarDetailFuncName) + detail, _, err := client.variationWithHooks( + gocontext.TODO(), + key, + context, + defaultVal, + false, + client.eventsWithReasons, + jsonVarDetailFuncName, + ) return detail.Value, detail, err } @@ -1057,7 +1075,7 @@ func (client *LDClient) JSONVariationCtx( context ldcontext.Context, defaultVal ldvalue.Value, ) (ldvalue.Value, error) { - detail, err := client.variationWithHooks(ctx, key, context, defaultVal, false, client.eventsDefault, + detail, _, err := client.variationWithHooks(ctx, key, context, defaultVal, false, client.eventsDefault, jsonVarExFuncName) return detail.Value, err } @@ -1075,7 +1093,7 @@ func (client *LDClient) JSONVariationDetailCtx( context ldcontext.Context, defaultVal ldvalue.Value, ) (ldvalue.Value, ldreason.EvaluationDetail, error) { - detail, err := client.variationWithHooks(ctx, key, context, defaultVal, false, client.eventsWithReasons, + detail, _, err := client.variationWithHooks(ctx, key, context, defaultVal, false, client.eventsWithReasons, jsonVarDetailExFuncName) return detail.Value, detail, err } @@ -1144,18 +1162,6 @@ func (client *LDClient) WithEventsDisabled(disabled bool) interfaces.LDClientInt return client.withEventsDisabled } -// Generic method for evaluating a feature flag for a given evaluation context. -func (client *LDClient) variation( - key string, - context ldcontext.Context, - defaultVal ldvalue.Value, - checkType bool, - eventsScope eventsScope, -) (ldreason.EvaluationDetail, error) { - detail, _, err := client.variationAndFlag(key, context, defaultVal, checkType, eventsScope) - return detail, err -} - func (client *LDClient) variationWithHooks( context gocontext.Context, key string, @@ -1164,12 +1170,18 @@ func (client *LDClient) variationWithHooks( checkType bool, eventsScope eventsScope, method string, -) (ldreason.EvaluationDetail, error) { - execution := client.hookRunner.PrepareEvaluationSeries(key, evalContext, defaultVal, method) - execution = client.hookRunner.BeforeEvaluation(context, execution) - detail, err := client.variation(key, evalContext, defaultVal, checkType, eventsScope) - client.hookRunner.AfterEvaluation(context, execution, detail) - return detail, err +) (ldreason.EvaluationDetail, *ldmodel.FeatureFlag, error) { + detail, flag, err := client.hookRunner.RunEvaluation( + context, + key, + evalContext, + defaultVal, + method, + func() (ldreason.EvaluationDetail, *ldmodel.FeatureFlag, error) { + return client.variationAndFlag(key, evalContext, defaultVal, checkType, eventsScope) + }, + ) + return detail, flag, err } // Generic method for evaluating a feature flag for a given evaluation context, @@ -1297,13 +1309,6 @@ func (client *LDClient) evaluateInternal( return result, feature, nil } -// AddHooks allows adding a hook after creating the LDClient. -// -// If a hook can be registered at LDClient initialization, then the Hooks field of Config can be used instead. -func (client *LDClient) AddHooks(hooks ...ldhooks.Hook) { - client.hookRunner.AddHooks(hooks...) -} - func newEvaluationError(jsonValue ldvalue.Value, errorKind ldreason.EvalErrorKind) ldreason.EvaluationDetail { return ldreason.EvaluationDetail{ Value: jsonValue, diff --git a/ldclient_events.go b/ldclient_events.go index 936268e5..cb2d0de4 100644 --- a/ldclient_events.go +++ b/ldclient_events.go @@ -106,14 +106,14 @@ func (c *clientEventsDisabledDecorator) BoolVariation( context ldcontext.Context, defaultVal bool, ) (bool, error) { - detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Bool(defaultVal), + detail, _, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Bool(defaultVal), true, c.scope, boolVarFuncName) return detail.Value.BoolValue(), err } func (c *clientEventsDisabledDecorator) BoolVariationDetail(key string, context ldcontext.Context, defaultVal bool) ( bool, ldreason.EvaluationDetail, error) { - detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Bool(defaultVal), + detail, _, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Bool(defaultVal), true, c.scope, boolVarDetailFuncName) return detail.Value.BoolValue(), detail, err } @@ -124,7 +124,7 @@ func (c *clientEventsDisabledDecorator) BoolVariationCtx( context ldcontext.Context, defaultVal bool, ) (bool, error) { - detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Bool(defaultVal), + detail, _, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Bool(defaultVal), true, c.scope, boolVarExFuncName) return detail.Value.BoolValue(), err } @@ -136,7 +136,7 @@ func (c *clientEventsDisabledDecorator) BoolVariationDetailCtx( defaultVal bool, ) ( bool, ldreason.EvaluationDetail, error) { - detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Bool(defaultVal), + detail, _, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Bool(defaultVal), true, c.scope, boolVarDetailExFuncName) return detail.Value.BoolValue(), detail, err } @@ -146,14 +146,14 @@ func (c *clientEventsDisabledDecorator) IntVariation( context ldcontext.Context, defaultVal int, ) (int, error) { - detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Int(defaultVal), + detail, _, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Int(defaultVal), true, c.scope, intVarFuncName) return detail.Value.IntValue(), err } func (c *clientEventsDisabledDecorator) IntVariationDetail(key string, context ldcontext.Context, defaultVal int) ( int, ldreason.EvaluationDetail, error) { - detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Int(defaultVal), + detail, _, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Int(defaultVal), true, c.scope, intVarDetailFuncName) return detail.Value.IntValue(), detail, err } @@ -164,7 +164,7 @@ func (c *clientEventsDisabledDecorator) IntVariationCtx( context ldcontext.Context, defaultVal int, ) (int, error) { - detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Int(defaultVal), + detail, _, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Int(defaultVal), true, c.scope, intVarExFuncName) return detail.Value.IntValue(), err } @@ -176,14 +176,14 @@ func (c *clientEventsDisabledDecorator) IntVariationDetailCtx( defaultVal int, ) ( int, ldreason.EvaluationDetail, error) { - detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Int(defaultVal), + detail, _, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Int(defaultVal), true, c.scope, intVarDetailExFuncName) return detail.Value.IntValue(), detail, err } func (c *clientEventsDisabledDecorator) Float64Variation(key string, context ldcontext.Context, defaultVal float64) ( float64, error) { - detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Float64(defaultVal), + detail, _, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Float64(defaultVal), true, c.scope, floatVarFuncName) return detail.Value.Float64Value(), err } @@ -194,7 +194,7 @@ func (c *clientEventsDisabledDecorator) Float64VariationDetail( defaultVal float64, ) ( float64, ldreason.EvaluationDetail, error) { - detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Float64(defaultVal), + detail, _, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.Float64(defaultVal), true, c.scope, floatVarDetailFuncName) return detail.Value.Float64Value(), detail, err } @@ -206,7 +206,7 @@ func (c *clientEventsDisabledDecorator) Float64VariationCtx( defaultVal float64, ) ( float64, error) { - detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Float64(defaultVal), + detail, _, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Float64(defaultVal), true, c.scope, floatVarExFuncName) return detail.Value.Float64Value(), err } @@ -218,14 +218,14 @@ func (c *clientEventsDisabledDecorator) Float64VariationDetailCtx( defaultVal float64, ) ( float64, ldreason.EvaluationDetail, error) { - detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Float64(defaultVal), + detail, _, err := c.client.variationWithHooks(ctx, key, context, ldvalue.Float64(defaultVal), true, c.scope, floatVarDetailExFuncName) return detail.Value.Float64Value(), detail, err } func (c *clientEventsDisabledDecorator) StringVariation(key string, context ldcontext.Context, defaultVal string) ( string, error) { - detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.String(defaultVal), + detail, _, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.String(defaultVal), true, c.scope, stringVarExFuncName) return detail.Value.StringValue(), err } @@ -236,7 +236,7 @@ func (c *clientEventsDisabledDecorator) StringVariationDetail( defaultVal string, ) ( string, ldreason.EvaluationDetail, error) { - detail, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.String(defaultVal), + detail, _, err := c.client.variationWithHooks(gocontext.TODO(), key, context, ldvalue.String(defaultVal), true, c.scope, stringVarDetailFuncName) return detail.Value.StringValue(), detail, err } @@ -248,7 +248,7 @@ func (c *clientEventsDisabledDecorator) StringVariationCtx( defaultVal string, ) ( string, error) { - detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.String(defaultVal), true, c.scope, + detail, _, err := c.client.variationWithHooks(ctx, key, context, ldvalue.String(defaultVal), true, c.scope, stringVarExFuncName) return detail.Value.StringValue(), err } @@ -260,7 +260,7 @@ func (c *clientEventsDisabledDecorator) StringVariationDetailCtx( defaultVal string, ) ( string, ldreason.EvaluationDetail, error) { - detail, err := c.client.variationWithHooks(ctx, key, context, ldvalue.String(defaultVal), true, c.scope, + detail, _, err := c.client.variationWithHooks(ctx, key, context, ldvalue.String(defaultVal), true, c.scope, stringVarDetailExFuncName) return detail.Value.StringValue(), detail, err } @@ -282,7 +282,15 @@ func (c *clientEventsDisabledDecorator) MigrationVariationCtx( func (c *clientEventsDisabledDecorator) JSONVariation(key string, context ldcontext.Context, defaultVal ldvalue.Value) ( ldvalue.Value, error) { - detail, err := c.client.variation(key, context, defaultVal, true, c.scope) + detail, _, err := c.client.variationWithHooks( + gocontext.TODO(), + key, + context, + defaultVal, + true, + c.scope, + jsonVarFuncName, + ) return detail.Value, err } @@ -292,7 +300,15 @@ func (c *clientEventsDisabledDecorator) JSONVariationDetail( defaultVal ldvalue.Value, ) ( ldvalue.Value, ldreason.EvaluationDetail, error) { - detail, err := c.client.variation(key, context, defaultVal, true, c.scope) + detail, _, err := c.client.variationWithHooks( + gocontext.TODO(), + key, + context, + defaultVal, + true, + c.scope, + jsonVarDetailFuncName, + ) return detail.Value, detail, err } diff --git a/ldclient_hooks_test.go b/ldclient_hooks_test.go index 092e6e54..da69edae 100644 --- a/ldclient_hooks_test.go +++ b/ldclient_hooks_test.go @@ -268,24 +268,3 @@ func TestHooksAreExecutedForAllVariationMethods(t *testing.T) { }) } } - -func TestUsesAStableSetOfHooksDuringEvaluation(t *testing.T) { - client, _ := MakeCustomClient("", Config{Offline: true, Hooks: []ldhooks.Hook{}}, 0) - - hook := sharedtest.NewTestHook("test-hook") - sneaky := sharedtest.NewTestHook("sneaky") - hook.BeforeInject = func( - ctx gocontext.Context, - context ldhooks.EvaluationSeriesContext, - data ldhooks.EvaluationSeriesData, - ) (ldhooks.EvaluationSeriesData, error) { - client.AddHooks(sneaky) - return ldhooks.NewEvaluationSeriesBuilder(data).Set("test-key", "test-value").Build(), nil - } - - client.AddHooks(hook) - - _, _ = client.BoolVariation("flag-key", ldcontext.New("test-context"), false) - - sneaky.VerifyNoCalls(t) -} diff --git a/testservice/go.sum b/testservice/go.sum index 7039a8ed..8dfcc99e 100644 --- a/testservice/go.sum +++ b/testservice/go.sum @@ -29,6 +29,7 @@ github.com/launchdarkly/go-test-helpers/v2 v2.2.0/go.mod h1:L7+th5govYp5oKU9iN7T github.com/launchdarkly/go-test-helpers/v2 v2.3.1 h1:KXUAQVTeHNcWVDVQ94uEkybI+URXI9rEd7E553EsZFw= github.com/launchdarkly/go-test-helpers/v2 v2.3.1/go.mod h1:L7+th5govYp5oKU9iN7To5PgznBuIjBPn+ejqKR0avw= github.com/launchdarkly/go-test-helpers/v3 v3.0.2 h1:rh0085g1rVJM5qIukdaQ8z1XTWZztbJ49vRZuveqiuU= +github.com/launchdarkly/go-test-helpers/v3 v3.0.2/go.mod h1:u2ZvJlc/DDJTFrshWW50tWMZHLVYXofuSHUfTU/eIwM= github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= @@ -40,6 +41,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ= github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM= golang.org/x/exp v0.0.0-20220823124025-807a23277127 h1:S4NrSKDfihhl3+4jSTgwoIevKxX9p7Iv9x++OEIptDo= @@ -51,3 +53,4 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/testservice/sdk_client_entity.go b/testservice/sdk_client_entity.go index f66cd9af..3a979360 100644 --- a/testservice/sdk_client_entity.go +++ b/testservice/sdk_client_entity.go @@ -14,6 +14,7 @@ import ( "github.com/launchdarkly/go-server-sdk/v7/interfaces" "github.com/launchdarkly/go-server-sdk/v7/interfaces/flagstate" "github.com/launchdarkly/go-server-sdk/v7/ldcomponents" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" "github.com/launchdarkly/go-server-sdk/v7/testservice/servicedef" "github.com/launchdarkly/go-sdk-common/v3/ldcontext" @@ -431,6 +432,15 @@ func makeSDKConfig(config servicedef.SDKConfigParams, sdkLog ldlog.Loggers) ld.C } } + if config.Hooks != nil { + hooks := make([]ldhooks.Hook, 0) + for _, hookConfig := range config.Hooks.Hooks { + hookInstance := newTestHook(hookConfig.Name, hookConfig.CallbackURI, hookConfig.Data) + hooks = append(hooks, hookInstance) + } + ret.Hooks = hooks + } + return ret } diff --git a/testservice/service.go b/testservice/service.go index 91978db9..59973a40 100644 --- a/testservice/service.go +++ b/testservice/service.go @@ -39,6 +39,7 @@ var capabilities = []string{ servicedef.CapabilityEventSampling, servicedef.CapabilityInlineContext, servicedef.CapabilityAnonymousRedaction, + servicedef.CapabilityEvaluationHooks, } // gets the specified environment variable, or the default if not set diff --git a/testservice/servicedef/command_params.go b/testservice/servicedef/command_params.go index 37f8c70b..9b5f0978 100644 --- a/testservice/servicedef/command_params.go +++ b/testservice/servicedef/command_params.go @@ -146,3 +146,24 @@ type MigrationOperationParams struct { type MigrationOperationResponse struct { Result interface{} `json:"result"` } + +type HookStage string + +const ( + BeforeEvaluation HookStage = "beforeEvaluation" + AfterEvaluation HookStage = "afterEvaluation" +) + +type EvaluationSeriesContext struct { + FlagKey string `json:"flagKey"` + Context ldcontext.Context `json:"context"` + DefaultValue ldvalue.Value `json:"defaultValue"` + Method string `json:"method"` +} + +type HookExecutionPayload struct { + EvaluationSeriesContext EvaluationSeriesContext `json:"evaluationSeriesContext,omitempty"` + EvaluationSeriesData map[string]ldvalue.Value `json:"evaluationSeriesData,omitempty"` + EvaluationDetail EvaluateFlagResponse `json:"evaluationDetail,omitempty"` + Stage HookStage `json:"stage,omitempty"` +} diff --git a/testservice/servicedef/sdk_config.go b/testservice/servicedef/sdk_config.go index 47c9eef5..cf2e93e0 100644 --- a/testservice/servicedef/sdk_config.go +++ b/testservice/servicedef/sdk_config.go @@ -16,6 +16,7 @@ type SDKConfigParams struct { PersistentDataStore *SDKConfigPersistentDataStoreParams `json:"persistentDataStore,omitempty"` BigSegments *SDKConfigBigSegmentsParams `json:"bigSegments,omitempty"` Tags *SDKConfigTagsParams `json:"tags,omitempty"` + Hooks *SDKConfigHooksParams `json:"hooks,omitempty"` } type SDKConfigServiceEndpointsParams struct { @@ -61,3 +62,15 @@ type SDKConfigTagsParams struct { ApplicationID ldvalue.OptionalString `json:"applicationId,omitempty"` ApplicationVersion ldvalue.OptionalString `json:"applicationVersion,omitempty"` } + +type SDKConfigEvaluationHookData map[string]ldvalue.Value + +type SDKConfigHookInstance struct { + Name string `json:"name"` + CallbackURI string `json:"callbackUri"` + Data map[HookStage]SDKConfigEvaluationHookData `json:"data,omitempty"` +} + +type SDKConfigHooksParams struct { + Hooks []SDKConfigHookInstance `json:"hooks"` +} diff --git a/testservice/servicedef/service_params.go b/testservice/servicedef/service_params.go index 095ee476..567bf3ad 100644 --- a/testservice/servicedef/service_params.go +++ b/testservice/servicedef/service_params.go @@ -20,6 +20,7 @@ const ( CapabilityEventSampling = "event-sampling" CapabilityInlineContext = "inline-context" CapabilityAnonymousRedaction = "anonymous-redaction" + CapabilityEvaluationHooks = "evaluation-hooks" ) type StatusRep struct { diff --git a/testservice/test_hook.go b/testservice/test_hook.go new file mode 100644 index 00000000..afd48e25 --- /dev/null +++ b/testservice/test_hook.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + + "github.com/launchdarkly/go-sdk-common/v3/ldreason" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" + "github.com/launchdarkly/go-server-sdk/v7/testservice/servicedef" +) + +type testHook struct { + ldhooks.Unimplemented + metadata ldhooks.Metadata + dataPayloads map[servicedef.HookStage]servicedef.SDKConfigEvaluationHookData + callbackService callbackService +} + +func newTestHook( + name string, + endpoint string, + data map[servicedef.HookStage]servicedef.SDKConfigEvaluationHookData, +) testHook { + return testHook{ + metadata: ldhooks.NewMetadata(name), + dataPayloads: data, + callbackService: callbackService{baseURL: endpoint}, + } +} + +func (t testHook) Metadata() ldhooks.Metadata { + return t.metadata +} + +func (t testHook) BeforeEvaluation( + _ context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, +) (ldhooks.EvaluationSeriesData, error) { + err := t.callbackService.post("", servicedef.HookExecutionPayload{ + EvaluationSeriesContext: evaluationSeriesContextToService(seriesContext), + EvaluationSeriesData: evaluationSeriesDataToService(data), + Stage: servicedef.BeforeEvaluation, + }, nil) + if err != nil { + return ldhooks.EmptyEvaluationSeriesData(), err + } + dataBuilder := ldhooks.NewEvaluationSeriesBuilder(data) + stageData := t.dataPayloads[servicedef.BeforeEvaluation] + for key, value := range stageData { + dataBuilder.Set(key, value) + } + return dataBuilder.Build(), nil +} + +func (t testHook) AfterEvaluation( + _ context.Context, + seriesContext ldhooks.EvaluationSeriesContext, + data ldhooks.EvaluationSeriesData, + detail ldreason.EvaluationDetail, +) (ldhooks.EvaluationSeriesData, error) { + err := t.callbackService.post("", servicedef.HookExecutionPayload{ + EvaluationSeriesContext: evaluationSeriesContextToService(seriesContext), + EvaluationSeriesData: evaluationSeriesDataToService(data), + Stage: servicedef.AfterEvaluation, + EvaluationDetail: *detailToService(detail), + }, nil) + if err != nil { + return ldhooks.EmptyEvaluationSeriesData(), err + } + dataBuilder := ldhooks.NewEvaluationSeriesBuilder(data) + stageData := t.dataPayloads[servicedef.AfterEvaluation] + for key, value := range stageData { + dataBuilder.Set(key, value) + } + return data, nil +} + +func evaluationSeriesContextToService( + seriesContext ldhooks.EvaluationSeriesContext, +) servicedef.EvaluationSeriesContext { + return servicedef.EvaluationSeriesContext{ + FlagKey: seriesContext.FlagKey(), + Context: seriesContext.Context(), + DefaultValue: seriesContext.DefaultValue(), + Method: seriesContext.Method(), + } +} + +func evaluationSeriesDataToService(seriesData ldhooks.EvaluationSeriesData) map[string]ldvalue.Value { + ret := make(map[string]ldvalue.Value) + for key, value := range seriesData.AsAnyMap() { + ret[key] = ldvalue.CopyArbitraryValue(value) + } + return ret +} + +func detailToService(detail ldreason.EvaluationDetail) *servicedef.EvaluateFlagResponse { + rep := &servicedef.EvaluateFlagResponse{ + Value: detail.Value, + VariationIndex: detail.VariationIndex.AsPointer(), + } + if detail.Reason.IsDefined() { + rep.Reason = &detail.Reason + } + return rep +} From 150d09e2ce39fe4d147f056c32d6f12b31ddef17 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 29 Mar 2024 14:33:53 -0700 Subject: [PATCH 05/31] Loop based targets and update contributing. --- CONTRIBUTING.md | 21 +++++++++++++++------ Makefile | 31 +++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3cfc6866..13d79943 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,28 +18,37 @@ This project should be built against the lowest supported Go version as describe ### Building -To build the SDK without running any tests: -``` +To build all modules without running any tests: +```shell make ``` If you wish to clean your working directory between builds, you can clean it by running: -``` +```shell make clean ``` To run the linter: -``` +```shell make lint ``` ### Testing -To build the SDK and run all unit tests: -``` +To build all modules and run all unit tests: +```shell make test ``` +### Working Cross Module + +If you have a change which affects more than a single module, then you can use a go workspace. + +You can create a workspace using: +```shell +make workspace +``` + ## Coding best practices The Go SDK can be used in high-traffic application/service code where performance is critical. There are a number of coding principles to keep in mind for maximizing performance. The benchmarks that are run in CI are helpful in measuring the impact of code changes in this regard. diff --git a/Makefile b/Makefile index f1ce24aa..8c976b5f 100644 --- a/Makefile +++ b/Makefile @@ -20,23 +20,38 @@ COVERAGE_ENFORCER_FLAGS=-package github.com/launchdarkly/go-server-sdk/v7 \ -skipcode "// COVERAGE" \ -packagestats -filestats -showcode -.PHONY: build clean test test-coverage benchmarks benchmark-allocs lint +ALL_MODULES := "./..." ./ldotel -build: - go build ./... - go build ./ldotel +.PHONY: all build clean test test-coverage benchmarks benchmark-allocs lint workspace + +all: + @for module in $(ALL_MODULES); do \ + echo "Building $$module"; \ + go build "$$module"; \ + done clean: - go clean + @for module in $(ALL_MODULES); do \ + if [ "$$module" = "./..." ]; then \ + echo "Cleaning ./..."; \ + go clean; \ + else \ + echo "Cleaning $$module"; \ + cd $$module; \ + go clean -v; \ + cd -; \ + fi \ + done test: - go test -run=not-a-real-test ./... # just ensures that the tests compile - go test -v -race ./... + @for module in $(ALL_MODULES); do \ + echo "Testing $$module"; \ + go test -v -race "$$module"; \ + done @# The proxy tests must be run separately because Go caches the global proxy environment variables. We use @# build tags to isolate these tests from the main test run so that if you do "go test ./..." you won't @# get unexpected errors. for tag in proxytest1 proxytest2; do go test -race -v -tags=$$tag ./proxytest; done - go test ./ldotel test-coverage: $(COVERAGE_PROFILE_RAW) go run github.com/launchdarkly-labs/go-coverage-enforcer@latest $(COVERAGE_ENFORCER_FLAGS) -outprofile $(COVERAGE_PROFILE_FILTERED) $(COVERAGE_PROFILE_RAW) From 4ab744ad88a441837c2fa4804276481be2c39e40 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 29 Mar 2024 14:34:28 -0700 Subject: [PATCH 06/31] Add comment to makefile --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 8c976b5f..4b4dcc7c 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ COVERAGE_ENFORCER_FLAGS=-package github.com/launchdarkly/go-server-sdk/v7 \ -skipcode "// COVERAGE" \ -packagestats -filestats -showcode +# When adding a module to the repository it should be added to this list. ALL_MODULES := "./..." ./ldotel .PHONY: all build clean test test-coverage benchmarks benchmark-allocs lint workspace From b041730c28cdcfe213d1a47f0074d0ca6e2af7d1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 1 Apr 2024 11:05:30 -0700 Subject: [PATCH 07/31] Support all targets in makefile. --- CONTRIBUTING.md | 5 ---- Makefile | 60 ++++++++++++++++++++---------------------- ldotel/tracing_hook.go | 2 +- 3 files changed, 29 insertions(+), 38 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13d79943..88147cd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,11 +23,6 @@ To build all modules without running any tests: make ``` -If you wish to clean your working directory between builds, you can clean it by running: -```shell -make clean -``` - To run the linter: ```shell make lint diff --git a/Makefile b/Makefile index 4b4dcc7c..3eb21915 100644 --- a/Makefile +++ b/Makefile @@ -20,40 +20,38 @@ COVERAGE_ENFORCER_FLAGS=-package github.com/launchdarkly/go-server-sdk/v7 \ -skipcode "// COVERAGE" \ -packagestats -filestats -showcode -# When adding a module to the repository it should be added to this list. -ALL_MODULES := "./..." ./ldotel - -.PHONY: all build clean test test-coverage benchmarks benchmark-allocs lint workspace - -all: - @for module in $(ALL_MODULES); do \ - echo "Building $$module"; \ - go build "$$module"; \ - done - -clean: - @for module in $(ALL_MODULES); do \ - if [ "$$module" = "./..." ]; then \ - echo "Cleaning ./..."; \ - go clean; \ - else \ - echo "Cleaning $$module"; \ - cd $$module; \ - go clean -v; \ - cd -; \ - fi \ - done - -test: - @for module in $(ALL_MODULES); do \ - echo "Testing $$module"; \ - go test -v -race "$$module"; \ - done +ALL_BUILD_TARGETS=sdk ldotel +ALL_TEST_TARGETS = $(addsuffix -test, $(ALL_BUILD_TARGETS)) +ALL_LINT_TARGETS = $(addsuffix -lint, $(ALL_BUILD_TARGETS)) + +.PHONY: all build clean test test-coverage benchmarks benchmark-allocs lint workspace sdk sdk-test ldotel ldotel-test + +all: $(ALL_BUILD_TARGETS) + +test: $(ALL_TEST_TARGETS) + +sdk: + go build ./... + +sdk-test: + go test -v -race ./... @# The proxy tests must be run separately because Go caches the global proxy environment variables. We use @# build tags to isolate these tests from the main test run so that if you do "go test ./..." you won't @# get unexpected errors. for tag in proxytest1 proxytest2; do go test -race -v -tags=$$tag ./proxytest; done +sdk-lint: + $(LINTER) run ./... + +ldotel: + go build ./ldotel + +ldotel-test: + go test -v -race ./ldotel + +ldotel-lint: + $(LINTER) run ./ldotel + test-coverage: $(COVERAGE_PROFILE_RAW) go run github.com/launchdarkly-labs/go-coverage-enforcer@latest $(COVERAGE_ENFORCER_FLAGS) -outprofile $(COVERAGE_PROFILE_FILTERED) $(COVERAGE_PROFILE_RAW) go tool cover -html $(COVERAGE_PROFILE_FILTERED) -o $(COVERAGE_PROFILE_FILTERED_HTML) @@ -88,9 +86,7 @@ $(LINTER_VERSION_FILE): curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s $(GOLANGCI_LINT_VERSION) touch $(LINTER_VERSION_FILE) -lint: $(LINTER_VERSION_FILE) - $(LINTER) run ./... - +lint: $(LINTER_VERSION_FILE) $(ALL_LINT_TARGETS) TEMP_TEST_OUTPUT=/tmp/sdk-contract-test-service.log diff --git a/ldotel/tracing_hook.go b/ldotel/tracing_hook.go index 12d39039..4b7010eb 100644 --- a/ldotel/tracing_hook.go +++ b/ldotel/tracing_hook.go @@ -48,7 +48,7 @@ type TracingHook struct { tracer trace.Tracer } -// GetMetadata returns meta-data about the tracing hook. +// Metadata returns meta-data about the tracing hook. func (h TracingHook) Metadata() ldhooks.Metadata { return h.metadata } From 152107dae2843173769f3f9e842bda4d0c892436 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 1 Apr 2024 11:08:00 -0700 Subject: [PATCH 08/31] Update .PHONY --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3eb21915..861013fe 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ ALL_BUILD_TARGETS=sdk ldotel ALL_TEST_TARGETS = $(addsuffix -test, $(ALL_BUILD_TARGETS)) ALL_LINT_TARGETS = $(addsuffix -lint, $(ALL_BUILD_TARGETS)) -.PHONY: all build clean test test-coverage benchmarks benchmark-allocs lint workspace sdk sdk-test ldotel ldotel-test +.PHONY: all build clean test test-coverage benchmarks benchmark-allocs lint workspace $(ALL_BUILD_TARGETS) $(ALL_TEST_TARGETS) $(ALL_LINT_TARGETS) all: $(ALL_BUILD_TARGETS) From 722f7b2c7b2b42e44b4d299e939a9adcecf5c1ca Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 1 Apr 2024 11:10:08 -0700 Subject: [PATCH 09/31] Add experimental text. --- ldotel/tracing_hook.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ldotel/tracing_hook.go b/ldotel/tracing_hook.go index 4b7010eb..36f2c555 100644 --- a/ldotel/tracing_hook.go +++ b/ldotel/tracing_hook.go @@ -18,7 +18,9 @@ const contextKeyAttributeName = "feature_flag.context.key" // TracingHookOption is used to implement functional options for the TracingHook. type TracingHookOption func(hook *TracingHook) -// WithSpans option enables generation of child spans for each variation call. +// WithSpans is an experimental option that enables creation of child spans for each variation call. +// +// This feature is experimental and the data in the spans, or nesting of spans, could change in future versions. func WithSpans() TracingHookOption { return func(h *TracingHook) { h.spans = true From 745c08ce075d739a38aabdce04727f50aa1f6385 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 1 Apr 2024 13:11:44 -0700 Subject: [PATCH 10/31] Add use separate releases. --- release-please-config.json | 1 + 1 file changed, 1 insertion(+) diff --git a/release-please-config.json b/release-please-config.json index 8eca0426..cf85ca1b 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,5 +1,6 @@ { "packages" : { + "separate-pull-requests": true, "." : { "release-type" : "go", "bump-minor-pre-major" : true, From b8ec2b6054b26948085b89cefed076a3ba499780 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 1 Apr 2024 13:26:55 -0700 Subject: [PATCH 11/31] Tidy --- ldotel/go.mod | 20 +++++++++++++++--- ldotel/go.sum | 58 ++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/ldotel/go.mod b/ldotel/go.mod index f45bada3..611f39d7 100644 --- a/ldotel/go.mod +++ b/ldotel/go.mod @@ -5,17 +5,31 @@ go 1.21 require ( github.com/launchdarkly/go-sdk-common/v3 v3.1.0 github.com/launchdarkly/go-server-sdk/v7 v7.2.0 - go.opentelemetry.io/otel/sdk v1.24.0 - go.opentelemetry.io/otel/trace v1.22.0 github.com/stretchr/testify v1.8.4 + go.opentelemetry.io/otel v1.24.0 + go.opentelemetry.io/otel/sdk v1.24.0 + go.opentelemetry.io/otel/trace v1.24.0 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.1.1 // indirect + github.com/gregjones/httpcache v0.0.0-20171119193500-2bcd89a1743f // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/launchdarkly/ccache v1.1.0 // indirect + github.com/launchdarkly/eventsource v1.6.2 // indirect github.com/launchdarkly/go-jsonstream/v3 v3.0.0 // indirect + github.com/launchdarkly/go-sdk-events/v3 v3.2.0 // indirect + github.com/launchdarkly/go-semver v1.0.2 // indirect + github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.0 // indirect github.com/mailru/easyjson v0.7.6 // indirect - go.opentelemetry.io/otel/metric v1.22.0 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect golang.org/x/exp v0.0.0-20220823124025-807a23277127 // indirect + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e // indirect + golang.org/x/sys v0.17.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/ldotel/go.sum b/ldotel/go.sum index fce8c56a..f9b1d762 100644 --- a/ldotel/go.sum +++ b/ldotel/go.sum @@ -1,3 +1,4 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -7,28 +8,71 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gregjones/httpcache v0.0.0-20171119193500-2bcd89a1743f h1:kOkUP6rcVVqC+KlKKENKtgfFfJyDySYhqL9srXooghY= +github.com/gregjones/httpcache v0.0.0-20171119193500-2bcd89a1743f/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003 h1:vJ0Snvo+SLMY72r5J4sEfkuE7AFbixEP2qRbEcum/wA= +github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003/go.mod h1:zNBxMY8P21owkeogJELCLeHIt+voOSduHYTFUbwRAV8= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/launchdarkly/ccache v1.1.0 h1:voD1M+ZJXR3MREOKtBwgTF9hYHl1jg+vFKS/+VAkR2k= +github.com/launchdarkly/ccache v1.1.0/go.mod h1:TlxzrlnzvYeXiLHmesMuvoZetu4Z97cV1SsdqqBJi1Q= +github.com/launchdarkly/eventsource v1.6.2 h1:5SbcIqzUomn+/zmJDrkb4LYw7ryoKFzH/0TbR0/3Bdg= +github.com/launchdarkly/eventsource v1.6.2/go.mod h1:LHxSeb4OnqznNZxCSXbFghxS/CjIQfzHovNoAqbO/Wk= github.com/launchdarkly/go-jsonstream/v3 v3.0.0 h1:qJF/WI09EUJ7kSpmP5d1Rhc81NQdYUhP17McKfUq17E= github.com/launchdarkly/go-jsonstream/v3 v3.0.0/go.mod h1:/1Gyml6fnD309JOvunOSfyysWbZ/ZzcA120gF/cQtC4= github.com/launchdarkly/go-sdk-common/v3 v3.1.0 h1:KNCP5rfkOt/25oxGLAVgaU1BgrZnzH9Y/3Z6I8bMwDg= github.com/launchdarkly/go-sdk-common/v3 v3.1.0/go.mod h1:mXFmDGEh4ydK3QilRhrAyKuf9v44VZQWnINyhqbbOd0= -github.com/launchdarkly/go-server-sdk/v7 v7.2.0/go.mod h1:EY2ag+p9HnNXiG4pJ+y7QG2gqCYEoYD+NJgwkhmUUqk= +github.com/launchdarkly/go-sdk-events/v3 v3.2.0 h1:FUby/4cUSVDghCkFDpvy+7vZlIW4+CK95HjQnuqGXVs= +github.com/launchdarkly/go-sdk-events/v3 v3.2.0/go.mod h1:oepYWQ2RvvjfL2WxkE1uJJIuRsIMOP4WIVgUpXRPcNI= +github.com/launchdarkly/go-semver v1.0.2 h1:sYVRnuKyvxlmQCnCUyDkAhtmzSFRoX6rG2Xa21Mhg+w= +github.com/launchdarkly/go-semver v1.0.2/go.mod h1:xFmMwXba5Mb+3h72Z+VeSs9ahCvKo2QFUTHRNHVqR28= +github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.0 h1:nQbR1xCpkdU9Z71FI28bWTi5LrmtSVURy0UFcBVD5ZU= +github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.0/go.mod h1:cwk7/7SzNB2wZbCZS7w2K66klMLBe3NFM3/qd3xnsRc= +github.com/launchdarkly/go-test-helpers/v2 v2.2.0 h1:L3kGILP/6ewikhzhdNkHy1b5y4zs50LueWenVF0sBbs= +github.com/launchdarkly/go-test-helpers/v2 v2.2.0/go.mod h1:L7+th5govYp5oKU9iN7To5PgznBuIjBPn+ejqKR0avw= github.com/launchdarkly/go-test-helpers/v3 v3.0.2 h1:rh0085g1rVJM5qIukdaQ8z1XTWZztbJ49vRZuveqiuU= github.com/launchdarkly/go-test-helpers/v3 v3.0.2/go.mod h1:u2ZvJlc/DDJTFrshWW50tWMZHLVYXofuSHUfTU/eIwM= github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -go.opentelemetry.io/otel v1.22.0 h1:xS7Ku+7yTFvDfDraDIJVpw7XPyuHlB9MCiqqX5mcJ6Y= -go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI= -go.opentelemetry.io/otel/metric v1.22.0 h1:lypMQnGyJYeuYPhOM/bgjbFM6WE44W1/T45er4d8Hhg= -go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY= -go.opentelemetry.io/otel/trace v1.22.0 h1:Hg6pPujv0XG9QaVbGOBVHunyuLcCC3jN7WEhPx83XD0= -go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo= +github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ= +github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= +go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= golang.org/x/exp v0.0.0-20220823124025-807a23277127 h1:S4NrSKDfihhl3+4jSTgwoIevKxX9p7Iv9x++OEIptDo= golang.org/x/exp v0.0.0-20220823124025-807a23277127/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From b74918096072a098f868f61f088831778a63dee8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 1 Apr 2024 14:18:38 -0700 Subject: [PATCH 12/31] Reformat example. --- ldotel/package_info.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ldotel/package_info.go b/ldotel/package_info.go index 0f732055..5561f98e 100644 --- a/ldotel/package_info.go +++ b/ldotel/package_info.go @@ -2,11 +2,9 @@ // // For instance, to use LaunchDarkly with OpenTelemetry tracing, one would use the TracingHook: // -// client, _ = ld.MakeCustomClient("sdk-47698c22-f258-4cd1-8e66-f2bd9bd1fc2a", -// -// ld.Config{ -// Hooks: []ldhooks.Hook{ldotel.NewTracingHook()}, -// }, 5*time.Second) +// client, _ = ld.MakeCustomClient("sdk-key", ld.Config{ +// Hooks: []ldhooks.Hook{ldotel.NewTracingHook()}, +// }, 5*time.Second) package ldotel // Version is the current version string of the ldotel package. This is updated by our release scripts. From 7806865d987738c52fdd292f5f8b4d0b56df6dbb Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 2 Apr 2024 08:59:26 -0700 Subject: [PATCH 13/31] Re-add clean. --- CONTRIBUTING.md | 7 +++++++ Makefile | 17 ++++++++++++++--- ldotel/go.sum | 1 + 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88147cd6..37c3ccd8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,13 @@ To build all modules and run all unit tests: make test ``` +### Clean + +To clean temporary files created by other targets: +```shell +make clean +``` + ### Working Cross Module If you have a change which affects more than a single module, then you can use a go workspace. diff --git a/Makefile b/Makefile index 861013fe..8a3d7807 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ GOLANGCI_LINT_VERSION=v1.57.1 LINTER=./bin/golangci-lint LINTER_VERSION_FILE=./bin/.golangci-lint-version-$(GOLANGCI_LINT_VERSION) +GO_WORK_FILE=go.work +GO_WORK_SUM=go.work.sum + TEST_BINARY=./go-server-sdk.test ALLOCATIONS_LOG=./build/allocations.out @@ -24,12 +27,15 @@ ALL_BUILD_TARGETS=sdk ldotel ALL_TEST_TARGETS = $(addsuffix -test, $(ALL_BUILD_TARGETS)) ALL_LINT_TARGETS = $(addsuffix -lint, $(ALL_BUILD_TARGETS)) -.PHONY: all build clean test test-coverage benchmarks benchmark-allocs lint workspace $(ALL_BUILD_TARGETS) $(ALL_TEST_TARGETS) $(ALL_LINT_TARGETS) +.PHONY: all build clean test test-coverage benchmarks benchmark-allocs lint workspace workspace-clean $(ALL_BUILD_TARGETS) $(ALL_TEST_TARGETS) $(ALL_LINT_TARGETS) all: $(ALL_BUILD_TARGETS) test: $(ALL_TEST_TARGETS) +clean: workspace-clean + rm -rf ./bin/ + sdk: go build ./... @@ -93,12 +99,17 @@ TEMP_TEST_OUTPUT=/tmp/sdk-contract-test-service.log # TEST_HARNESS_PARAMS can be set to add -skip parameters for any contract tests that cannot yet pass TEST_HARNESS_PARAMS= -workspace: - rm -f go.work +workspace: go.work + +go.work: go work init ./ go work use ./ldotel go work use ./testservice +workspace-clean: + rm -f $(GO_WORK_FILE) $(GO_WORK_SUM) + + build-contract-tests: @go build -o ./testservice/testservice ./testservice diff --git a/ldotel/go.sum b/ldotel/go.sum index f9b1d762..88fb3ab0 100644 --- a/ldotel/go.sum +++ b/ldotel/go.sum @@ -34,6 +34,7 @@ github.com/launchdarkly/go-semver v1.0.2 h1:sYVRnuKyvxlmQCnCUyDkAhtmzSFRoX6rG2Xa github.com/launchdarkly/go-semver v1.0.2/go.mod h1:xFmMwXba5Mb+3h72Z+VeSs9ahCvKo2QFUTHRNHVqR28= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.0 h1:nQbR1xCpkdU9Z71FI28bWTi5LrmtSVURy0UFcBVD5ZU= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.0/go.mod h1:cwk7/7SzNB2wZbCZS7w2K66klMLBe3NFM3/qd3xnsRc= +github.com/launchdarkly/go-server-sdk/v7 v7.2.0/go.mod h1:EY2ag+p9HnNXiG4pJ+y7QG2gqCYEoYD+NJgwkhmUUqk= github.com/launchdarkly/go-test-helpers/v2 v2.2.0 h1:L3kGILP/6ewikhzhdNkHy1b5y4zs50LueWenVF0sBbs= github.com/launchdarkly/go-test-helpers/v2 v2.2.0/go.mod h1:L7+th5govYp5oKU9iN7To5PgznBuIjBPn+ejqKR0avw= github.com/launchdarkly/go-test-helpers/v3 v3.0.2 h1:rh0085g1rVJM5qIukdaQ8z1XTWZztbJ49vRZuveqiuU= From 8c9d5292295b0ec14c1d133d76343aea7d32a1d7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 2 Apr 2024 09:11:38 -0700 Subject: [PATCH 14/31] Change ldmodule path --- ldotel/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ldotel/go.mod b/ldotel/go.mod index 611f39d7..65f5459b 100644 --- a/ldotel/go.mod +++ b/ldotel/go.mod @@ -1,4 +1,4 @@ -module ldotel +module github.com/launchdarkly/go-server-sdk/ldotel go 1.21 From f493f4621e01d43b74d95ca2c0309ffdc2a01247 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 2 Apr 2024 09:41:50 -0700 Subject: [PATCH 15/31] Remove blank line. --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index 8a3d7807..621e5a9a 100644 --- a/Makefile +++ b/Makefile @@ -109,7 +109,6 @@ go.work: workspace-clean: rm -f $(GO_WORK_FILE) $(GO_WORK_SUM) - build-contract-tests: @go build -o ./testservice/testservice ./testservice From 51f15d5d8530e3c008824848df67d7d26eb33b1c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 14:12:24 -0700 Subject: [PATCH 16/31] Update version of go server SDK used. --- ldotel/go.mod | 2 +- ldotel/go.sum | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ldotel/go.mod b/ldotel/go.mod index 65f5459b..b5ad4ebb 100644 --- a/ldotel/go.mod +++ b/ldotel/go.mod @@ -4,7 +4,7 @@ go 1.21 require ( github.com/launchdarkly/go-sdk-common/v3 v3.1.0 - github.com/launchdarkly/go-server-sdk/v7 v7.2.0 + github.com/launchdarkly/go-server-sdk/v7 v7.3.0 github.com/stretchr/testify v1.8.4 go.opentelemetry.io/otel v1.24.0 go.opentelemetry.io/otel/sdk v1.24.0 diff --git a/ldotel/go.sum b/ldotel/go.sum index 88fb3ab0..a7c03822 100644 --- a/ldotel/go.sum +++ b/ldotel/go.sum @@ -34,7 +34,8 @@ github.com/launchdarkly/go-semver v1.0.2 h1:sYVRnuKyvxlmQCnCUyDkAhtmzSFRoX6rG2Xa github.com/launchdarkly/go-semver v1.0.2/go.mod h1:xFmMwXba5Mb+3h72Z+VeSs9ahCvKo2QFUTHRNHVqR28= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.0 h1:nQbR1xCpkdU9Z71FI28bWTi5LrmtSVURy0UFcBVD5ZU= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.0/go.mod h1:cwk7/7SzNB2wZbCZS7w2K66klMLBe3NFM3/qd3xnsRc= -github.com/launchdarkly/go-server-sdk/v7 v7.2.0/go.mod h1:EY2ag+p9HnNXiG4pJ+y7QG2gqCYEoYD+NJgwkhmUUqk= +github.com/launchdarkly/go-server-sdk/v7 v7.3.0 h1:blc8npHPjhXGs2NU68YSKby6Xkxp16aDSObLt3W5Qww= +github.com/launchdarkly/go-server-sdk/v7 v7.3.0/go.mod h1:EY2ag+p9HnNXiG4pJ+y7QG2gqCYEoYD+NJgwkhmUUqk= github.com/launchdarkly/go-test-helpers/v2 v2.2.0 h1:L3kGILP/6ewikhzhdNkHy1b5y4zs50LueWenVF0sBbs= github.com/launchdarkly/go-test-helpers/v2 v2.2.0/go.mod h1:L7+th5govYp5oKU9iN7To5PgznBuIjBPn+ejqKR0avw= github.com/launchdarkly/go-test-helpers/v3 v3.0.2 h1:rh0085g1rVJM5qIukdaQ8z1XTWZztbJ49vRZuveqiuU= From 7d9913741ec3e13f19d8a21782fd76082b2d19e3 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 14:29:45 -0700 Subject: [PATCH 17/31] Update makefile to work regardless of workspace. --- Makefile | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 621e5a9a..f9a52d31 100644 --- a/Makefile +++ b/Makefile @@ -50,13 +50,31 @@ sdk-lint: $(LINTER) run ./... ldotel: - go build ./ldotel + @if [ -f go.work ]; then \ + echo "Building ldotel with workspace" \ + go build ./ldotel; \ + else \ + echo "Building ldotel without workspace" \ + cd ldotel && go build .; \ + fi ldotel-test: - go test -v -race ./ldotel + @if [ -f go.work ]; then \ + echo "Testing ldotel with workspace" \ + go test -v -race ./ldotel; \ + else \ + echo "Testing ldotel without workspace" \ + cd ldotel && go test -v -race .; \ + fi ldotel-lint: - $(LINTER) run ./ldotel + @if [ -f go.work ]; then \ + echo "Linting ldotel with workspace" \ + $(LINTER) run ./ldotel; \ + else \ + echo "Linting ldotel without workspace" \ + cd ldotel && $(LINTER) run .; \ + fi test-coverage: $(COVERAGE_PROFILE_RAW) go run github.com/launchdarkly-labs/go-coverage-enforcer@latest $(COVERAGE_ENFORCER_FLAGS) -outprofile $(COVERAGE_PROFILE_FILTERED) $(COVERAGE_PROFILE_RAW) From b21bd619bdba109df6e33fd7d4466e5800348710 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 14:45:31 -0700 Subject: [PATCH 18/31] Attempt at CI. --- .github/actions/unit-tests/action.yml | 3 +++ .github/workflows/ci.yml | 4 +++- .github/workflows/common_ci.yml | 10 ++++++++- .github/workflows/ldotel-ci.yml | 29 +++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ldotel-ci.yml diff --git a/.github/actions/unit-tests/action.yml b/.github/actions/unit-tests/action.yml index 623a1313..1f4c3088 100644 --- a/.github/actions/unit-tests/action.yml +++ b/.github/actions/unit-tests/action.yml @@ -5,6 +5,9 @@ inputs: description: 'Whether to run linters.' required: false default: 'false' + test-target: + description: 'The test target to run.' + required: true runs: using: composite diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49885396..9f607140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Build and Test +name: Build and Test SDK on: push: branches: [ 'v7', 'feat/**' ] @@ -25,6 +25,8 @@ jobs: uses: ./.github/workflows/common_ci.yml with: go-version: ${{ matrix.go-version }} + test-target: sdk-test + contract-tests: true test-windows: name: ${{ format('Windows, Go {0}', matrix.go-version) }} diff --git a/.github/workflows/common_ci.yml b/.github/workflows/common_ci.yml index 0c959f3f..530a699e 100644 --- a/.github/workflows/common_ci.yml +++ b/.github/workflows/common_ci.yml @@ -6,7 +6,14 @@ on: description: "Go version to use for the jobs." required: true type: string - + test-target: + type: string + description: 'The test target to run.' + required: true + contract-tests: + type: boolean + description: 'True to run the contract tests' + required: true jobs: unit-test-and-coverage: @@ -21,6 +28,7 @@ jobs: - uses: ./.github/actions/unit-tests with: lint: 'true' + test-target: ${{ inputs.test-target }} - uses: ./.github/actions/coverage with: enforce: 'false' diff --git a/.github/workflows/ldotel-ci.yml b/.github/workflows/ldotel-ci.yml new file mode 100644 index 00000000..71b00f40 --- /dev/null +++ b/.github/workflows/ldotel-ci.yml @@ -0,0 +1,29 @@ +name: Build and Test ldotel +on: + push: + branches: [ 'v7', 'feat/**' ] + paths-ignore: + - '**.md' # Don't run CI on markdown changes. + pull_request: + branches: [ 'v7', 'feat/**' ] + paths-ignore: + - '**.md' + +jobs: + go-versions: + uses: ./.github/workflows/go-versions.yml + + # Runs the common tasks (unit tests, lint, contract tests) for each Go version. + test-linux: + name: ${{ format('Linux, Go {0}', matrix.go-version) }} + needs: go-versions + strategy: + # Let jobs fail independently, in case it's a single version that's broken. + fail-fast: false + matrix: + go-version: ${{ fromJSON(needs.go-versions.outputs.matrix) }} + uses: ./.github/workflows/common_ci.yml + with: + go-version: ${{ matrix.go-version }} + test-target: ldotel-test + contract-tests: true From d9a04934114b22bab2737a5ec49a58e3b31b445c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 14:46:39 -0700 Subject: [PATCH 19/31] No contract tests for ldotel. --- .github/workflows/ldotel-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ldotel-ci.yml b/.github/workflows/ldotel-ci.yml index 71b00f40..4960dcb5 100644 --- a/.github/workflows/ldotel-ci.yml +++ b/.github/workflows/ldotel-ci.yml @@ -26,4 +26,4 @@ jobs: with: go-version: ${{ matrix.go-version }} test-target: ldotel-test - contract-tests: true + contract-tests: false From 57056a9ef7ef08451fa8c7e8e16d502058a2f549 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 14:57:01 -0700 Subject: [PATCH 20/31] Conditions. --- .github/actions/unit-tests/action.yml | 2 +- .github/workflows/common_ci.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/actions/unit-tests/action.yml b/.github/actions/unit-tests/action.yml index 1f4c3088..e5fa7984 100644 --- a/.github/actions/unit-tests/action.yml +++ b/.github/actions/unit-tests/action.yml @@ -22,7 +22,7 @@ runs: - name: Test shell: bash id: test - run: make test | tee raw_report.txt + run: make ${{ inputs.test-target }} | tee raw_report.txt - name: Process test results if: steps.test.outcome == 'success' diff --git a/.github/workflows/common_ci.yml b/.github/workflows/common_ci.yml index 530a699e..2d228a29 100644 --- a/.github/workflows/common_ci.yml +++ b/.github/workflows/common_ci.yml @@ -36,6 +36,7 @@ jobs: contract-tests: runs-on: ubuntu-latest name: 'Contract Tests' + if: inputs.contract-tests env: TEST_SERVICE_PORT: 8000 steps: From cad7c92b3a95f199e57d5122aafa19d1642c35d3 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:47:07 -0700 Subject: [PATCH 21/31] Split testing. --- .github/workflows/ci.yml | 1 - .github/workflows/common_ci.yml | 13 +------------ .github/workflows/ldotel-ci.yml | 21 +++++++++++++++------ 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f607140..ef6f95d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,6 @@ jobs: with: go-version: ${{ matrix.go-version }} test-target: sdk-test - contract-tests: true test-windows: name: ${{ format('Windows, Go {0}', matrix.go-version) }} diff --git a/.github/workflows/common_ci.yml b/.github/workflows/common_ci.yml index 2d228a29..f0e4e4bb 100644 --- a/.github/workflows/common_ci.yml +++ b/.github/workflows/common_ci.yml @@ -6,14 +6,6 @@ on: description: "Go version to use for the jobs." required: true type: string - test-target: - type: string - description: 'The test target to run.' - required: true - contract-tests: - type: boolean - description: 'True to run the contract tests' - required: true jobs: unit-test-and-coverage: @@ -28,7 +20,7 @@ jobs: - uses: ./.github/actions/unit-tests with: lint: 'true' - test-target: ${{ inputs.test-target }} + test-target: sdk-test - uses: ./.github/actions/coverage with: enforce: 'false' @@ -36,7 +28,6 @@ jobs: contract-tests: runs-on: ubuntu-latest name: 'Contract Tests' - if: inputs.contract-tests env: TEST_SERVICE_PORT: 8000 steps: @@ -60,8 +51,6 @@ jobs: name: Contract-test-service-logs-${{ steps.go-version.outputs.version }} path: /tmp/sdk-contract-test-service.log - - benchmarks: name: 'Benchmarks' runs-on: ubuntu-latest diff --git a/.github/workflows/ldotel-ci.yml b/.github/workflows/ldotel-ci.yml index 4960dcb5..8300ce22 100644 --- a/.github/workflows/ldotel-ci.yml +++ b/.github/workflows/ldotel-ci.yml @@ -1,4 +1,4 @@ -name: Build and Test ldotel +name: Build and Test SDK on: push: branches: [ 'v7', 'feat/**' ] @@ -22,8 +22,17 @@ jobs: fail-fast: false matrix: go-version: ${{ fromJSON(needs.go-versions.outputs.matrix) }} - uses: ./.github/workflows/common_ci.yml - with: - go-version: ${{ matrix.go-version }} - test-target: ldotel-test - contract-tests: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Go ${{ inputs.go-version }} + uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.go-version }} + - uses: ./.github/actions/unit-tests + with: + lint: 'true' + test-target: ldotel-test + - uses: ./.github/actions/coverage + with: + enforce: 'false' \ No newline at end of file From f7455cfa20212b6ca9078f19dfd345e0337ec82d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:47:30 -0700 Subject: [PATCH 22/31] Fix name --- .github/workflows/ldotel-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ldotel-ci.yml b/.github/workflows/ldotel-ci.yml index 8300ce22..08052277 100644 --- a/.github/workflows/ldotel-ci.yml +++ b/.github/workflows/ldotel-ci.yml @@ -1,4 +1,4 @@ -name: Build and Test SDK +name: Build and Test ldotel on: push: branches: [ 'v7', 'feat/**' ] From e5a7bc9bea01229b1c735e801901d2a97759c6c9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:48:19 -0700 Subject: [PATCH 23/31] Cleanup name. --- .github/workflows/ldotel-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ldotel-ci.yml b/.github/workflows/ldotel-ci.yml index 08052277..de002b63 100644 --- a/.github/workflows/ldotel-ci.yml +++ b/.github/workflows/ldotel-ci.yml @@ -15,7 +15,7 @@ jobs: # Runs the common tasks (unit tests, lint, contract tests) for each Go version. test-linux: - name: ${{ format('Linux, Go {0}', matrix.go-version) }} + name: ${{ format('ldotel Linux, Go {0}', matrix.go-version) }} needs: go-versions strategy: # Let jobs fail independently, in case it's a single version that's broken. From 7199460674bd372f5313dd2788bbfd9a9080e389 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:50:05 -0700 Subject: [PATCH 24/31] Fix base CI workflow. --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef6f95d9..d958bccd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,6 @@ jobs: uses: ./.github/workflows/common_ci.yml with: go-version: ${{ matrix.go-version }} - test-target: sdk-test test-windows: name: ${{ format('Windows, Go {0}', matrix.go-version) }} From ac70cab957c603bb74974ae6ea0484756e7d1bcb Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:52:48 -0700 Subject: [PATCH 25/31] Add readme. --- ldotel/README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 ldotel/README.md diff --git a/ldotel/README.md b/ldotel/README.md new file mode 100644 index 00000000..bef50dcc --- /dev/null +++ b/ldotel/README.md @@ -0,0 +1,56 @@ +LaunchDarkly Server-side OTEL library for Go +============================================== +[![Actions Status](https://github.com/launchdarkly/go-server-sdk/actions/workflows/ldotel-ci.yml/badge.svg?branch=v7)](https://github.com/launchdarkly/go-server-sdk/actions/workflows/ci.yml) + +LaunchDarkly overview +------------------------- +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions of feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! + +[![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) + +Getting started +----------- + +Import the module: + +```go +import ( + "github.com/launchdarkly/go-server-sdk/ldotel" +) +``` + +Configure the LaunchDarkly client to use a tracing hook: + +```go +client, _ = ld.MakeCustomClient("your-sdk-key", +ld.Config{ + Hooks: []ldhooks.Hook{ldotel.NewTracingHook()}, +}, 5*time.Second) +``` + +Learn more +----------- + +Read our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [reference guide for the ruby SDK](http://docs.launchdarkly.com/docs/ruby-sdk-reference). + +Generated API documentation for all versions of the library is on [RubyDoc.info](https://www.rubydoc.info/gems/launchdarkly-server-sdk-otel). The API documentation for the latest version is also on [GitHub Pages](https://launchdarkly.github.io/ruby-server-sdk-otel). + +Contributing +------------ + +We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this library. + +About LaunchDarkly +----------- + +* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. +* Explore LaunchDarkly + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation + * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates From ec7d52dc31f86290e1868d8946c38cf140ee0e15 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:56:50 -0700 Subject: [PATCH 26/31] Issue templates --- .github/ISSUE_TEMPLATE/bug_report--ldotel.md | 37 +++++++++++++++++++ .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .../ISSUE_TEMPLATE/feature_request--ldotel.md | 20 ++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 2 +- 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report--ldotel.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request--ldotel.md diff --git a/.github/ISSUE_TEMPLATE/bug_report--ldotel.md b/.github/ISSUE_TEMPLATE/bug_report--ldotel.md new file mode 100644 index 00000000..4e50b2f7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report--ldotel.md @@ -0,0 +1,37 @@ +--- +name: Bug report for the ldotel module +about: Create a report to help us improve +title: '' +labels: 'ldotel, enhancement' +assignees: '' + +--- + +**Is this a support request?** +This issue tracker is maintained by LaunchDarkly SDK developers and is intended for feedback on the SDK code. If you're not sure whether the problem you are having is specifically related to the SDK, or to the LaunchDarkly service overall, it may be more appropriate to contact the LaunchDarkly support team; they can help to investigate the problem and will consult the SDK team if necessary. You can submit a support request by going [here](https://support.launchdarkly.com/hc/en-us/requests/new) or by emailing support@launchdarkly.com. + +Note that issues filed on this issue tracker are publicly accessible. Do not provide any private account information on your issues. If your problem is specific to your account, you should submit a support request as described above. + +**Describe the bug** +A clear and concise description of what the bug is. + +**To reproduce** +Steps to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +If applicable, add any log output related to your problem. + +**SDK version** +The version of this SDK that you are using. + +**Language version, developer tools** +For instance, Go 1.11 or Ruby 2.5.3. If you are using a language that requires a separate compiler, such as C, please include the name and version of the compiler too. + +**OS/platform** +For instance, Ubuntu 16.04, Windows 10, or Android 4.0.3. If your code is running in a browser, please also include the browser type and version. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index a9c8db85..6ad037e9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,7 +2,7 @@ name: Bug report about: Create a report to help us improve title: '' -labels: '' +labels: 'server-sdk, bug' assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/feature_request--ldotel.md b/.github/ISSUE_TEMPLATE/feature_request--ldotel.md new file mode 100644 index 00000000..435960c9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request--ldotel.md @@ -0,0 +1,20 @@ +--- +name: Feature request for the ldotel module +about: Suggest an idea for this project +title: '' +labels: 'ldotel, enhancement' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I would love to see the SDK [...does something new...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 3f7d5bf3..4c3878f6 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,7 +2,7 @@ name: Feature request about: Suggest an idea for this project title: '' -labels: '' +labels: 'server-sdk, enhancement' assignees: '' --- From 0c7461f162f556d818285a238cb5bb9b04a5ccf9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:00:58 -0700 Subject: [PATCH 27/31] Remove extra links. --- .github/workflows/ldotel-ci.yml | 2 +- ldotel/README.md | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ldotel-ci.yml b/.github/workflows/ldotel-ci.yml index de002b63..0657f9f7 100644 --- a/.github/workflows/ldotel-ci.yml +++ b/.github/workflows/ldotel-ci.yml @@ -35,4 +35,4 @@ jobs: test-target: ldotel-test - uses: ./.github/actions/coverage with: - enforce: 'false' \ No newline at end of file + enforce: 'false' diff --git a/ldotel/README.md b/ldotel/README.md index bef50dcc..eeb02f66 100644 --- a/ldotel/README.md +++ b/ldotel/README.md @@ -31,9 +31,7 @@ ld.Config{ Learn more ----------- -Read our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [reference guide for the ruby SDK](http://docs.launchdarkly.com/docs/ruby-sdk-reference). - -Generated API documentation for all versions of the library is on [RubyDoc.info](https://www.rubydoc.info/gems/launchdarkly-server-sdk-otel). The API documentation for the latest version is also on [GitHub Pages](https://launchdarkly.github.io/ruby-server-sdk-otel). +Read our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. Contributing ------------ From beb9a75b62bede1430a986fdd570e702964071d2 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:05:45 -0700 Subject: [PATCH 28/31] Unique test result files. --- .github/actions/unit-tests/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/unit-tests/action.yml b/.github/actions/unit-tests/action.yml index e5fa7984..41eb1e8e 100644 --- a/.github/actions/unit-tests/action.yml +++ b/.github/actions/unit-tests/action.yml @@ -34,5 +34,5 @@ runs: if: steps.process-test.outcome == 'success' uses: actions/upload-artifact@v4 with: - name: Test-result-${{ steps.go-version.outputs.version }} + name: Test-result-${{ inputs.test-target }}${{ steps.go-version.outputs.version }} path: junit_report.xml From eeae3e59516314cb0c0223e9ca6f32be7d5d4d2e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:35:19 -0700 Subject: [PATCH 29/31] Go version from matrix. --- .github/workflows/ldotel-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ldotel-ci.yml b/.github/workflows/ldotel-ci.yml index 0657f9f7..98706523 100644 --- a/.github/workflows/ldotel-ci.yml +++ b/.github/workflows/ldotel-ci.yml @@ -28,7 +28,7 @@ jobs: - name: Setup Go ${{ inputs.go-version }} uses: actions/setup-go@v5 with: - go-version: ${{ inputs.go-version }} + go-version: ${{ matrix.go-version }} - uses: ./.github/actions/unit-tests with: lint: 'true' From 509a5e0d5ce8c2aa366549cf1e3dd3a4a889b451 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 10 Apr 2024 13:15:03 -0700 Subject: [PATCH 30/31] Update .github/ISSUE_TEMPLATE/bug_report--ldotel.md Co-authored-by: Casey Waldren --- .github/ISSUE_TEMPLATE/bug_report--ldotel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report--ldotel.md b/.github/ISSUE_TEMPLATE/bug_report--ldotel.md index 4e50b2f7..b8b05edb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report--ldotel.md +++ b/.github/ISSUE_TEMPLATE/bug_report--ldotel.md @@ -28,7 +28,7 @@ If applicable, add any log output related to your problem. The version of this SDK that you are using. **Language version, developer tools** -For instance, Go 1.11 or Ruby 2.5.3. If you are using a language that requires a separate compiler, such as C, please include the name and version of the compiler too. +For instance, Go 1.22. **OS/platform** For instance, Ubuntu 16.04, Windows 10, or Android 4.0.3. If your code is running in a browser, please also include the browser type and version. From c07f7a20337c22a69b5f6c21d4068c6d22f89f58 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 10 Apr 2024 13:15:13 -0700 Subject: [PATCH 31/31] Update .github/ISSUE_TEMPLATE/bug_report--ldotel.md Co-authored-by: Casey Waldren --- .github/ISSUE_TEMPLATE/bug_report--ldotel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report--ldotel.md b/.github/ISSUE_TEMPLATE/bug_report--ldotel.md index b8b05edb..bd51435e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report--ldotel.md +++ b/.github/ISSUE_TEMPLATE/bug_report--ldotel.md @@ -31,7 +31,7 @@ The version of this SDK that you are using. For instance, Go 1.22. **OS/platform** -For instance, Ubuntu 16.04, Windows 10, or Android 4.0.3. If your code is running in a browser, please also include the browser type and version. +For instance, Ubuntu 16.04, or Windows 10. **Additional context** Add any other context about the problem here.