Skip to content

Commit 3b7ce62

Browse files
authored
feat: add support for tracking (#114)
Signed-off-by: Nicklas Lundin <[email protected]>
1 parent 1f59e3d commit 3b7ce62

File tree

9 files changed

+169
-5
lines changed

9 files changed

+169
-5
lines changed

README.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,16 @@ coroutineScope.launch(Dispatchers.IO) {
6868
## 🌟 Features
6969

7070
| Status | Features | Description |
71-
| ------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
71+
|--------|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
7272
|| [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
7373
|| [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
7474
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
75+
|| [Tracking](#tracking) | Associate user actions with feature flag evaluations. |
7576
|| [Logging](#logging) | Integrate with popular logging packages. |
7677
|| [Named clients](#named-clients) | Utilize multiple providers in a single application. |
7778
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
7879
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
79-
| ⚠️ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
80+
| ⚠️ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
8081

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

@@ -117,7 +118,6 @@ If the hook you're looking for hasn't been created yet, see the [develop a hook]
117118

118119
Once you've added a hook as a dependency, it can be registered at the global, client, or flag invocation level.
119120

120-
121121
```kotlin
122122
// add a hook globally, to run on all evaluations
123123
OpenFeatureAPI.addHooks(listOf(ExampleHook()))
@@ -130,6 +130,32 @@ client.addHooks(listOf(ExampleHook()))
130130
val retval = client.getBooleanValue(flagKey, false,
131131
FlagEvaluationOptions(listOf(ExampleHook())))
132132
```
133+
134+
### Tracking
135+
136+
The [tracking API](https://openfeature.dev/specification/sections/tracking/) allows you to use
137+
OpenFeature abstractions to associate user actions with feature flag evaluations.
138+
This is essential for robust experimentation powered by feature flags. Note that, unlike methods
139+
that handle feature flag evaluations, calling `track(...)` may throw an `IllegalArgumentException`
140+
if an empty string is passed as the `trackingEventName`.
141+
142+
Below is an example of how we can track a "Checkout" event with some `TrackingDetails`.
143+
144+
```kotlin
145+
OpenFeatureAPI.getClient().track(
146+
"Checkout",
147+
TrackingEventDetails(
148+
499.99,
149+
ImmutableStructure(
150+
"numberOfItems" to Value.Integer(4),
151+
"timeInCheckout" to Value.String("PT3M20S")
152+
)
153+
)
154+
)
155+
```
156+
157+
Tracking is optionally implemented by Providers.
158+
133159
### Logging
134160

135161
Logging customization is not yet available in the Kotlin SDK.

android/src/main/java/dev/openfeature/sdk/Client.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package dev.openfeature.sdk
22

3-
interface Client : Features {
3+
interface Client : Features, Tracking {
44
val metadata: ClientMetadata
55
val hooks: List<Hook<*>>
66

android/src/main/java/dev/openfeature/sdk/FeatureProvider.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,17 @@ interface FeatureProvider : EventObserver, ProviderStatus {
2727
fun getIntegerEvaluation(key: String, defaultValue: Int, context: EvaluationContext?): ProviderEvaluation<Int>
2828
fun getDoubleEvaluation(key: String, defaultValue: Double, context: EvaluationContext?): ProviderEvaluation<Double>
2929
fun getObjectEvaluation(key: String, defaultValue: Value, context: EvaluationContext?): ProviderEvaluation<Value>
30+
31+
/**
32+
* Feature provider implementations can opt in for to support Tracking by implementing this method.
33+
*
34+
* Performs tracking of a particular action or application state.
35+
*
36+
* @param trackingEventName Event name to track
37+
* @param context Evaluation context used in flag evaluation (Optional)
38+
* @param details Data pertinent to a particular tracking event (Optional)
39+
*/
40+
fun track(trackingEventName: String, context: EvaluationContext?, details: TrackingEventDetails?) {
41+
// an empty default implementation to make implementing this functionality optional
42+
}
3043
}

android/src/main/java/dev/openfeature/sdk/ImmutableStructure.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package dev.openfeature.sdk
22

33
class ImmutableStructure(private val attributes: Map<String, Value> = mapOf()) : Structure {
4+
constructor(vararg pairs: Pair<String, Value>) : this(pairs.toMap())
5+
46
override fun keySet(): Set<String> {
57
return attributes.keys
68
}

android/src/main/java/dev/openfeature/sdk/NoOpProvider.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import dev.openfeature.sdk.events.OpenFeatureEvents
44
import kotlinx.coroutines.flow.Flow
55
import kotlinx.coroutines.flow.flowOf
66

7-
class NoOpProvider(override val hooks: List<Hook<*>> = listOf()) : FeatureProvider {
7+
open class NoOpProvider(override val hooks: List<Hook<*>> = listOf()) : FeatureProvider {
88
override val metadata: ProviderMetadata = NoOpProviderMetadata("No-op provider")
99
override fun initialize(initialContext: EvaluationContext?) {
1010
// no-op

android/src/main/java/dev/openfeature/sdk/OpenFeatureClient.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ class OpenFeatureClient(
159159
return evaluateFlag(OBJECT, key, defaultValue, options)
160160
}
161161

162+
override fun track(trackingEventName: String, details: TrackingEventDetails?) {
163+
validateTrackingEventName(trackingEventName)
164+
openFeatureAPI.getProvider()
165+
.track(trackingEventName, openFeatureAPI.getEvaluationContext(), details)
166+
}
167+
162168
private fun <T> evaluateFlag(
163169
flagValueType: FlagValueType,
164170
key: String,
@@ -257,4 +263,10 @@ class OpenFeatureClient(
257263
}
258264

259265
data class Metadata(override val name: String?) : ClientMetadata
266+
}
267+
268+
private fun validateTrackingEventName(name: String) {
269+
if (name.isEmpty()) {
270+
throw IllegalArgumentException("trackingEventName cannot be empty")
271+
}
260272
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package dev.openfeature.sdk
2+
3+
/**
4+
* Interface for Tracking events.
5+
*/
6+
interface Tracking {
7+
/**
8+
* Performs tracking of a particular action or application state.
9+
*
10+
* @param trackingEventName Event name to track
11+
* @param details Data pertinent to a particular tracking event
12+
* @throws IllegalArgumentException if {@code trackingEventName} is null
13+
*/
14+
fun track(trackingEventName: String, details: TrackingEventDetails? = null)
15+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package dev.openfeature.sdk
2+
3+
data class TrackingEventDetails(
4+
val `value`: Number? = null,
5+
val structure: Structure = ImmutableStructure()
6+
) : Structure by structure
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package dev.openfeature.sdk
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertNotNull
5+
import org.junit.Assert.assertNull
6+
import org.junit.Before
7+
import org.junit.Test
8+
9+
class TrackingProviderTests {
10+
11+
private lateinit var inMemoryTrackingProvider: InMemoryTrackingProvider
12+
13+
@Before
14+
fun setup() {
15+
inMemoryTrackingProvider = InMemoryTrackingProvider()
16+
}
17+
18+
@Test(expected = IllegalArgumentException::class)
19+
fun throwsOnEmptyName() {
20+
OpenFeatureAPI.setProvider(inMemoryTrackingProvider)
21+
OpenFeatureAPI.getClient().track("")
22+
assertEquals(0, inMemoryTrackingProvider.trackings.size)
23+
}
24+
25+
@Test
26+
fun sendWithoutDetailsAppendsContext() {
27+
OpenFeatureAPI.setProvider(inMemoryTrackingProvider)
28+
val evaluationContext = ImmutableContext(
29+
"targetingKey",
30+
mapOf("integer" to Value.Integer(33))
31+
)
32+
OpenFeatureAPI.setEvaluationContext(
33+
evaluationContext
34+
)
35+
OpenFeatureAPI.getClient().track("MyEventName")
36+
37+
val trackedEventCall = inMemoryTrackingProvider.trackings[0]
38+
assertEquals("MyEventName", trackedEventCall.first)
39+
val trackedEventDetails = trackedEventCall.third
40+
assertNull(trackedEventDetails)
41+
val trackedContext = trackedEventCall.second
42+
assertEquals(evaluationContext, trackedContext)
43+
}
44+
45+
@Test
46+
fun trackEventWithDetails() {
47+
OpenFeatureAPI.setProvider(inMemoryTrackingProvider)
48+
val evaluationContext = ImmutableContext(
49+
"targetingKey",
50+
mapOf("integer" to Value.Integer(33))
51+
)
52+
OpenFeatureAPI.setEvaluationContext(
53+
evaluationContext
54+
)
55+
OpenFeatureAPI.getClient().track(
56+
"Checkout",
57+
TrackingEventDetails(
58+
499.99,
59+
ImmutableStructure(
60+
"numberOfItems" to Value.Integer(4),
61+
"timeInCheckout" to Value.String("PT3M20S")
62+
)
63+
)
64+
)
65+
66+
val trackedEventCall = inMemoryTrackingProvider.trackings[0]
67+
assertEquals("Checkout", trackedEventCall.first)
68+
val trackedEventDetails = trackedEventCall.third
69+
assertNotNull(trackedEventDetails!!.value)
70+
assertEquals(499.99, trackedEventDetails.value)
71+
assertEquals(2, trackedEventDetails.structure.asMap().size)
72+
assertEquals(Value.Integer(4), trackedEventDetails.structure.getValue("numberOfItems"))
73+
assertEquals(Value.String("PT3M20S"), trackedEventDetails.structure.getValue("timeInCheckout"))
74+
75+
val trackedContext = trackedEventCall.second
76+
assertEquals(evaluationContext, trackedContext)
77+
}
78+
79+
private class InMemoryTrackingProvider : NoOpProvider() {
80+
val trackings = mutableListOf<Triple<String, EvaluationContext?, TrackingEventDetails?>>()
81+
82+
override fun track(
83+
trackingEventName: String,
84+
context: EvaluationContext?,
85+
details: TrackingEventDetails?
86+
) {
87+
trackings.add(Triple(trackingEventName, context, details))
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)