Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: Implement otel tracing hook. #130

Merged
merged 33 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a54c2d1
feat: Implement supporting types for hooks. (#125)
kinyoklion Mar 28, 2024
f93d96c
feat: Add support for hooks. (#126)
kinyoklion Mar 28, 2024
1c56855
feat: Implement otel tracing hook.
kinyoklion Mar 28, 2024
b735744
chore(refactor): Remove AddHooks, refactor execution, add contract te…
kinyoklion Mar 29, 2024
84f86f0
Merge branch 'feat/hooks' into rlamb/implement-otel-hook
kinyoklion Mar 29, 2024
150d09e
Loop based targets and update contributing.
kinyoklion Mar 29, 2024
4ab744a
Add comment to makefile
kinyoklion Mar 29, 2024
b041730
Support all targets in makefile.
kinyoklion Apr 1, 2024
152107d
Update .PHONY
kinyoklion Apr 1, 2024
722f7b2
Add experimental text.
kinyoklion Apr 1, 2024
745c08c
Add use separate releases.
kinyoklion Apr 1, 2024
b8ec2b6
Tidy
kinyoklion Apr 1, 2024
b749180
Reformat example.
kinyoklion Apr 1, 2024
7806865
Re-add clean.
kinyoklion Apr 2, 2024
8c9d529
Change ldmodule path
kinyoklion Apr 2, 2024
f493f46
Remove blank line.
kinyoklion Apr 2, 2024
78d794c
Merge branch 'v7' into rlamb/implement-otel-hook
kinyoklion Apr 3, 2024
51f15d5
Update version of go server SDK used.
kinyoklion Apr 3, 2024
7d99137
Update makefile to work regardless of workspace.
kinyoklion Apr 3, 2024
b21bd61
Attempt at CI.
kinyoklion Apr 3, 2024
d9a0493
No contract tests for ldotel.
kinyoklion Apr 3, 2024
57056a9
Conditions.
kinyoklion Apr 3, 2024
cad7c92
Split testing.
kinyoklion Apr 3, 2024
f7455cf
Fix name
kinyoklion Apr 3, 2024
e5a7bc9
Cleanup name.
kinyoklion Apr 3, 2024
7199460
Fix base CI workflow.
kinyoklion Apr 3, 2024
ac70cab
Add readme.
kinyoklion Apr 3, 2024
ec7d52d
Issue templates
kinyoklion Apr 3, 2024
0c7461f
Remove extra links.
kinyoklion Apr 3, 2024
beb9a75
Unique test result files.
kinyoklion Apr 3, 2024
eeae3e5
Go version from matrix.
kinyoklion Apr 3, 2024
509a5e0
Update .github/ISSUE_TEMPLATE/bug_report--ldotel.md
kinyoklion Apr 10, 2024
c07f7a2
Update .github/ISSUE_TEMPLATE/bug_report--ldotel.md
kinyoklion Apr 10, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ go-server-sdk.test
allocations.out
.idea
.vscode
go.work
go.work.sum
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ COVERAGE_ENFORCER_FLAGS=-package github.com/launchdarkly/go-server-sdk/v7 \

kinyoklion marked this conversation as resolved.
Show resolved Hide resolved
build:
go build ./...
go build ./ldotel

clean:
go clean
Expand All @@ -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)
Expand Down Expand Up @@ -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
kinyoklion marked this conversation as resolved.
Show resolved Hide resolved

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
Expand Down
9 changes: 9 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
}
62 changes: 62 additions & 0 deletions internal/hooks/iterator.go
Original file line number Diff line number Diff line change
@@ -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
}
63 changes: 63 additions & 0 deletions internal/hooks/iterator_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
})
}
}
2 changes: 2 additions & 0 deletions internal/hooks/package_info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package hooks is an internal package containing implementations to run hooks.
package hooks
141 changes: 141 additions & 0 deletions internal/hooks/runner.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading