Skip to content

Commit

Permalink
feat: add TransactionContext (#283)
Browse files Browse the repository at this point in the history
Signed-off-by: Kevin Schoonover <[email protected]>
  • Loading branch information
kevinschoonover authored Sep 9, 2024
1 parent ad9db29 commit 788151d
Show file tree
Hide file tree
Showing 5 changed files with 148 additions and 11 deletions.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ See [here](https://pkg.go.dev/github.com/open-feature/go-sdk/pkg/openfeature) fo
|| [Domains](#domains) | Logically bind clients with providers.|
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
|| [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) |
|| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |

<sub>Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌</sub>
Expand Down Expand Up @@ -250,6 +251,29 @@ import "github.com/open-feature/go-sdk/openfeature"
openfeature.Shutdown()
```


### Transaction Context Propagation

Transaction context is a container for transaction-specific evaluation context (e.g. user id, user agent, IP).
Transaction context can be set where specific data is available (e.g. an auth service or request handler), and by using the transaction context propagator, it will automatically be applied to all flag evaluations within a transaction (e.g. a request or thread).

```go
import "github.com/open-feature/go-sdk/openfeature"

// set the TransactionContext
ctx := openfeature.WithTransactionContext(context.Background(), openfeature.EvaluationContext{})

// get the TransactionContext from a context
ec := openfeature.TransactionContext(ctx)

// merge an EvaluationContext with the existing TransactionContext, preferring
// the context that is passed to MergeTransactionContext
tCtx := openfeature.MergeTransactionContext(ctx, openfeature.EvaluationContext{})

// use TransactionContext in a flag evaluation
client.BooleanValue(tCtx, ....)
```

## Extending

### Develop a provider
Expand Down
2 changes: 1 addition & 1 deletion openfeature/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,7 @@ func (c *Client) evaluate(
// ensure that the same provider & hooks are used across this transaction to avoid unexpected behaviour
provider, globalHooks, globalCtx := c.api.ForEvaluation(c.metadata.name)

evalCtx = mergeContexts(evalCtx, c.evaluationContext, globalCtx) // API (global) -> client -> invocation
evalCtx = mergeContexts(evalCtx, c.evaluationContext, TransactionContext(ctx), globalCtx) // API (global) -> transaction -> client -> invocation
apiClientInvocationProviderHooks := append(append(append(globalHooks, c.hooks...), options.hooks...), provider.Hooks()...) // API, Client, Invocation, Provider
providerInvocationClientApiHooks := append(append(append(provider.Hooks(), options.hooks...), c.hooks...), globalHooks...) // Provider, Invocation, Client, API

Expand Down
39 changes: 39 additions & 0 deletions openfeature/evaluation_context.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package openfeature

import (
"context"

"github.com/open-feature/go-sdk/openfeature/internal"
)

// EvaluationContext provides ambient information for the purposes of flag evaluation
// The use of the constructor, NewEvaluationContext, is enforced to set EvaluationContext's fields in order
// to enforce immutability.
Expand Down Expand Up @@ -53,3 +59,36 @@ func NewEvaluationContext(targetingKey string, attributes map[string]interface{}
func NewTargetlessEvaluationContext(attributes map[string]interface{}) EvaluationContext {
return NewEvaluationContext("", attributes)
}

// NewTransactionContext constructs a TransactionContext
//
// ctx - the context to embed the EvaluationContext in
// ec - the EvaluationContext to embed into the context
func WithTransactionContext(ctx context.Context, ec EvaluationContext) context.Context {
return context.WithValue(ctx, internal.TransactionContext, ec)
}

// MergeTransactionContext merges the provided EvaluationContext with the current TransactionContext (if it exists)
//
// ctx - the context to pull existing TransactionContext from
// ec - the EvaluationContext to merge with the existing TransactionContext
func MergeTransactionContext(ctx context.Context, ec EvaluationContext) context.Context {
oldTc := TransactionContext(ctx)
mergedTc := mergeContexts(ec, oldTc)
return WithTransactionContext(ctx, mergedTc)
}

// TransactionContext extracts a EvaluationContext from the current
// golang.org/x/net/context. if no EvaluationContext exist, it will construct
// an empty EvaluationContext
//
// ctx - the context to pull EvaluationContext from
func TransactionContext(ctx context.Context) EvaluationContext {
ec, ok := ctx.Value(internal.TransactionContext).(EvaluationContext)

if !ok {
return EvaluationContext{}
}

return ec
}
84 changes: 74 additions & 10 deletions openfeature/evaluation_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

"github.com/golang/mock/gomock"
"github.com/open-feature/go-sdk/openfeature/internal"
)

// The `evaluation context` structure MUST define an optional `targeting key` field of type string,
Expand Down Expand Up @@ -83,7 +84,7 @@ func TestRequirement_3_2_1(t *testing.T) {
})
}

// Evaluation context MUST be merged in the order: API (global) - client - invocation,
// Evaluation context MUST be merged in the order: API (global) - transaction - client - invocation,
// with duplicate values being overwritten.
func TestRequirement_3_2_2(t *testing.T) {
defer t.Cleanup(initSingleton)
Expand All @@ -93,12 +94,22 @@ func TestRequirement_3_2_2(t *testing.T) {
targetingKey: "API",
attributes: map[string]interface{}{
"invocationEvalCtx": true,
"foo": 2,
"user": 2,
"foo": 3,
"user": 3,
},
}
SetEvaluationContext(apiEvalCtx)

transactionEvalCtx := EvaluationContext{
targetingKey: "Transcation",
attributes: map[string]interface{}{
"transactionEvalCtx": true,
"foo": 2,
"user": 2,
},
}
transactionCtx := WithTransactionContext(context.Background(), transactionEvalCtx)

mockProvider := NewMockFeatureProvider(ctrl)
mockProvider.EXPECT().Metadata().AnyTimes()

Expand Down Expand Up @@ -130,21 +141,21 @@ func TestRequirement_3_2_2(t *testing.T) {
expectedMergedEvalCtx := EvaluationContext{
targetingKey: "Client",
attributes: map[string]interface{}{
"apiEvalCtx": true,
"invocationEvalCtx": true,
"clientEvalCtx": true,
"foo": "bar",
"user": 1,
"apiEvalCtx": true,
"transactionEvalCtx": true,
"invocationEvalCtx": true,
"clientEvalCtx": true,
"foo": "bar",
"user": 1,
},
}
flatCtx := flattenContext(expectedMergedEvalCtx)
mockProvider.EXPECT().StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), flatCtx)

_, err = client.StringValue(context.Background(), "foo", "bar", invocationEvalCtx)
_, err = client.StringValue(transactionCtx, "foo", "bar", invocationEvalCtx)
if err != nil {
t.Error(err)
}

}

func TestEvaluationContext_AttributesNotPassedByReference(t *testing.T) {
Expand All @@ -160,6 +171,18 @@ func TestEvaluationContext_AttributesNotPassedByReference(t *testing.T) {
}
}

func TestRequirement_3_3_1(t *testing.T) {
t.Run("The API MUST have a method for setting the evaluation context of the transaction context propagator for the current transaction.", func(t *testing.T) {
ctx := context.Background()
ctx = WithTransactionContext(ctx, EvaluationContext{})
val, ok := ctx.Value(internal.TransactionContext).(EvaluationContext)

if !ok {
t.Fatalf("failed to find transcation context set from WithTransactionContext: %v", val)
}
})
}

func TestEvaluationContext_AttributesFuncNotPassedByReference(t *testing.T) {
evalCtx := NewEvaluationContext("foo", map[string]interface{}{
"foo": "bar",
Expand All @@ -186,3 +209,44 @@ func TestNewTargetlessEvaluationContext(t *testing.T) {
t.Errorf("we expect no difference in the attributes")
}
}

func TestMergeTransactionContext(t *testing.T) {
oldEvalCtx := NewEvaluationContext("old", map[string]interface{}{
"old": true,
"overwrite": "old",
})
newEvalCtx := NewEvaluationContext("new", map[string]interface{}{
"new": true,
"overwrite": "new",
})

ctx := WithTransactionContext(context.Background(), oldEvalCtx)
ctx = MergeTransactionContext(ctx, newEvalCtx)

expectedMergedEvalCtx := EvaluationContext{
targetingKey: "new",
attributes: map[string]interface{}{
"old": true,
"new": true,
"overwrite": "new",
},
}

finalTransactionContext := TransactionContext(ctx)

if finalTransactionContext.targetingKey != expectedMergedEvalCtx.targetingKey {
t.Errorf(
"targetingKey is not expected value, finalTransactionContext.targetingKey: %s, newEvalCtx.targetingKey: %s",
finalTransactionContext.TargetingKey(),
expectedMergedEvalCtx.TargetingKey(),
)
}

if !reflect.DeepEqual(finalTransactionContext.Attributes(), expectedMergedEvalCtx.Attributes()) {
t.Errorf(
"attributes are not expected value, finalTransactionContext.Attributes(): %v, expectedMergedEvalCtx.Attributes(): %v",
finalTransactionContext.Attributes(),
expectedMergedEvalCtx.Attributes(),
)
}
}
10 changes: 10 additions & 0 deletions openfeature/internal/context_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package internal

// ContextKey is just an empty struct. It exists so TransactionContext can be
// an immutable public variable with a unique type. It's immutable
// because nobody else can create a ContextKey, being unexported.
type ContextKey struct{}

// TransactionContext is the context key to use with golang.org/x/net/context's
// WithValue function to associate an EvaluationContext value with a context.
var TransactionContext ContextKey

0 comments on commit 788151d

Please sign in to comment.