Repository: open-feature/go-sdk (go)

Issue:
[FEATURE] Implement Tracking in Go
Implement tracking as per spec: https://github.com/open-feature/spec/commit/cd99c3560a68c1810971be19b87ea803fb9b0b3a

Functional requirements: 

- implement all the features described [here](https://github.com/open-feature/spec/commit/cd99c3560a68c1810971be19b87ea803fb9b0b3a)

Non functional requirements:

- use as many existing abstractions and implementations as possible
  - ie: `tracking event details` should likely use similar underlying functionality to evaluation context / structure
- provider interface(s) are modified to _optionally_ support tracking; not all providers will support it
- associated testing and README entry (see markdown snippet below)
- no breaking changes

README addition (be careful to correctly link to #hooks and #providers and update the code snippet for the language in question

```md
### Tracking

The tracking API 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.

{example-language-in-question}

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

Pull request:
feat: Implement Tracking in Go
<!-- Please use this template for your pull request. -->
<!-- Please use the sections that you need and delete other sections -->

## This PR
<!-- add the description of the PR here -->

- Implement Tracking in Go

### Related Issues
<!-- add here the GitHub issue that this PR resolves if applicable -->

Fixes #291

### Notes

### Follow-up Tasks
<!-- anything that is related to this PR but not done here should be noted under this section -->
<!-- if there is a need for a new issue, please link it here -->

### How to test
<!-- if applicable, add testing instructions under this section -->
View example in client_example_test.go


Patch:
diff --git a/README.md b/README.md
index 50c4766..6f45007 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/openfeature/client.go b/openfeature/client.go
index d44bc43..011d65a 100644
--- a/openfeature/client.go
+++ b/openfeature/client.go
@@ -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) {
diff --git a/openfeature/interfaces.go b/openfeature/interfaces.go
index ac9b462..c08ffeb 100644
--- a/openfeature/interfaces.go
+++ b/openfeature/interfaces.go
@@ -41,6 +41,7 @@ type IClient interface {
 	Object(ctx context.Context, flag string, defaultValue interface{}, evalCtx EvaluationContext, options ...Option) interface{}
 
 	IEventing
+	ITracking
 }
 
 // IEventing defines the OpenFeature eventing contract
@@ -48,3 +49,8 @@ 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)
+}
diff --git a/openfeature/memprovider/in_memory_provider.go b/openfeature/memprovider/in_memory_provider.go
index 3f833f2..7ce75dd 100644
--- a/openfeature/memprovider/in_memory_provider.go
+++ b/openfeature/memprovider/in_memory_provider.go
@@ -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{},
 	}
 }
 
@@ -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 {
@@ -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{}
+}
diff --git a/openfeature/noop_provider.go b/openfeature/noop_provider.go
index 12a7255..415fd21 100644
--- a/openfeature/noop_provider.go
+++ b/openfeature/noop_provider.go
@@ -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) {
+}
diff --git a/openfeature/provider.go b/openfeature/provider.go
index 634b23b..ef16920 100644
--- a/openfeature/provider.go
+++ b/openfeature/provider.go
@@ -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 {
@@ -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
+}


Test patch:
diff --git a/openfeature/client_example_test.go b/openfeature/client_example_test.go
index f72290e..73921c9 100644
--- a/openfeature/client_example_test.go
+++ b/openfeature/client_example_test.go
@@ -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:
+}
diff --git a/openfeature/client_test.go b/openfeature/client_test.go
index 1d7e4a9..c882abe 100644
--- a/openfeature/client_test.go
+++ b/openfeature/client_test.go
@@ -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
diff --git a/openfeature/memprovider/in_memory_provider_test.go b/openfeature/memprovider/in_memory_provider_test.go
index f0aec0b..2fbb79b 100644
--- a/openfeature/memprovider/in_memory_provider_test.go
+++ b/openfeature/memprovider/in_memory_provider_test.go
@@ -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{})
+}
diff --git a/openfeature/provider_mock_test.go b/openfeature/provider_mock_test.go
index d1994e1..a79b409 100644
--- a/openfeature/provider_mock_test.go
+++ b/openfeature/provider_mock_test.go
@@ -195,6 +195,41 @@ func (mr *MockStateHandlerMockRecorder) Status() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockStateHandler)(nil).Status))
 }
 
+// MockTracker is a mock of Tracker interface.
+type MockTracker struct {
+	ctrl     *gomock.Controller
+	recorder *MockTrackerMockRecorder
+}
+
+// MockTrackerMockRecorder is the mock recorder for MockTracker.
+type MockTrackerMockRecorder struct {
+	mock *MockTracker
+}
+
+// NewMockTracker creates a new mock instance.
+func NewMockTracker(ctrl *gomock.Controller) *MockTracker {
+	mock := &MockTracker{ctrl: ctrl}
+	mock.recorder = &MockTrackerMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockTracker) EXPECT() *MockTrackerMockRecorder {
+	return m.recorder
+}
+
+// Track mocks base method.
+func (m *MockTracker) Track(ctx context.Context, trackingEventName string, evaluationContext EvaluationContext, details TrackingEventDetails) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "Track", ctx, trackingEventName, evaluationContext, details)
+}
+
+// Track indicates an expected call of Track.
+func (mr *MockTrackerMockRecorder) Track(ctx, trackingEventName, evaluationContext, details interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Track", reflect.TypeOf((*MockTracker)(nil).Track), ctx, trackingEventName, evaluationContext, details)
+}
+
 // MockEventHandler is a mock of EventHandler interface.
 type MockEventHandler struct {
 	ctrl     *gomock.Controller
diff --git a/openfeature/provider_test.go b/openfeature/provider_test.go
index 2dbddd3..6935588 100644
--- a/openfeature/provider_test.go
+++ b/openfeature/provider_test.go
@@ -60,3 +60,61 @@ func TestRequirement_2_2_1(t *testing.T) {
 // including boolean, numeric, string, and structure.
 //
 // Is satisfied by TestRequirement_2_2.
+
+func TestTrackingEventDetails_Copy(t *testing.T) {
+	tests := map[string]struct {
+		inputDetails TrackingEventDetails
+		copiedValue  float64
+		outputDetail TrackingEventDetails
+	}{
+		"copied correctly": {
+			inputDetails: NewTrackingEventDetails(1).Add("foo", "bar"),
+			copiedValue:  2,
+			outputDetail: NewTrackingEventDetails(2).Add("foo", "bar"),
+		},
+	}
+
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			copiedDetails := tc.inputDetails.Copy(tc.copiedValue)
+
+			if !reflect.DeepEqual(copiedDetails, tc.outputDetail) {
+				t.Errorf("copied value %v doesn't match the expected value  %v", copiedDetails, tc.outputDetail)
+			}
+		})
+	}
+}
+
+func TestTrackingEventDetails_Add(t *testing.T) {
+	type dummyStruct struct {
+		qux string
+	}
+
+	tests := map[string]struct {
+		inputDetails TrackingEventDetails
+		addKeyPair   map[string]interface{}
+	}{
+		"added correctly": {
+			inputDetails: NewTrackingEventDetails(1),
+			addKeyPair: map[string]interface{}{
+				"foo": "bar",
+				"baz": 1,
+				"qux": dummyStruct{
+					qux: "qux",
+				},
+			},
+		},
+	}
+
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			for key, value := range tc.addKeyPair {
+				tc.inputDetails.Add(key, value)
+			}
+
+			if !reflect.DeepEqual(tc.inputDetails.Attributes(), tc.addKeyPair) {
+				t.Errorf("added key-pair %v not match with input key-pair %v", tc.addKeyPair, tc.inputDetails.Attributes())
+			}
+		})
+	}
+}