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