Skip to content

feat: add support for tracking #114

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

<sub>Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌</sub>

Expand Down Expand Up @@ -117,6 +118,31 @@ 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.

### 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.


```kotlin
// add a hook globally, to run on all evaluations
Expand Down
2 changes: 1 addition & 1 deletion android/src/main/java/dev/openfeature/sdk/Client.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package dev.openfeature.sdk

interface Client : Features {
interface Client : Features, Tracking {
val metadata: ClientMetadata
val hooks: List<Hook<*>>

Expand Down
13 changes: 13 additions & 0 deletions android/src/main/java/dev/openfeature/sdk/FeatureProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,17 @@ interface FeatureProvider : EventObserver, ProviderStatus {
fun getIntegerEvaluation(key: String, defaultValue: Int, context: EvaluationContext?): ProviderEvaluation<Int>
fun getDoubleEvaluation(key: String, defaultValue: Double, context: EvaluationContext?): ProviderEvaluation<Double>
fun getObjectEvaluation(key: String, defaultValue: Value, context: EvaluationContext?): ProviderEvaluation<Value>

/**
* 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
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package dev.openfeature.sdk

class ImmutableStructure(private val attributes: Map<String, Value> = mapOf()) : Structure {
constructor(vararg pairs: Pair<String, Value>) : this(pairs.toMap())

override fun keySet(): Set<String> {
return attributes.keys
}
Expand Down
2 changes: 1 addition & 1 deletion android/src/main/java/dev/openfeature/sdk/NoOpProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hook<*>> = listOf()) : FeatureProvider {
open class NoOpProvider(override val hooks: List<Hook<*>> = listOf()) : FeatureProvider {
override val metadata: ProviderMetadata = NoOpProviderMetadata("No-op provider")
override fun initialize(initialContext: EvaluationContext?) {
// no-op
Expand Down
12 changes: 12 additions & 0 deletions android/src/main/java/dev/openfeature/sdk/OpenFeatureClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T> evaluateFlag(
flagValueType: FlagValueType,
key: String,
Expand Down Expand Up @@ -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")
}
}
15 changes: 15 additions & 0 deletions android/src/main/java/dev/openfeature/sdk/Tracking.kt
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package dev.openfeature.sdk

data class TrackingEventDetails(
val `value`: Number? = null,
val structure: Structure = ImmutableStructure()
) : Structure by structure
90 changes: 90 additions & 0 deletions android/src/test/java/dev/openfeature/sdk/TrackingProviderTests.kt
Original file line number Diff line number Diff line change
@@ -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<Triple<String, EvaluationContext?, TrackingEventDetails?>>()

override fun track(
trackingEventName: String,
context: EvaluationContext?,
details: TrackingEventDetails?
) {
trackings.add(Triple(trackingEventName, context, details))
}
}
}
Loading