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

Issue:
[FEATURE] Implement Tracking in Java SDK
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 description:
feat: implemented tracking as per spec
<!-- 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 -->

- adds support for Tracking as per Spec

### Related Issues
<!-- add here the GitHub issue that this PR resolves if applicable -->

Fixes #1164




Patch:
diff --git a/README.md b/README.md
index c710de6..a277600 100644
--- a/README.md
+++ b/README.md
@@ -120,17 +120,18 @@ See [here](https://javadoc.io/doc/dev.openfeature/sdk/latest/) for the Javadocs.
 
 ## 🌟 Features
 
-| Status | Features                                                              | Description                                                                                                                                                   |
-| ------ |-----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| ✅     | [Providers](#providers)                                               | Integrate with a commercial, open source, or in-house feature management tool.                                                                                |
-| ✅     | [Targeting](#targeting)                                               | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context).                            |
-| ✅     | [Hooks](#hooks)                                                       | Add functionality to various stages of the flag evaluation life-cycle.                                                                                        |
-| ✅     | [Logging](#logging)                                                   | Integrate with popular logging packages.                                                                                                                      |
-| ✅     | [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.                                                                                                           |
+| Status | Features                                                            | Description                                                                                                                                                   |
+| ------ |---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| ✅     | [Providers](#providers)                                             | Integrate with a commercial, open source, or in-house feature management tool.                                                                                |
+| ✅     | [Targeting](#targeting)                                             | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context).                            |
+| ✅     | [Hooks](#hooks)                                                     | Add functionality to various stages of the flag evaluation life-cycle.                                                                                        |
+| ✅     | [Tracking](#tracking)                                               | Associate user actions with feature flag evaluations.                                                                                                         |
+| ✅     | [Logging](#logging)                                                 | Integrate with popular logging packages.                                                                                                                      |
+| ✅     | [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>
 
@@ -215,6 +216,16 @@ Once you've added a hook as a dependency, it can be registered at the global, cl
           FlagEvaluationOptions.builder().hook(new ExampleHook()).build());
 ```
 
+### Tracking
+
+The [tracking API](https://openfeature.dev/specification/sections/tracking/) allows you to use OpenFeature abstractions to associate user actions with feature flag evaluations.
+This is essential for robust experimentation powered by feature flags. Note that, unlike methods that handle feature flag evaluations, calling `track(...)` may throw an `IllegalArgumentException` if an empty string is passed as the `trackingEventName`.
+
+```java
+OpenFeatureAPI api = OpenFeatureAPI.getInstance();
+api.getClient().track("visited-promo-page", new MutableTrackingEventDetails(99.77).add("currency", "USD"));
+```
+
 ### Logging
 
 The Java SDK uses SLF4J. See the [SLF4J manual](https://slf4j.org/manual.html) for complete documentation.
diff --git a/src/main/java/dev/openfeature/sdk/Client.java b/src/main/java/dev/openfeature/sdk/Client.java
index 7b41b9b..441d31e 100644
--- a/src/main/java/dev/openfeature/sdk/Client.java
+++ b/src/main/java/dev/openfeature/sdk/Client.java
@@ -5,17 +5,19 @@ import java.util.List;
 /**
  * Interface used to resolve flags of varying types.
  */
-public interface Client extends Features, EventBus<Client> {
+public interface Client extends Features, Tracking, EventBus<Client> {
     ClientMetadata getMetadata();
 
     /**
      * Return an optional client-level evaluation context.
+     *
      * @return {@link EvaluationContext}
      */
     EvaluationContext getEvaluationContext();
 
     /**
      * Set the client-level evaluation context.
+     *
      * @param ctx Client level context.
      */
     Client setEvaluationContext(EvaluationContext ctx);
@@ -30,12 +32,14 @@ public interface Client extends Features, EventBus<Client> {
 
     /**
      * Fetch the hooks associated to this client.
+     *
      * @return A list of {@link Hook}s.
      */
     List<Hook> getHooks();
 
     /**
      * Returns the current state of the associated provider.
+     *
      * @return the provider state
      */
     ProviderState getProviderState();
diff --git a/src/main/java/dev/openfeature/sdk/FeatureProvider.java b/src/main/java/dev/openfeature/sdk/FeatureProvider.java
index f73b6cd..706818e 100644
--- a/src/main/java/dev/openfeature/sdk/FeatureProvider.java
+++ b/src/main/java/dev/openfeature/sdk/FeatureProvider.java
@@ -71,4 +71,14 @@ public interface FeatureProvider {
         return ProviderState.READY;
     }
 
+    /**
+     * Feature provider implementations can opt in for to support Tracking by implementing this method.
+     *
+     * @param eventName The name of the tracking event
+     * @param context   Evaluation context used in flag evaluation (Optional)
+     * @param details   Data pertinent to a particular tracking event (Optional)
+     */
+    default void track(String eventName, EvaluationContext context, TrackingEventDetails details) {
+
+    }
 }
diff --git a/src/main/java/dev/openfeature/sdk/ImmutableTrackingEventDetails.java b/src/main/java/dev/openfeature/sdk/ImmutableTrackingEventDetails.java
new file mode 100644
index 0000000..b535bb7
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/ImmutableTrackingEventDetails.java
@@ -0,0 +1,53 @@
+package dev.openfeature.sdk;
+
+import dev.openfeature.sdk.internal.ExcludeFromGeneratedCoverageReport;
+import lombok.experimental.Delegate;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+
+
+/**
+ * ImmutableTrackingEventDetails represents data pertinent to a particular tracking event.
+ */
+public class ImmutableTrackingEventDetails implements TrackingEventDetails {
+
+    @Delegate(excludes = DelegateExclusions.class)
+    private final ImmutableStructure structure;
+
+    private final Number value;
+
+    public ImmutableTrackingEventDetails() {
+        this.value = null;
+        this.structure = new ImmutableStructure();
+    }
+
+    public ImmutableTrackingEventDetails(final Number value) {
+        this.value = value;
+        this.structure = new ImmutableStructure();
+    }
+
+    public ImmutableTrackingEventDetails(final Number value, final Map<String, Value> attributes) {
+        this.value = value;
+        this.structure = new ImmutableStructure(attributes);
+    }
+
+    /**
+     * Returns the optional tracking value.
+     */
+    public Optional<Number> getValue() {
+        return Optional.ofNullable(value);
+    }
+
+
+    @SuppressWarnings("all")
+    private static class DelegateExclusions {
+        @ExcludeFromGeneratedCoverageReport
+        public <T extends Structure> Map<String, Value> merge(Function<Map<String, Value>, Structure> newStructure,
+                                                              Map<String, Value> base,
+                                                              Map<String, Value> overriding) {
+            return null;
+        }
+    }
+}
diff --git a/src/main/java/dev/openfeature/sdk/MutableTrackingEventDetails.java b/src/main/java/dev/openfeature/sdk/MutableTrackingEventDetails.java
new file mode 100644
index 0000000..9f0de8c
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/MutableTrackingEventDetails.java
@@ -0,0 +1,94 @@
+package dev.openfeature.sdk;
+
+import dev.openfeature.sdk.internal.ExcludeFromGeneratedCoverageReport;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import lombok.experimental.Delegate;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+
+/**
+ * MutableTrackingEventDetails represents data pertinent to a particular tracking event.
+ */
+@EqualsAndHashCode
+@ToString
+public class MutableTrackingEventDetails implements TrackingEventDetails {
+
+    private final Number value;
+    @Delegate(excludes = MutableTrackingEventDetails.DelegateExclusions.class)
+    private final MutableStructure structure;
+
+    public MutableTrackingEventDetails() {
+        this.value = null;
+        this.structure = new MutableStructure();
+    }
+
+    public MutableTrackingEventDetails(final Number value) {
+        this.value = value;
+        this.structure = new MutableStructure();
+    }
+
+    /**
+     * Returns the optional tracking value.
+     */
+    public Optional<Number> getValue() {
+        return Optional.ofNullable(value);
+    }
+
+    // override @Delegate methods so that we can use "add" methods and still return MutableTrackingEventDetails,
+    // not Structure
+    public MutableTrackingEventDetails add(String key, Boolean value) {
+        this.structure.add(key, value);
+        return this;
+    }
+
+    public MutableTrackingEventDetails add(String key, String value) {
+        this.structure.add(key, value);
+        return this;
+    }
+
+    public MutableTrackingEventDetails add(String key, Integer value) {
+        this.structure.add(key, value);
+        return this;
+    }
+
+    public MutableTrackingEventDetails add(String key, Double value) {
+        this.structure.add(key, value);
+        return this;
+    }
+
+    public MutableTrackingEventDetails add(String key, Instant value) {
+        this.structure.add(key, value);
+        return this;
+    }
+
+    public MutableTrackingEventDetails add(String key, Structure value) {
+        this.structure.add(key, value);
+        return this;
+    }
+
+    public MutableTrackingEventDetails add(String key, List<Value> value) {
+        this.structure.add(key, value);
+        return this;
+    }
+
+    public MutableTrackingEventDetails add(String key, Value value) {
+        this.structure.add(key, value);
+        return this;
+    }
+
+
+    @SuppressWarnings("all")
+    private static class DelegateExclusions {
+        @ExcludeFromGeneratedCoverageReport
+        public <T extends Structure> Map<String, Value> merge(Function<Map<String, Value>, Structure> newStructure,
+                                                              Map<String, Value> base,
+                                                              Map<String, Value> overriding) {
+            return null;
+        }
+    }
+}
diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java
index f56df15..ea566e6 100644
--- a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java
+++ b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java
@@ -1,13 +1,5 @@
 package dev.openfeature.sdk;
 
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.List;
-import java.util.function.Consumer;
-
 import dev.openfeature.sdk.exceptions.ExceptionUtils;
 import dev.openfeature.sdk.exceptions.FatalError;
 import dev.openfeature.sdk.exceptions.GeneralError;
@@ -19,6 +11,15 @@ import dev.openfeature.sdk.internal.ObjectUtils;
 import lombok.Getter;
 import lombok.extern.slf4j.Slf4j;
 
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Consumer;
+
 /**
  * OpenFeature Client implementation.
  * You should not instantiate this or reference this class.
@@ -28,8 +29,8 @@ import lombok.extern.slf4j.Slf4j;
  * @deprecated // TODO: eventually we will make this non-public. See issue #872
  */
 @Slf4j
-@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.BeanMembersShouldSerialize", "PMD.UnusedLocalVariable",
-    "unchecked", "rawtypes" })
+@SuppressWarnings({"PMD.DataflowAnomalyAnalysis", "PMD.BeanMembersShouldSerialize", "PMD.UnusedLocalVariable",
+    "unchecked", "rawtypes"})
 @Deprecated() // TODO: eventually we will make this non-public. See issue #872
 public class OpenFeatureClient implements Client {
 
@@ -67,11 +68,56 @@ public class OpenFeatureClient implements Client {
         this.hookSupport = new HookSupport();
     }
 
+    /**
+     * {@inheritDoc}
+     */
     @Override
     public ProviderState getProviderState() {
         return openfeatureApi.getFeatureProviderStateManager(domain).getState();
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void track(String trackingEventName) {
+        validateTrackingEventName(trackingEventName);
+        invokeTrack(trackingEventName, null, null);
+    }
+
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void track(String trackingEventName, EvaluationContext context) {
+        validateTrackingEventName(trackingEventName);
+        Objects.requireNonNull(context);
+        invokeTrack(trackingEventName, context, null);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void track(String trackingEventName, TrackingEventDetails details) {
+        validateTrackingEventName(trackingEventName);
+        Objects.requireNonNull(details);
+        invokeTrack(trackingEventName, null, details);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void track(String trackingEventName, EvaluationContext context, TrackingEventDetails details) {
+        validateTrackingEventName(trackingEventName);
+        Objects.requireNonNull(context);
+        Objects.requireNonNull(details);
+        invokeTrack(trackingEventName, mergeEvaluationContext(context), details);
+    }
+
+
     /**
      * {@inheritDoc}
      */
@@ -115,7 +161,7 @@ public class OpenFeatureClient implements Client {
     }
 
     private <T> FlagEvaluationDetails<T> evaluateFlag(FlagValueType type, String key, T defaultValue,
-            EvaluationContext ctx, FlagEvaluationOptions options) {
+                                                      EvaluationContext ctx, FlagEvaluationOptions options) {
         FlagEvaluationOptions flagOptions = ObjectUtils.defaultIfNull(options,
                 () -> FlagEvaluationOptions.builder().build());
         Map<String, Object> hints = Collections.unmodifiableMap(flagOptions.getHookHints());
@@ -183,6 +229,19 @@ public class OpenFeatureClient implements Client {
         details.setReason(Reason.ERROR.toString());
     }
 
+    private static void validateTrackingEventName(String str) {
+        Objects.requireNonNull(str);
+        if (str.isEmpty()) {
+            throw new IllegalArgumentException("trackingEventName cannot be empty");
+        }
+    }
+
+    private void invokeTrack(String trackingEventName, EvaluationContext context, TrackingEventDetails details) {
+        openfeatureApi.getFeatureProviderStateManager(domain)
+                .getProvider()
+                .track(trackingEventName, mergeEvaluationContext(context), details);
+    }
+
     /**
      * Merge invocation contexts with API, transaction and client contexts.
      * Does not merge before context.
@@ -244,7 +303,7 @@ public class OpenFeatureClient implements Client {
 
     @Override
     public Boolean getBooleanValue(String key, Boolean defaultValue, EvaluationContext ctx,
-            FlagEvaluationOptions options) {
+                                   FlagEvaluationOptions options) {
         return getBooleanDetails(key, defaultValue, ctx, options).getValue();
     }
 
@@ -260,7 +319,7 @@ public class OpenFeatureClient implements Client {
 
     @Override
     public FlagEvaluationDetails<Boolean> getBooleanDetails(String key, Boolean defaultValue, EvaluationContext ctx,
-            FlagEvaluationOptions options) {
+                                                            FlagEvaluationOptions options) {
         return this.evaluateFlag(FlagValueType.BOOLEAN, key, defaultValue, ctx, options);
     }
 
@@ -276,7 +335,7 @@ public class OpenFeatureClient implements Client {
 
     @Override
     public String getStringValue(String key, String defaultValue, EvaluationContext ctx,
-            FlagEvaluationOptions options) {
+                                 FlagEvaluationOptions options) {
         return getStringDetails(key, defaultValue, ctx, options).getValue();
     }
 
@@ -292,7 +351,7 @@ public class OpenFeatureClient implements Client {
 
     @Override
     public FlagEvaluationDetails<String> getStringDetails(String key, String defaultValue, EvaluationContext ctx,
-            FlagEvaluationOptions options) {
+                                                          FlagEvaluationOptions options) {
         return this.evaluateFlag(FlagValueType.STRING, key, defaultValue, ctx, options);
     }
 
@@ -308,7 +367,7 @@ public class OpenFeatureClient implements Client {
 
     @Override
     public Integer getIntegerValue(String key, Integer defaultValue, EvaluationContext ctx,
-            FlagEvaluationOptions options) {
+                                   FlagEvaluationOptions options) {
         return getIntegerDetails(key, defaultValue, ctx, options).getValue();
     }
 
@@ -324,7 +383,7 @@ public class OpenFeatureClient implements Client {
 
     @Override
     public FlagEvaluationDetails<Integer> getIntegerDetails(String key, Integer defaultValue, EvaluationContext ctx,
-            FlagEvaluationOptions options) {
+                                                            FlagEvaluationOptions options) {
         return this.evaluateFlag(FlagValueType.INTEGER, key, defaultValue, ctx, options);
     }
 
@@ -340,7 +399,7 @@ public class OpenFeatureClient implements Client {
 
     @Override
     public Double getDoubleValue(String key, Double defaultValue, EvaluationContext ctx,
-            FlagEvaluationOptions options) {
+                                 FlagEvaluationOptions options) {
         return this.evaluateFlag(FlagValueType.DOUBLE, key, defaultValue, ctx, options).getValue();
     }
 
@@ -356,7 +415,7 @@ public class OpenFeatureClient implements Client {
 
     @Override
     public FlagEvaluationDetails<Double> getDoubleDetails(String key, Double defaultValue, EvaluationContext ctx,
-            FlagEvaluationOptions options) {
+                                                          FlagEvaluationOptions options) {
         return this.evaluateFlag(FlagValueType.DOUBLE, key, defaultValue, ctx, options);
     }
 
@@ -372,7 +431,7 @@ public class OpenFeatureClient implements Client {
 
     @Override
     public Value getObjectValue(String key, Value defaultValue, EvaluationContext ctx,
-            FlagEvaluationOptions options) {
+                                FlagEvaluationOptions options) {
         return getObjectDetails(key, defaultValue, ctx, options).getValue();
     }
 
@@ -383,13 +442,13 @@ public class OpenFeatureClient implements Client {
 
     @Override
     public FlagEvaluationDetails<Value> getObjectDetails(String key, Value defaultValue,
-            EvaluationContext ctx) {
+                                                         EvaluationContext ctx) {
         return getObjectDetails(key, defaultValue, ctx, FlagEvaluationOptions.builder().build());
     }
 
     @Override
     public FlagEvaluationDetails<Value> getObjectDetails(String key, Value defaultValue, EvaluationContext ctx,
-            FlagEvaluationOptions options) {
+                                                         FlagEvaluationOptions options) {
         return this.evaluateFlag(FlagValueType.OBJECT, key, defaultValue, ctx, options);
     }
 
diff --git a/src/main/java/dev/openfeature/sdk/Tracking.java b/src/main/java/dev/openfeature/sdk/Tracking.java
new file mode 100644
index 0000000..ec9c8a8
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/Tracking.java
@@ -0,0 +1,42 @@
+package dev.openfeature.sdk;
+
+/**
+ * Interface for Tracking events.
+ */
+public interface Tracking {
+    /**
+     * Performs tracking of a particular action or application state.
+     *
+     * @param trackingEventName Event name to track
+     * @throws IllegalArgumentException if {@code trackingEventName} is null
+     */
+    void track(String trackingEventName);
+
+    /**
+     * Performs tracking of a particular action or application state.
+     *
+     * @param trackingEventName Event name to track
+     * @param context           Evaluation context used in flag evaluation
+     * @throws IllegalArgumentException if {@code trackingEventName} is null
+     */
+    void track(String trackingEventName, EvaluationContext context);
+
+    /**
+     * Performs tracking of a particular action or application state.
+     *
+     * @param trackingEventName Event name to track
+     * @param details           Data pertinent to a particular tracking event
+     * @throws IllegalArgumentException if {@code trackingEventName} is null
+     */
+    void track(String trackingEventName, TrackingEventDetails details);
+
+    /**
+     * Performs tracking of a particular action or application state.
+     *
+     * @param trackingEventName Event name to track
+     * @param context           Evaluation context used in flag evaluation
+     * @param details           Data pertinent to a particular tracking event
+     * @throws IllegalArgumentException if {@code trackingEventName} is null
+     */
+    void track(String trackingEventName, EvaluationContext context, TrackingEventDetails details);
+}
diff --git a/src/main/java/dev/openfeature/sdk/TrackingEventDetails.java b/src/main/java/dev/openfeature/sdk/TrackingEventDetails.java
new file mode 100644
index 0000000..76b20fb
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/TrackingEventDetails.java
@@ -0,0 +1,7 @@
+package dev.openfeature.sdk;
+
+/**
+ * Data pertinent to a particular tracking event.
+ */
+public interface TrackingEventDetails extends Structure {
+}


Test patch:
diff --git a/src/test/java/dev/openfeature/sdk/MutableTrackingEventDetailsTest.java b/src/test/java/dev/openfeature/sdk/MutableTrackingEventDetailsTest.java
new file mode 100644
index 0000000..a169107
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/MutableTrackingEventDetailsTest.java
@@ -0,0 +1,51 @@
+package dev.openfeature.sdk;
+
+import com.google.common.collect.Lists;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+class MutableTrackingEventDetailsTest {
+
+
+    @Test
+    void hasDefaultValue() {
+        MutableTrackingEventDetails track = new MutableTrackingEventDetails();
+        assertFalse(track.getValue().isPresent());
+    }
+
+    @Test
+    void shouldUseCorrectValue() {
+        MutableTrackingEventDetails track = new MutableTrackingEventDetails(3);
+        assertThat(track.getValue()).hasValue(3);
+    }
+
+    @Test
+    void shouldStoreAttributes() {
+        MutableTrackingEventDetails track = new MutableTrackingEventDetails();
+        track.add("key0", true);
+        track.add("key1", 1);
+        track.add("key2", "2");
+        track.add("key3", 1d);
+        track.add("key4", 4);
+        track.add("key5", Instant.parse("2023-12-03T10:15:30Z"));
+        track.add("key6", new MutableContext());
+        track.add("key7", new Value(7));
+        track.add("key8", Lists.newArrayList(new Value(8), new Value(9)));
+
+        assertEquals(new Value(true), track.getValue("key0"));
+        assertEquals(new Value(1), track.getValue("key1"));
+        assertEquals(new Value("2"), track.getValue("key2"));
+        assertEquals(new Value(1d), track.getValue("key3"));
+        assertEquals(new Value(4), track.getValue("key4"));
+        assertEquals(new Value(Instant.parse("2023-12-03T10:15:30Z")), track.getValue("key5"));
+        assertEquals(new Value(new MutableContext()), track.getValue("key6"));
+        assertEquals(new Value(7), track.getValue("key7"));
+        assertArrayEquals(new Object[]{new Value(8), new Value(9)}, track.getValue("key8").asList().toArray());
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java b/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java
index 23c758e..026170a 100644
--- a/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java
+++ b/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java
@@ -12,6 +12,9 @@ import java.util.HashMap;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatCode;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 
 class OpenFeatureAPITest {
 
@@ -101,4 +104,21 @@ class OpenFeatureAPITest {
         assertThat(OpenFeatureAPI.getInstance().getClient(domain).getProviderState())
                 .isEqualTo(ProviderState.READY);
     }
+
+
+    @Test
+    void featureProviderTrackIsCalled() throws Exception {
+        FeatureProvider featureProvider = mock(FeatureProvider.class);
+        FeatureProviderTestUtils.setFeatureProvider(featureProvider);
+
+        OpenFeatureAPI.getInstance()
+                .getClient()
+                .track("track-event",
+                        new ImmutableContext(),
+                        new MutableTrackingEventDetails(22.2f));
+
+        verify(featureProvider).initialize(any());
+        verify(featureProvider).getMetadata();
+        verify(featureProvider).track(any(), any(), any());
+    }
 }
diff --git a/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java b/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java
index 69d38a4..3c82fd6 100644
--- a/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java
+++ b/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java
@@ -5,8 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.*;
 
 import java.util.HashMap;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -79,6 +78,7 @@ class OpenFeatureClientTest implements HookFixtures {
         assertEquals(client, result);
     }
 
+
     @Test
     @DisplayName("Should not call evaluation methods when the provider has state FATAL")
     void shouldNotCallEvaluationMethodsWhenProviderIsInFatalErrorState() {
diff --git a/src/test/java/dev/openfeature/sdk/TrackingSpecTest.java b/src/test/java/dev/openfeature/sdk/TrackingSpecTest.java
new file mode 100644
index 0000000..6d19560
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/TrackingSpecTest.java
@@ -0,0 +1,189 @@
+package dev.openfeature.sdk;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import dev.openfeature.sdk.fixtures.ProviderFixture;
+import dev.openfeature.sdk.testutils.FeatureProviderTestUtils;
+import lombok.SneakyThrows;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.notNull;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.argThat;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+class TrackingSpecTest {
+
+    private OpenFeatureAPI api;
+    private Client client;
+
+    @BeforeEach
+    void getApiInstance() {
+        api = OpenFeatureAPI.getInstance();
+        client = api.getClient();
+    }
+
+
+    @Specification(number = "6.1.1.1", text = "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.")
+    @Specification(number = "6.1.2.1", text = "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.")
+    @Test
+    @SneakyThrows
+    void trackMethodFulfillsSpec() {
+
+        ImmutableContext ctx = new ImmutableContext();
+        MutableTrackingEventDetails details = new MutableTrackingEventDetails(0.0f);
+        assertThatCode(() -> client.track("event")).doesNotThrowAnyException();
+        assertThatCode(() -> client.track("event", ctx)).doesNotThrowAnyException();
+        assertThatCode(() -> client.track("event", details)).doesNotThrowAnyException();
+        assertThatCode(() -> client.track("event", ctx, details)).doesNotThrowAnyException();
+
+        assertThrows(NullPointerException.class, () -> client.track(null, ctx, details));
+        assertThrows(NullPointerException.class, () -> client.track("event", null, details));
+        assertThrows(NullPointerException.class, () -> client.track("event", ctx, null));
+        assertThrows(NullPointerException.class, () -> client.track(null, null, null));
+        assertThrows(NullPointerException.class, () -> client.track(null, ctx));
+        assertThrows(NullPointerException.class, () -> client.track(null, details));
+        assertThrows(NullPointerException.class, () -> client.track("event", (EvaluationContext) null));
+        assertThrows(NullPointerException.class, () -> client.track("event", (TrackingEventDetails) null));
+
+        assertThrows(IllegalArgumentException.class, () -> client.track(""));
+        assertThrows(IllegalArgumentException.class, () -> client.track("", ctx));
+        assertThrows(IllegalArgumentException.class, () -> client.track("", ctx, details));
+
+
+        Class<OpenFeatureClient> clientClass = OpenFeatureClient.class;
+        assertEquals(
+                void.class,
+                clientClass.getMethod("track", String.class).getReturnType(),
+                "The method should return void.");
+        assertEquals(
+                void.class,
+                clientClass.getMethod("track", String.class, EvaluationContext.class).getReturnType(),
+                "The method should return void.");
+
+        assertEquals(
+                void.class,
+                clientClass.getMethod("track", String.class, EvaluationContext.class, TrackingEventDetails.class).getReturnType(),
+                "The method should return void.");
+
+
+    }
+
+    @Specification(number = "6.1.3", text = "The evaluation context passed to the provider's track function " +
+            "MUST be merged in the order: API (global; lowest precedence) -> transaction -> client -> " +
+            "invocation (highest precedence), with duplicate values being overwritten.")
+    @Test
+    void contextsGetMerged() {
+
+        api.setTransactionContextPropagator(new ThreadLocalTransactionContextPropagator());
+
+        Map<String, Value> apiAttr = new HashMap<>();
+        apiAttr.put("my-key", new Value("hey"));
+        apiAttr.put("my-api-key", new Value("333"));
+        EvaluationContext apiCtx = new ImmutableContext(apiAttr);
+        api.setEvaluationContext(apiCtx);
+
+        Map<String, Value> txAttr = new HashMap<>();
+        txAttr.put("my-key", new Value("overwritten"));
+        txAttr.put("my-tx-key", new Value("444"));
+        EvaluationContext txCtx = new ImmutableContext(txAttr);
+        api.setTransactionContext(txCtx);
+
+        Map<String, Value> clAttr = new HashMap<>();
+        clAttr.put("my-key", new Value("overwritten-again"));
+        clAttr.put("my-cl-key", new Value("555"));
+        EvaluationContext clCtx = new ImmutableContext(clAttr);
+        client.setEvaluationContext(clCtx);
+
+        FeatureProvider provider = ProviderFixture.createMockedProvider();
+        FeatureProviderTestUtils.setFeatureProvider(provider);
+
+        client.track("event", new MutableContext().add("my-key", "final"), new MutableTrackingEventDetails(0.0f));
+
+        Map<String, Value> expectedMap = Maps.newHashMap();
+        expectedMap.put("my-key", new Value("final"));
+        expectedMap.put("my-api-key", new Value("333"));
+        expectedMap.put("my-tx-key", new Value("444"));
+        expectedMap.put("my-cl-key", new Value("555"));
+        verify(provider).track(eq("event"), argThat(ctx -> ctx.asMap().equals(expectedMap)), notNull());
+    }
+
+    @Specification(number = "6.1.4", text = "If the client's `track` function is called and the associated provider " +
+            "does not implement tracking, the client's `track` function MUST no-op.")
+    @Test
+    void noopProvider() {
+        FeatureProvider provider = spy(FeatureProvider.class);
+        api.setProvider(provider);
+        client.track("event");
+        verify(provider).track(any(), any(), any());
+    }
+
+    @Specification(number = "6.2.1", text = "The `tracking event details` structure MUST define an optional numeric " +
+            "`value`, associating a scalar quality with an `tracking event`.")
+    @Specification(number = "6.2.2", text = "The `tracking event details` MUST support the inclusion of custom " +
+            "fields, having keys of type `string`, and values of type `boolean | string | number | structure`.")
+    @Test
+    void eventDetails() {
+        assertFalse(new MutableTrackingEventDetails().getValue().isPresent());
+        assertFalse(new ImmutableTrackingEventDetails().getValue().isPresent());
+        assertThat(new ImmutableTrackingEventDetails(2).getValue()).hasValue(2);
+        assertThat(new MutableTrackingEventDetails(9.87f).getValue()).hasValue(9.87f);
+
+
+        // using mutable tracking event details
+        Map<String, Value> expectedMap = Maps.newHashMap();
+        expectedMap.put("my-str", new Value("str"));
+        expectedMap.put("my-num", new Value(1));
+        expectedMap.put("my-bool", new Value(true));
+        expectedMap.put("my-struct", new Value(new MutableTrackingEventDetails()));
+
+        MutableTrackingEventDetails details = new MutableTrackingEventDetails()
+                .add("my-str", new Value("str"))
+                .add("my-num", new Value(1))
+                .add("my-bool", new Value(true))
+                .add("my-struct", new Value(new MutableTrackingEventDetails()));
+
+        assertEquals(expectedMap, details.asMap());
+        assertThatCode(() -> OpenFeatureAPI.getInstance().getClient().
+                track(
+                        "tracking-event-name",
+                        new ImmutableContext(),
+                        new MutableTrackingEventDetails()))
+                .doesNotThrowAnyException();
+
+
+        // using immutable tracking event details
+        ImmutableMap<String, Value> expectedImmutable = ImmutableMap.of("my-str", new Value("str"),
+                "my-num", new Value(1),
+                "my-bool", new Value(true),
+                "my-struct", new Value(new ImmutableStructure())
+        );
+
+        ImmutableTrackingEventDetails immutableDetails = new ImmutableTrackingEventDetails(2, expectedMap);
+        assertEquals(expectedImmutable, immutableDetails.asMap());
+        assertThatCode(() -> OpenFeatureAPI.getInstance().getClient().
+                track(
+                        "tracking-event-name",
+                        new ImmutableContext(),
+                        new ImmutableTrackingEventDetails()))
+                .doesNotThrowAnyException();
+
+
+    }
+
+}