Skip to content

Commit dee5ec7

Browse files
feat: Implement Tracking in Go (#297)
* 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]>
1 parent dc66ed3 commit dee5ec7

11 files changed

+398
-2
lines changed

README.md

+21
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,27 @@ value, err := client.BooleanValue(
173173
)
174174
```
175175

176+
### Tracking
177+
178+
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.
179+
This is essential for robust experimentation powered by feature flags.
180+
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.
181+
182+
```go
183+
// initilize a client
184+
client := openfeature.NewClient('my-app')
185+
186+
// trigger tracking event action
187+
client.Track(
188+
context.Background(),
189+
'visited-promo-page',
190+
openfeature.EvaluationContext{},
191+
openfeature.NewTrackingEventDetails(99.77).Add("currencyCode", "USD"),
192+
)
193+
```
194+
195+
Note that some providers may not support tracking; check the documentation for your provider for more information.
196+
176197
### Logging
177198

178199
Note that in accordance with the OpenFeature specification, the SDK doesn't generally log messages during flag evaluation.

openfeature/client.go

+29
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,35 @@ func (c *Client) Object(ctx context.Context, flag string, defaultValue interface
641641
return value
642642
}
643643

644+
// Track performs an action for tracking for occurrence of a particular action or application state.
645+
//
646+
// Parameters:
647+
// - ctx is the standard go context struct used to manage requests (e.g. timeouts)
648+
// - trackingEventName is the event name to track
649+
// - evalCtx is the evaluation context used in a flag evaluation (not to be confused with ctx)
650+
// - trackingEventDetails defines optional data pertinent to a particular
651+
func (c *Client) Track(ctx context.Context, trackingEventName string, evalCtx EvaluationContext, details TrackingEventDetails) {
652+
provider, evalCtx := c.forTracking(ctx, evalCtx)
653+
provider.Track(ctx, trackingEventName, evalCtx, details)
654+
}
655+
656+
// forTracking return the TrackingHandler and the combination of EvaluationContext from api, transaction, client and invocation.
657+
//
658+
// The returned evaluation context MUST be merged in the order, with duplicate values being overwritten:
659+
// - API (global; lowest precedence)
660+
// - transaction
661+
// - client
662+
// - invocation (highest precedence)
663+
func (c *Client) forTracking(ctx context.Context, evalCtx EvaluationContext) (Tracker, EvaluationContext) {
664+
provider, _, apiCtx := c.api.ForEvaluation(c.metadata.name)
665+
evalCtx = mergeContexts(evalCtx, c.evaluationContext, TransactionContext(ctx), apiCtx)
666+
trackingProvider, ok := provider.(Tracker)
667+
if !ok {
668+
trackingProvider = NoopProvider{}
669+
}
670+
return trackingProvider, evalCtx
671+
}
672+
644673
func (c *Client) evaluate(
645674
ctx context.Context, flag string, flagType Type, defaultValue interface{}, evalCtx EvaluationContext, options EvaluationOptions,
646675
) (InterfaceEvaluationDetails, error) {

openfeature/client_example_test.go

+18
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,21 @@ func ExampleClient_Object() {
129129

130130
// Output: map[foo:bar]
131131
}
132+
133+
func ExampleClient_Track() {
134+
ctx := context.Background()
135+
client := openfeature.NewClient("example-client")
136+
137+
evaluationContext := openfeature.EvaluationContext{}
138+
139+
// example tracking event recording that a subject reached a page associated with a business goal
140+
client.Track(ctx, "visited-promo-page", evaluationContext, openfeature.TrackingEventDetails{})
141+
142+
// 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
143+
client.Track(ctx, "clicked-checkout", evaluationContext, openfeature.NewTrackingEventDetails(99.77))
144+
145+
// 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
146+
client.Track(ctx, "clicked-checkout", evaluationContext, openfeature.NewTrackingEventDetails(99.77).Add("currencyCode", "USD"))
147+
148+
// Output:
149+
}

openfeature/client_test.go

+147
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,153 @@ func TestRequirement_1_4_13(t *testing.T) {
764764
// The `client` SHOULD transform the `evaluation context` using the `provider's` `context transformer` function
765765
// if one is defined, before passing the result of the transformation to the provider's flag resolution functions.
766766

767+
// TestRequirement_6_1 tests the 6.1.1 and 6.1.2 requirements by asserting that the returned client matches the interface
768+
// defined by the 6.1.1 and 6.1.2 requirements
769+
770+
// Requirement_6_1_1
771+
// The `client` MUST define a function for tracking the occurrence of a particular action or application state,
772+
// with parameters `tracking event name` (string, required), `evaluation context` (optional) and `tracking event details` (optional),
773+
// which returns nothing.
774+
775+
// Requirement_6_1_2
776+
// The `client` MUST define a function for tracking the occurrence of a particular action or application state,
777+
// with parameters `tracking event name` (string, required) and `tracking event details` (optional), which returns nothing.
778+
func TestRequirement_6_1(t *testing.T) {
779+
client := NewClient("test-client")
780+
781+
type requirements interface {
782+
Track(ctx context.Context, trackingEventName string, evalCtx EvaluationContext, details TrackingEventDetails)
783+
}
784+
785+
var clientI interface{} = client
786+
if _, ok := clientI.(requirements); !ok {
787+
t.Error("client returned by NewClient doesn't implement the 1.6.* requirements interface")
788+
}
789+
}
790+
791+
// Requirement_6_1_3
792+
// The evaluation context passed to the provider's track function MUST be merged in the order, with duplicate values being overwritten:
793+
// - API (global; lowest precedence)
794+
// - transaction
795+
// - client
796+
// - invocation (highest precedence)
797+
798+
// Requirement_6_1_4
799+
// If the client's `track` function is called and the associated provider does not implement tracking, the client's `track` function MUST no-op.
800+
// Allow backward compatible to non-Tracker Provider
801+
func TestTrack(t *testing.T) {
802+
type inputCtx struct {
803+
api EvaluationContext
804+
txn EvaluationContext
805+
client EvaluationContext
806+
invocation EvaluationContext
807+
}
808+
809+
type testcase struct {
810+
inCtx inputCtx
811+
eventName string
812+
outCtx EvaluationContext
813+
// allow asserting the input to provider
814+
provider func(tc *testcase, ctrl *gomock.Controller) FeatureProvider
815+
}
816+
817+
// mockTrackingProvider is a feature provider that implements tracker contract.
818+
type mockTrackingProvider struct {
819+
*MockTracker
820+
*MockFeatureProvider
821+
}
822+
823+
tests := map[string]*testcase{
824+
"merging in correct order": {
825+
eventName: "example-event",
826+
inCtx: inputCtx{
827+
api: EvaluationContext{
828+
attributes: map[string]interface{}{
829+
"1": "api",
830+
"2": "api",
831+
"3": "api",
832+
"4": "api",
833+
},
834+
},
835+
txn: EvaluationContext{
836+
attributes: map[string]interface{}{
837+
"2": "txn",
838+
"3": "txn",
839+
"4": "txn",
840+
},
841+
},
842+
client: EvaluationContext{
843+
attributes: map[string]interface{}{
844+
"3": "client",
845+
"4": "client",
846+
},
847+
},
848+
invocation: EvaluationContext{
849+
attributes: map[string]interface{}{
850+
"4": "invocation",
851+
},
852+
},
853+
},
854+
outCtx: EvaluationContext{
855+
attributes: map[string]interface{}{
856+
"1": "api",
857+
"2": "txn",
858+
"3": "client",
859+
"4": "invocation",
860+
},
861+
},
862+
provider: func(tc *testcase, ctrl *gomock.Controller) FeatureProvider {
863+
provider := &mockTrackingProvider{
864+
MockTracker: NewMockTracker(ctrl),
865+
MockFeatureProvider: NewMockFeatureProvider(ctrl),
866+
}
867+
868+
provider.MockFeatureProvider.EXPECT().Metadata().AnyTimes()
869+
// assert if Track is called once with evalCtx expected
870+
provider.MockTracker.EXPECT().Track(gomock.Any(), tc.eventName, tc.outCtx, TrackingEventDetails{}).Times(1)
871+
872+
return provider
873+
},
874+
},
875+
"do no-op if Provider do not implement Tracker": {
876+
inCtx: inputCtx{},
877+
eventName: "example-event",
878+
outCtx: EvaluationContext{},
879+
provider: func(tc *testcase, ctrl *gomock.Controller) FeatureProvider {
880+
provider := NewMockFeatureProvider(ctrl)
881+
882+
provider.EXPECT().Metadata().AnyTimes()
883+
884+
return provider
885+
},
886+
},
887+
}
888+
889+
for name, test := range tests {
890+
t.Run(name, func(t *testing.T) {
891+
// arrange
892+
ctrl := gomock.NewController(t)
893+
provider := test.provider(test, ctrl)
894+
client := NewClient("test-client")
895+
896+
// use different api in this client to avoid racing when changing global context
897+
client.api = newEvaluationAPI(newEventExecutor())
898+
899+
client.api.SetEvaluationContext(test.inCtx.api)
900+
_ = client.api.SetProviderAndWait(provider)
901+
902+
client.evaluationContext = test.inCtx.client
903+
ctx := WithTransactionContext(context.Background(), test.inCtx.txn)
904+
905+
// action
906+
client.Track(ctx, test.eventName, test.inCtx.invocation, TrackingEventDetails{})
907+
908+
// assert
909+
ctrl.Finish()
910+
})
911+
}
912+
}
913+
767914
func TestFlattenContext(t *testing.T) {
768915
tests := map[string]struct {
769916
inCtx EvaluationContext

openfeature/interfaces.go

+6
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,16 @@ type IClient interface {
4141
Object(ctx context.Context, flag string, defaultValue interface{}, evalCtx EvaluationContext, options ...Option) interface{}
4242

4343
IEventing
44+
ITracking
4445
}
4546

4647
// IEventing defines the OpenFeature eventing contract
4748
type IEventing interface {
4849
AddHandler(eventType EventType, callback EventCallback)
4950
RemoveHandler(eventType EventType, callback EventCallback)
5051
}
52+
53+
// ITracking defines the Tracking contract
54+
type ITracking interface {
55+
Track(ctx context.Context, trackingEventName string, evalCtx EvaluationContext, details TrackingEventDetails)
56+
}

openfeature/memprovider/in_memory_provider.go

+18-2
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ const (
1313
)
1414

1515
type InMemoryProvider struct {
16-
flags map[string]InMemoryFlag
16+
flags map[string]InMemoryFlag
17+
trackingEvents map[string][]InMemoryEvent
1718
}
1819

1920
func NewInMemoryProvider(from map[string]InMemoryFlag) InMemoryProvider {
2021
return InMemoryProvider{
21-
flags: from,
22+
flags: from,
23+
trackingEvents: map[string][]InMemoryEvent{},
2224
}
2325
}
2426

@@ -130,6 +132,14 @@ func (i InMemoryProvider) Hooks() []openfeature.Hook {
130132
return []openfeature.Hook{}
131133
}
132134

135+
func (i InMemoryProvider) Track(ctx context.Context, trackingEventName string, evalCtx openfeature.EvaluationContext, details openfeature.TrackingEventDetails) {
136+
i.trackingEvents[trackingEventName] = append(i.trackingEvents[trackingEventName], InMemoryEvent{
137+
Value: details.Value(),
138+
Data: details.Attributes(),
139+
ContextAttributes: evalCtx.Attributes(),
140+
})
141+
}
142+
133143
func (i InMemoryProvider) find(flag string) (*InMemoryFlag, *openfeature.ProviderResolutionDetail, bool) {
134144
memoryFlag, ok := i.flags[flag]
135145
if !ok {
@@ -199,3 +209,9 @@ func (flag *InMemoryFlag) Resolve(defaultValue interface{}, evalCtx openfeature.
199209
Variant: flag.DefaultVariant,
200210
}
201211
}
212+
213+
type InMemoryEvent struct {
214+
Value float64
215+
Data map[string]interface{}
216+
ContextAttributes map[string]interface{}
217+
}

openfeature/memprovider/in_memory_provider_test.go

+5
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,8 @@ func TestInMemoryProvider_Metadata(t *testing.T) {
260260
t.Errorf("incorrect name for in-memory provider")
261261
}
262262
}
263+
264+
func TestInMemoryProvider_Track(t *testing.T) {
265+
memoryProvider := NewInMemoryProvider(map[string]InMemoryFlag{})
266+
memoryProvider.Track(context.Background(), "example-event-name", openfeature.EvaluationContext{}, openfeature.TrackingEventDetails{})
267+
}

openfeature/noop_provider.go

+3
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,6 @@ func (e NoopProvider) ObjectEvaluation(ctx context.Context, flag string, default
7070
func (e NoopProvider) Hooks() []Hook {
7171
return []Hook{}
7272
}
73+
74+
func (e NoopProvider) Track(ctx context.Context, eventName string, evalCtx EvaluationContext, details TrackingEventDetails) {
75+
}

openfeature/provider.go

+58
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ type StateHandler interface {
6666
Status() State
6767
}
6868

69+
// Tracker is the contract for tracking
70+
// FeatureProvider can opt in for this behavior by implementing the interface
71+
type Tracker interface {
72+
Track(ctx context.Context, trackingEventName string, evaluationContext EvaluationContext, details TrackingEventDetails)
73+
}
74+
6975
// NoopStateHandler is a noop StateHandler implementation
7076
// Status always set to ReadyState to comply with specification
7177
type NoopStateHandler struct {
@@ -190,3 +196,55 @@ type InterfaceResolutionDetail struct {
190196
type Metadata struct {
191197
Name string
192198
}
199+
200+
// TrackingEventDetails provides a tracking details with float64 value
201+
type TrackingEventDetails struct {
202+
value float64
203+
attributes map[string]interface{}
204+
}
205+
206+
// NewTrackingEventDetails return TrackingEventDetails associated with numeric value value
207+
func NewTrackingEventDetails(value float64) TrackingEventDetails {
208+
return TrackingEventDetails{
209+
value: value,
210+
attributes: make(map[string]interface{}),
211+
}
212+
}
213+
214+
// Add insert new key-value pair into TrackingEventDetails and return the TrackingEventDetails itself.
215+
// If the key already exists in TrackingEventDetails, it will be replaced.
216+
//
217+
// Usage: trackingEventDetails.Add('active-time', 2).Add('unit': 'seconds')
218+
func (t TrackingEventDetails) Add(key string, value interface{}) TrackingEventDetails {
219+
t.attributes[key] = value
220+
return t
221+
}
222+
223+
// Attributes return a map contains the key-value pairs stored in TrackingEventDetails.
224+
func (t TrackingEventDetails) Attributes() map[string]interface{} {
225+
// copy fields to new map to prevent mutation (maps are passed by reference)
226+
fields := make(map[string]interface{}, len(t.attributes))
227+
for key, value := range t.attributes {
228+
fields[key] = value
229+
}
230+
return fields
231+
}
232+
233+
// Attribute retrieves the attribute with the given key.
234+
func (t TrackingEventDetails) Attribute(key string) interface{} {
235+
return t.attributes[key]
236+
}
237+
238+
// Copy return a new TrackingEventDetails with new value.
239+
// It will copy details of old TrackingEventDetails into the new one to ensure the immutability.
240+
func (t TrackingEventDetails) Copy(value float64) TrackingEventDetails {
241+
return TrackingEventDetails{
242+
value: value,
243+
attributes: t.Attributes(),
244+
}
245+
}
246+
247+
// Value retrieves the value of TrackingEventDetails.
248+
func (t TrackingEventDetails) Value() float64 {
249+
return t.value
250+
}

0 commit comments

Comments
 (0)