Skip to content

Commit

Permalink
feat: Implement Tracking in Go (#297)
Browse files Browse the repository at this point in the history
* Implement Tracking in Go

Signed-off-by: Tran Dinh Loc <[email protected]>
Signed-off-by: Todd Baert <[email protected]>
Co-authored-by: Todd Baert <[email protected]>
  • Loading branch information
dinhlockt02 and toddbaert authored Nov 5, 2024
1 parent dc66ed3 commit dee5ec7
Show file tree
Hide file tree
Showing 11 changed files with 398 additions and 2 deletions.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,27 @@ value, err := client.BooleanValue(
)
```

### Tracking

The [tracking API](https://openfeature.dev/specification/sections/tracking/) allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations.
This is essential for robust experimentation powered by feature flags.
For example, a flag enhancing the appearance of a UI component might drive user engagement to a new feature; to test this hypothesis, telemetry collected by a [hook](#hooks) or [provider](#providers) can be associated with telemetry reported in the client's `track` function.

```go
// initilize a client
client := openfeature.NewClient('my-app')

// trigger tracking event action
client.Track(
context.Background(),
'visited-promo-page',
openfeature.EvaluationContext{},
openfeature.NewTrackingEventDetails(99.77).Add("currencyCode", "USD"),
)
```

Note that some providers may not support tracking; check the documentation for your provider for more information.

### Logging

Note that in accordance with the OpenFeature specification, the SDK doesn't generally log messages during flag evaluation.
Expand Down
29 changes: 29 additions & 0 deletions openfeature/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,35 @@ func (c *Client) Object(ctx context.Context, flag string, defaultValue interface
return value
}

// Track performs an action for tracking for occurrence of a particular action or application state.
//
// Parameters:
// - ctx is the standard go context struct used to manage requests (e.g. timeouts)
// - trackingEventName is the event name to track
// - evalCtx is the evaluation context used in a flag evaluation (not to be confused with ctx)
// - trackingEventDetails defines optional data pertinent to a particular
func (c *Client) Track(ctx context.Context, trackingEventName string, evalCtx EvaluationContext, details TrackingEventDetails) {
provider, evalCtx := c.forTracking(ctx, evalCtx)
provider.Track(ctx, trackingEventName, evalCtx, details)
}

// forTracking return the TrackingHandler and the combination of EvaluationContext from api, transaction, client and invocation.
//
// The returned evaluation context MUST be merged in the order, with duplicate values being overwritten:
// - API (global; lowest precedence)
// - transaction
// - client
// - invocation (highest precedence)
func (c *Client) forTracking(ctx context.Context, evalCtx EvaluationContext) (Tracker, EvaluationContext) {
provider, _, apiCtx := c.api.ForEvaluation(c.metadata.name)
evalCtx = mergeContexts(evalCtx, c.evaluationContext, TransactionContext(ctx), apiCtx)
trackingProvider, ok := provider.(Tracker)
if !ok {
trackingProvider = NoopProvider{}
}
return trackingProvider, evalCtx
}

func (c *Client) evaluate(
ctx context.Context, flag string, flagType Type, defaultValue interface{}, evalCtx EvaluationContext, options EvaluationOptions,
) (InterfaceEvaluationDetails, error) {
Expand Down
18 changes: 18 additions & 0 deletions openfeature/client_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,21 @@ func ExampleClient_Object() {

// Output: map[foo:bar]
}

func ExampleClient_Track() {
ctx := context.Background()
client := openfeature.NewClient("example-client")

evaluationContext := openfeature.EvaluationContext{}

// example tracking event recording that a subject reached a page associated with a business goal
client.Track(ctx, "visited-promo-page", evaluationContext, openfeature.TrackingEventDetails{})

// example tracking event recording that a subject performed an action associated with a business goal, with the tracking event details having a particular numeric value
client.Track(ctx, "clicked-checkout", evaluationContext, openfeature.NewTrackingEventDetails(99.77))

// example tracking event recording that a subject performed an action associated with a business goal, with the tracking event details having a particular numeric value
client.Track(ctx, "clicked-checkout", evaluationContext, openfeature.NewTrackingEventDetails(99.77).Add("currencyCode", "USD"))

// Output:
}
147 changes: 147 additions & 0 deletions openfeature/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,153 @@ func TestRequirement_1_4_13(t *testing.T) {
// The `client` SHOULD transform the `evaluation context` using the `provider's` `context transformer` function
// if one is defined, before passing the result of the transformation to the provider's flag resolution functions.

// TestRequirement_6_1 tests the 6.1.1 and 6.1.2 requirements by asserting that the returned client matches the interface
// defined by the 6.1.1 and 6.1.2 requirements

// Requirement_6_1_1
// The `client` MUST define a function for tracking the occurrence of a particular action or application state,
// with parameters `tracking event name` (string, required), `evaluation context` (optional) and `tracking event details` (optional),
// which returns nothing.

// Requirement_6_1_2
// The `client` MUST define a function for tracking the occurrence of a particular action or application state,
// with parameters `tracking event name` (string, required) and `tracking event details` (optional), which returns nothing.
func TestRequirement_6_1(t *testing.T) {
client := NewClient("test-client")

type requirements interface {
Track(ctx context.Context, trackingEventName string, evalCtx EvaluationContext, details TrackingEventDetails)
}

var clientI interface{} = client
if _, ok := clientI.(requirements); !ok {
t.Error("client returned by NewClient doesn't implement the 1.6.* requirements interface")
}
}

// Requirement_6_1_3
// The evaluation context passed to the provider's track function MUST be merged in the order, with duplicate values being overwritten:
// - API (global; lowest precedence)
// - transaction
// - client
// - invocation (highest precedence)

// Requirement_6_1_4
// If the client's `track` function is called and the associated provider does not implement tracking, the client's `track` function MUST no-op.
// Allow backward compatible to non-Tracker Provider
func TestTrack(t *testing.T) {
type inputCtx struct {
api EvaluationContext
txn EvaluationContext
client EvaluationContext
invocation EvaluationContext
}

type testcase struct {
inCtx inputCtx
eventName string
outCtx EvaluationContext
// allow asserting the input to provider
provider func(tc *testcase, ctrl *gomock.Controller) FeatureProvider
}

// mockTrackingProvider is a feature provider that implements tracker contract.
type mockTrackingProvider struct {
*MockTracker
*MockFeatureProvider
}

tests := map[string]*testcase{
"merging in correct order": {
eventName: "example-event",
inCtx: inputCtx{
api: EvaluationContext{
attributes: map[string]interface{}{
"1": "api",
"2": "api",
"3": "api",
"4": "api",
},
},
txn: EvaluationContext{
attributes: map[string]interface{}{
"2": "txn",
"3": "txn",
"4": "txn",
},
},
client: EvaluationContext{
attributes: map[string]interface{}{
"3": "client",
"4": "client",
},
},
invocation: EvaluationContext{
attributes: map[string]interface{}{
"4": "invocation",
},
},
},
outCtx: EvaluationContext{
attributes: map[string]interface{}{
"1": "api",
"2": "txn",
"3": "client",
"4": "invocation",
},
},
provider: func(tc *testcase, ctrl *gomock.Controller) FeatureProvider {
provider := &mockTrackingProvider{
MockTracker: NewMockTracker(ctrl),
MockFeatureProvider: NewMockFeatureProvider(ctrl),
}

provider.MockFeatureProvider.EXPECT().Metadata().AnyTimes()
// assert if Track is called once with evalCtx expected
provider.MockTracker.EXPECT().Track(gomock.Any(), tc.eventName, tc.outCtx, TrackingEventDetails{}).Times(1)

return provider
},
},
"do no-op if Provider do not implement Tracker": {
inCtx: inputCtx{},
eventName: "example-event",
outCtx: EvaluationContext{},
provider: func(tc *testcase, ctrl *gomock.Controller) FeatureProvider {
provider := NewMockFeatureProvider(ctrl)

provider.EXPECT().Metadata().AnyTimes()

return provider
},
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
// arrange
ctrl := gomock.NewController(t)
provider := test.provider(test, ctrl)
client := NewClient("test-client")

// use different api in this client to avoid racing when changing global context
client.api = newEvaluationAPI(newEventExecutor())

client.api.SetEvaluationContext(test.inCtx.api)
_ = client.api.SetProviderAndWait(provider)

client.evaluationContext = test.inCtx.client
ctx := WithTransactionContext(context.Background(), test.inCtx.txn)

// action
client.Track(ctx, test.eventName, test.inCtx.invocation, TrackingEventDetails{})

// assert
ctrl.Finish()
})
}
}

func TestFlattenContext(t *testing.T) {
tests := map[string]struct {
inCtx EvaluationContext
Expand Down
6 changes: 6 additions & 0 deletions openfeature/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,16 @@ type IClient interface {
Object(ctx context.Context, flag string, defaultValue interface{}, evalCtx EvaluationContext, options ...Option) interface{}

IEventing
ITracking
}

// IEventing defines the OpenFeature eventing contract
type IEventing interface {
AddHandler(eventType EventType, callback EventCallback)
RemoveHandler(eventType EventType, callback EventCallback)
}

// ITracking defines the Tracking contract
type ITracking interface {
Track(ctx context.Context, trackingEventName string, evalCtx EvaluationContext, details TrackingEventDetails)
}
20 changes: 18 additions & 2 deletions openfeature/memprovider/in_memory_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ const (
)

type InMemoryProvider struct {
flags map[string]InMemoryFlag
flags map[string]InMemoryFlag
trackingEvents map[string][]InMemoryEvent
}

func NewInMemoryProvider(from map[string]InMemoryFlag) InMemoryProvider {
return InMemoryProvider{
flags: from,
flags: from,
trackingEvents: map[string][]InMemoryEvent{},
}
}

Expand Down Expand Up @@ -130,6 +132,14 @@ func (i InMemoryProvider) Hooks() []openfeature.Hook {
return []openfeature.Hook{}
}

func (i InMemoryProvider) Track(ctx context.Context, trackingEventName string, evalCtx openfeature.EvaluationContext, details openfeature.TrackingEventDetails) {
i.trackingEvents[trackingEventName] = append(i.trackingEvents[trackingEventName], InMemoryEvent{
Value: details.Value(),
Data: details.Attributes(),
ContextAttributes: evalCtx.Attributes(),
})
}

func (i InMemoryProvider) find(flag string) (*InMemoryFlag, *openfeature.ProviderResolutionDetail, bool) {
memoryFlag, ok := i.flags[flag]
if !ok {
Expand Down Expand Up @@ -199,3 +209,9 @@ func (flag *InMemoryFlag) Resolve(defaultValue interface{}, evalCtx openfeature.
Variant: flag.DefaultVariant,
}
}

type InMemoryEvent struct {
Value float64
Data map[string]interface{}
ContextAttributes map[string]interface{}
}
5 changes: 5 additions & 0 deletions openfeature/memprovider/in_memory_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,8 @@ func TestInMemoryProvider_Metadata(t *testing.T) {
t.Errorf("incorrect name for in-memory provider")
}
}

func TestInMemoryProvider_Track(t *testing.T) {
memoryProvider := NewInMemoryProvider(map[string]InMemoryFlag{})
memoryProvider.Track(context.Background(), "example-event-name", openfeature.EvaluationContext{}, openfeature.TrackingEventDetails{})
}
3 changes: 3 additions & 0 deletions openfeature/noop_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,6 @@ func (e NoopProvider) ObjectEvaluation(ctx context.Context, flag string, default
func (e NoopProvider) Hooks() []Hook {
return []Hook{}
}

func (e NoopProvider) Track(ctx context.Context, eventName string, evalCtx EvaluationContext, details TrackingEventDetails) {
}
58 changes: 58 additions & 0 deletions openfeature/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ type StateHandler interface {
Status() State
}

// Tracker is the contract for tracking
// FeatureProvider can opt in for this behavior by implementing the interface
type Tracker interface {
Track(ctx context.Context, trackingEventName string, evaluationContext EvaluationContext, details TrackingEventDetails)
}

// NoopStateHandler is a noop StateHandler implementation
// Status always set to ReadyState to comply with specification
type NoopStateHandler struct {
Expand Down Expand Up @@ -190,3 +196,55 @@ type InterfaceResolutionDetail struct {
type Metadata struct {
Name string
}

// TrackingEventDetails provides a tracking details with float64 value
type TrackingEventDetails struct {
value float64
attributes map[string]interface{}
}

// NewTrackingEventDetails return TrackingEventDetails associated with numeric value value
func NewTrackingEventDetails(value float64) TrackingEventDetails {
return TrackingEventDetails{
value: value,
attributes: make(map[string]interface{}),
}
}

// Add insert new key-value pair into TrackingEventDetails and return the TrackingEventDetails itself.
// If the key already exists in TrackingEventDetails, it will be replaced.
//
// Usage: trackingEventDetails.Add('active-time', 2).Add('unit': 'seconds')
func (t TrackingEventDetails) Add(key string, value interface{}) TrackingEventDetails {
t.attributes[key] = value
return t
}

// Attributes return a map contains the key-value pairs stored in TrackingEventDetails.
func (t TrackingEventDetails) Attributes() map[string]interface{} {
// copy fields to new map to prevent mutation (maps are passed by reference)
fields := make(map[string]interface{}, len(t.attributes))
for key, value := range t.attributes {
fields[key] = value
}
return fields
}

// Attribute retrieves the attribute with the given key.
func (t TrackingEventDetails) Attribute(key string) interface{} {
return t.attributes[key]
}

// Copy return a new TrackingEventDetails with new value.
// It will copy details of old TrackingEventDetails into the new one to ensure the immutability.
func (t TrackingEventDetails) Copy(value float64) TrackingEventDetails {
return TrackingEventDetails{
value: value,
attributes: t.Attributes(),
}
}

// Value retrieves the value of TrackingEventDetails.
func (t TrackingEventDetails) Value() float64 {
return t.value
}
Loading

0 comments on commit dee5ec7

Please sign in to comment.