diff --git a/README.md b/README.md
index 43faeb40..f16921a1 100644
--- a/README.md
+++ b/README.md
@@ -68,15 +68,16 @@ coroutineScope.launch(Dispatchers.IO) {
## 🌟 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. |
+| ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations. |
| ❌ | [Logging](#logging) | Integrate with popular logging packages. |
| ❌ | [Named clients](#named-clients) | Utilize multiple providers in a single application. |
| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. |
| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
-| ⚠️ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
+| ⚠️ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌
@@ -117,7 +118,6 @@ If the hook you're looking for hasn't been created yet, see the [develop a hook]
Once you've added a hook as a dependency, it can be registered at the global, client, or flag invocation level.
-
```kotlin
// add a hook globally, to run on all evaluations
OpenFeatureAPI.addHooks(listOf(ExampleHook()))
@@ -130,6 +130,32 @@ client.addHooks(listOf(ExampleHook()))
val retval = client.getBooleanValue(flagKey, false,
FlagEvaluationOptions(listOf(ExampleHook())))
```
+
+### 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`.
+
+Below is an example of how we can track a "Checkout" event with some `TrackingDetails`.
+
+```kotlin
+OpenFeatureAPI.getClient().track(
+ "Checkout",
+ TrackingEventDetails(
+ 499.99,
+ ImmutableStructure(
+ "numberOfItems" to Value.Integer(4),
+ "timeInCheckout" to Value.String("PT3M20S")
+ )
+ )
+)
+```
+
+Tracking is optionally implemented by Providers.
+
### Logging
Logging customization is not yet available in the Kotlin SDK.
diff --git a/android/src/main/java/dev/openfeature/sdk/Client.kt b/android/src/main/java/dev/openfeature/sdk/Client.kt
index e87d89b0..e2e48e93 100644
--- a/android/src/main/java/dev/openfeature/sdk/Client.kt
+++ b/android/src/main/java/dev/openfeature/sdk/Client.kt
@@ -1,6 +1,6 @@
package dev.openfeature.sdk
-interface Client : Features {
+interface Client : Features, Tracking {
val metadata: ClientMetadata
val hooks: List>
diff --git a/android/src/main/java/dev/openfeature/sdk/FeatureProvider.kt b/android/src/main/java/dev/openfeature/sdk/FeatureProvider.kt
index 09523b63..63abe726 100644
--- a/android/src/main/java/dev/openfeature/sdk/FeatureProvider.kt
+++ b/android/src/main/java/dev/openfeature/sdk/FeatureProvider.kt
@@ -27,4 +27,17 @@ interface FeatureProvider : EventObserver, ProviderStatus {
fun getIntegerEvaluation(key: String, defaultValue: Int, context: EvaluationContext?): ProviderEvaluation
fun getDoubleEvaluation(key: String, defaultValue: Double, context: EvaluationContext?): ProviderEvaluation
fun getObjectEvaluation(key: String, defaultValue: Value, context: EvaluationContext?): ProviderEvaluation
+
+ /**
+ * Feature provider implementations can opt in for to support Tracking by implementing this method.
+ *
+ * Performs tracking of a particular action or application state.
+ *
+ * @param trackingEventName Event name to track
+ * @param context Evaluation context used in flag evaluation (Optional)
+ * @param details Data pertinent to a particular tracking event (Optional)
+ */
+ fun track(trackingEventName: String, context: EvaluationContext?, details: TrackingEventDetails?) {
+ // an empty default implementation to make implementing this functionality optional
+ }
}
\ No newline at end of file
diff --git a/android/src/main/java/dev/openfeature/sdk/ImmutableStructure.kt b/android/src/main/java/dev/openfeature/sdk/ImmutableStructure.kt
index 88d908e8..01e3b14d 100644
--- a/android/src/main/java/dev/openfeature/sdk/ImmutableStructure.kt
+++ b/android/src/main/java/dev/openfeature/sdk/ImmutableStructure.kt
@@ -1,6 +1,8 @@
package dev.openfeature.sdk
class ImmutableStructure(private val attributes: Map = mapOf()) : Structure {
+ constructor(vararg pairs: Pair) : this(pairs.toMap())
+
override fun keySet(): Set {
return attributes.keys
}
diff --git a/android/src/main/java/dev/openfeature/sdk/NoOpProvider.kt b/android/src/main/java/dev/openfeature/sdk/NoOpProvider.kt
index fcbba651..943d0159 100644
--- a/android/src/main/java/dev/openfeature/sdk/NoOpProvider.kt
+++ b/android/src/main/java/dev/openfeature/sdk/NoOpProvider.kt
@@ -4,7 +4,7 @@ import dev.openfeature.sdk.events.OpenFeatureEvents
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
-class NoOpProvider(override val hooks: List> = listOf()) : FeatureProvider {
+open class NoOpProvider(override val hooks: List> = listOf()) : FeatureProvider {
override val metadata: ProviderMetadata = NoOpProviderMetadata("No-op provider")
override fun initialize(initialContext: EvaluationContext?) {
// no-op
diff --git a/android/src/main/java/dev/openfeature/sdk/OpenFeatureClient.kt b/android/src/main/java/dev/openfeature/sdk/OpenFeatureClient.kt
index 88f0667d..c2cbf3ff 100644
--- a/android/src/main/java/dev/openfeature/sdk/OpenFeatureClient.kt
+++ b/android/src/main/java/dev/openfeature/sdk/OpenFeatureClient.kt
@@ -159,6 +159,12 @@ class OpenFeatureClient(
return evaluateFlag(OBJECT, key, defaultValue, options)
}
+ override fun track(trackingEventName: String, details: TrackingEventDetails?) {
+ validateTrackingEventName(trackingEventName)
+ openFeatureAPI.getProvider()
+ .track(trackingEventName, openFeatureAPI.getEvaluationContext(), details)
+ }
+
private fun evaluateFlag(
flagValueType: FlagValueType,
key: String,
@@ -257,4 +263,10 @@ class OpenFeatureClient(
}
data class Metadata(override val name: String?) : ClientMetadata
+}
+
+private fun validateTrackingEventName(name: String) {
+ if (name.isEmpty()) {
+ throw IllegalArgumentException("trackingEventName cannot be empty")
+ }
}
\ No newline at end of file
diff --git a/android/src/main/java/dev/openfeature/sdk/Tracking.kt b/android/src/main/java/dev/openfeature/sdk/Tracking.kt
new file mode 100644
index 00000000..224043b8
--- /dev/null
+++ b/android/src/main/java/dev/openfeature/sdk/Tracking.kt
@@ -0,0 +1,15 @@
+package dev.openfeature.sdk
+
+/**
+ * Interface for Tracking events.
+ */
+interface Tracking {
+ /**
+ * 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
+ */
+ fun track(trackingEventName: String, details: TrackingEventDetails? = null)
+}
\ No newline at end of file
diff --git a/android/src/main/java/dev/openfeature/sdk/TrackingEventDetails.kt b/android/src/main/java/dev/openfeature/sdk/TrackingEventDetails.kt
new file mode 100644
index 00000000..37843d21
--- /dev/null
+++ b/android/src/main/java/dev/openfeature/sdk/TrackingEventDetails.kt
@@ -0,0 +1,6 @@
+package dev.openfeature.sdk
+
+data class TrackingEventDetails(
+ val `value`: Number? = null,
+ val structure: Structure = ImmutableStructure()
+) : Structure by structure
\ No newline at end of file
diff --git a/android/src/test/java/dev/openfeature/sdk/TrackingProviderTests.kt b/android/src/test/java/dev/openfeature/sdk/TrackingProviderTests.kt
new file mode 100644
index 00000000..71d1dd79
--- /dev/null
+++ b/android/src/test/java/dev/openfeature/sdk/TrackingProviderTests.kt
@@ -0,0 +1,90 @@
+package dev.openfeature.sdk
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+
+class TrackingProviderTests {
+
+ private lateinit var inMemoryTrackingProvider: InMemoryTrackingProvider
+
+ @Before
+ fun setup() {
+ inMemoryTrackingProvider = InMemoryTrackingProvider()
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun throwsOnEmptyName() {
+ OpenFeatureAPI.setProvider(inMemoryTrackingProvider)
+ OpenFeatureAPI.getClient().track("")
+ assertEquals(0, inMemoryTrackingProvider.trackings.size)
+ }
+
+ @Test
+ fun sendWithoutDetailsAppendsContext() {
+ OpenFeatureAPI.setProvider(inMemoryTrackingProvider)
+ val evaluationContext = ImmutableContext(
+ "targetingKey",
+ mapOf("integer" to Value.Integer(33))
+ )
+ OpenFeatureAPI.setEvaluationContext(
+ evaluationContext
+ )
+ OpenFeatureAPI.getClient().track("MyEventName")
+
+ val trackedEventCall = inMemoryTrackingProvider.trackings[0]
+ assertEquals("MyEventName", trackedEventCall.first)
+ val trackedEventDetails = trackedEventCall.third
+ assertNull(trackedEventDetails)
+ val trackedContext = trackedEventCall.second
+ assertEquals(evaluationContext, trackedContext)
+ }
+
+ @Test
+ fun trackEventWithDetails() {
+ OpenFeatureAPI.setProvider(inMemoryTrackingProvider)
+ val evaluationContext = ImmutableContext(
+ "targetingKey",
+ mapOf("integer" to Value.Integer(33))
+ )
+ OpenFeatureAPI.setEvaluationContext(
+ evaluationContext
+ )
+ OpenFeatureAPI.getClient().track(
+ "Checkout",
+ TrackingEventDetails(
+ 499.99,
+ ImmutableStructure(
+ "numberOfItems" to Value.Integer(4),
+ "timeInCheckout" to Value.String("PT3M20S")
+ )
+ )
+ )
+
+ val trackedEventCall = inMemoryTrackingProvider.trackings[0]
+ assertEquals("Checkout", trackedEventCall.first)
+ val trackedEventDetails = trackedEventCall.third
+ assertNotNull(trackedEventDetails!!.value)
+ assertEquals(499.99, trackedEventDetails.value)
+ assertEquals(2, trackedEventDetails.structure.asMap().size)
+ assertEquals(Value.Integer(4), trackedEventDetails.structure.getValue("numberOfItems"))
+ assertEquals(Value.String("PT3M20S"), trackedEventDetails.structure.getValue("timeInCheckout"))
+
+ val trackedContext = trackedEventCall.second
+ assertEquals(evaluationContext, trackedContext)
+ }
+
+ private class InMemoryTrackingProvider : NoOpProvider() {
+ val trackings = mutableListOf>()
+
+ override fun track(
+ trackingEventName: String,
+ context: EvaluationContext?,
+ details: TrackingEventDetails?
+ ) {
+ trackings.add(Triple(trackingEventName, context, details))
+ }
+ }
+}
\ No newline at end of file