Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions .github/workflows/sdk-spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ permissions:
jobs:
sdk-spec:
# Keep the ref in `uses:` and `spec-ref` in sync.
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.5.0
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.7.0
with:
spec-ref: v0.5.0
spec-ref: v0.7.0
language: kotlin
88 changes: 87 additions & 1 deletion FlagsmithClient/src/main/java/com/flagsmith/Flagsmith.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.flagsmith.entities.IdentityFlagsAndTraits
import com.flagsmith.entities.Trait
import com.flagsmith.entities.TraitWithIdentity
import com.flagsmith.internal.FlagsmithAnalytics
import com.flagsmith.internal.FlagsmithEventProcessor
import com.flagsmith.internal.FlagsmithEventService
import com.flagsmith.internal.FlagsmithEventTimeTracker
import com.flagsmith.internal.FlagsmithRetrofitService
Expand All @@ -27,6 +28,9 @@ import java.io.IOException
* @property context The current context is required to use the Flagsmith Analytics functionality
* @property enableAnalytics Enable analytics - default true
* @property analyticsFlushPeriod The period in seconds between attempts by the Flagsmith SDK to push analytic events to the server
* @property enableEvents Send tracking and exposure events to Flagsmith, e.g. for experiments - default false
* @property eventsBaseUrl The base URL of Flagsmith's events API
* @property eventsFlushPeriod The period in seconds between attempts by the Flagsmith SDK to send events to the events API
* @constructor Create empty Flagsmith
*/
class Flagsmith constructor(
Expand All @@ -42,12 +46,18 @@ class Flagsmith constructor(
private val requestTimeoutSeconds: Long = 4L,
private val readTimeoutSeconds: Long = 6L,
private val writeTimeoutSeconds: Long = 6L,
override var lastFlagFetchTime: Double = 0.0 // from FlagsmithEventTimeTracker
override var lastFlagFetchTime: Double = 0.0, // from FlagsmithEventTimeTracker
private val enableEvents: Boolean = false,
private val eventsBaseUrl: String = DEFAULT_EVENTS_BASE_URL,
private val eventsFlushPeriod: Int = DEFAULT_EVENTS_FLUSH_PERIOD_SECONDS
) : FlagsmithEventTimeTracker {
private lateinit var retrofit: FlagsmithRetrofitService
private var cache: Cache? = null
private var lastUsedIdentity: String? = null
private var analytics: FlagsmithAnalytics? = null
private val eventProcessor: FlagsmithEventProcessor? =
if (!enableEvents) null
else FlagsmithEventProcessor(environmentKey, eventsBaseUrl, eventsFlushPeriod)

private val eventService: FlagsmithEventService? =
if (!enableRealtimeUpdates) null
Expand Down Expand Up @@ -104,6 +114,8 @@ class Flagsmith constructor(
companion object {
const val DEFAULT_ENABLE_ANALYTICS = true
const val DEFAULT_ANALYTICS_FLUSH_PERIOD_SECONDS = 10
const val DEFAULT_EVENTS_BASE_URL = "https://events.api.flagsmith.com/"
const val DEFAULT_EVENTS_FLUSH_PERIOD_SECONDS = 10
}

fun getFeatureFlags(identity: String? = null, traits: List<Trait>? = null, transient: Boolean = false, result: (Result<List<Flag>>) -> Unit) {
Expand Down Expand Up @@ -185,6 +197,80 @@ class Flagsmith constructor(
retrofit.getIdentityFlagsAndTraits(identity, if (transient) true else null).enqueueWithResult(defaults = null, result = result)
.also { lastUsedIdentity = identity }

/**
* Gets a feature's flag for an identity, and records an exposure event when the identity
* is enrolled in an experiment running on the feature.
*
* Exposures are recorded only with [enableEvents], and only for enabled flags.
*/
fun getExperimentFlag(
featureName: String,
identity: String,
traits: List<Trait>? = null,
transient: Boolean = false,
result: (Result<Flag?>) -> Unit
) = getFeatureFlags(identity, traits, transient) { res ->
result(res.map { flags ->
val flag = flags.find { it.feature.name == featureName }
analytics?.trackEvent(featureName)
val experiment = flag?.metadata?.experiment
if (flag != null && flag.enabled && experiment != null && experiment.inExperiment) {
trackExposureEvent(
featureName,
identity = identity,
value = flag.variant,
traits = traits?.associate { it.key to it.traitValue },
metadata = mapOf("experiment_id" to experiment.id)
)
}
flag
})
}

/**
* Records an event, e.g. a conversion, for the identity. Does nothing unless [enableEvents].
*
* @param identity Defaults to the identity flags were last fetched for.
*/
fun trackEvent(
event: String,
identity: String? = lastUsedIdentity,
value: Any? = null,
traits: Map<String, Any?>? = null,
metadata: Map<String, Any?>? = null
) {
require(!event.startsWith("\$")) {
"Event names starting with \"\$\" are reserved; use trackExposureEvent to record an exposure"
}
eventProcessor?.trackEvent(event, identity, value, traits, metadata)
}

/**
* Records that the identity was shown a variant of a feature, as a `$flag_exposure` event.
* Does nothing unless [enableEvents].
*
* @param identity Defaults to the identity flags were last fetched for.
*/
fun trackExposureEvent(
featureName: String,
identity: String? = lastUsedIdentity,
value: Any? = null,
traits: Map<String, Any?>? = null,
metadata: Map<String, Any?>? = null
) {
val processor = eventProcessor ?: return
if (identity == null) {
Log.e("Flagsmith", "Not tracking an exposure of $featureName without an identity")
return
}
processor.trackExposureEvent(featureName, identity, value, traits, metadata)
}

/** Sends the events recorded so far, without waiting for the next periodic send. */
fun flushEvents() {
eventProcessor?.flush()
}

fun clearCache() {
try {
cache?.evictAll()
Expand Down
16 changes: 16 additions & 0 deletions FlagsmithClient/src/main/java/com/flagsmith/entities/Event.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.flagsmith.entities

import com.google.gson.annotations.SerializedName

data class Event(
val event: String,
@SerializedName(value = "feature_name") val featureName: String?,
val identifier: String?,
val value: String?,
val traits: Map<String, Any?>?,
val metadata: Map<String, Any?>,
/** Milliseconds since the epoch. */
val timestamp: Long
)

data class EventBatch(val events: List<Event>)
17 changes: 16 additions & 1 deletion FlagsmithClient/src/main/java/com/flagsmith/entities/Flag.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import com.google.gson.annotations.SerializedName
data class Flag(
val feature: Feature,
@SerializedName(value = "feature_state_value") val featureStateValue: Any?,
val enabled: Boolean
val enabled: Boolean,
/** The key of the multivariate option served to the identity, if any. */
val variant: String? = null,
val metadata: FlagMetadata? = null
)

data class Feature(
Expand All @@ -17,3 +20,15 @@ data class Feature(
@SerializedName(value = "default_enabled") val defaultEnabled: Boolean = false,
val type: String = ""
)

data class FlagMetadata(
/** The experiment running on the flag's feature, if any. */
val experiment: Experiment? = null
)

data class Experiment(
val id: Long,
val name: String,
/** Whether the identity is enrolled in the experiment. */
@SerializedName(value = "in_experiment") val inExperiment: Boolean
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package com.flagsmith.internal

import android.util.Log
import com.flagsmith.entities.Event
import com.flagsmith.entities.EventBatch
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.POST
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit

interface FlagsmithEventsRetrofitService {
@POST("v1/events")
fun postEvents(@Body batch: EventBatch): Call<Unit>
}

/**
* Buffers tracking and exposure events, and sends them to Flagsmith's events API in batches.
*
* Batches are sent every [flushPeriodSeconds], when [maxBuffer] events are buffered, and on [flush].
* Repeated exposures within a batch are sent once. A batch that fails to send is retried once, then dropped.
*/
class FlagsmithEventProcessor(
environmentKey: String,
eventsBaseUrl: String,
flushPeriodSeconds: Int,
private val maxBuffer: Int = DEFAULT_MAX_BUFFER,
private val retryDelayMillis: Long = DEFAULT_RETRY_DELAY_MILLIS
) {
private val service: FlagsmithEventsRetrofitService = Retrofit.Builder()
.baseUrl(if (eventsBaseUrl.endsWith("/")) eventsBaseUrl else "$eventsBaseUrl/")
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
.client(
OkHttpClient.Builder()
.addInterceptor(FlagsmithRetrofitService.envKeyInterceptor(environmentKey))
.addInterceptor(FlagsmithRetrofitService.userAgentInterceptor())
.build()
)
.build()
.create(FlagsmithEventsRetrofitService::class.java)

private val buffer = mutableListOf<Event>()
private val exposureKeys = mutableSetOf<List<Any?>>()
private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "flagsmith-events").apply { isDaemon = true }
}

init {
if (flushPeriodSeconds > 0) {
executor.scheduleWithFixedDelay(
::flush, flushPeriodSeconds.toLong(), flushPeriodSeconds.toLong(), TimeUnit.SECONDS
)
}
}

companion object {
const val FLAG_EXPOSURE_EVENT = "\$flag_exposure"
const val DEFAULT_MAX_BUFFER = 1000
const val DEFAULT_RETRY_DELAY_MILLIS = 1000L
}

fun trackEvent(
event: String,
identifier: String?,
value: Any?,
traits: Map<String, Any?>?,
metadata: Map<String, Any?>?
) = buffer(event, null, identifier, value, traits, metadata)

fun trackExposureEvent(
featureName: String,
identifier: String,
value: Any?,
traits: Map<String, Any?>?,
metadata: Map<String, Any?>?
) = buffer(FLAG_EXPOSURE_EVENT, featureName, identifier, value, traits, metadata)

/** Sends the buffered events, without waiting for the request to complete. */
fun flush() {
val events = synchronized(buffer) {
if (buffer.isEmpty()) return
buffer.toList().also {
buffer.clear()
exposureKeys.clear()
}
}
send(events, retry = true)
}

private fun buffer(
event: String,
featureName: String?,
identifier: String?,
value: Any?,
traits: Map<String, Any?>?,
metadata: Map<String, Any?>?
) {
val stringValue = value?.toString()
val isFull = synchronized(buffer) {
if (event == FLAG_EXPOSURE_EVENT &&
!exposureKeys.add(listOf(featureName, identifier, stringValue, metadata?.get("experiment_id")))
) return
buffer.add(
Event(
event = event,
featureName = featureName,
identifier = identifier,
value = stringValue,
traits = traits,
metadata = (metadata ?: emptyMap()) + ("sdk_version" to FlagsmithRetrofitService.SDK_VERSION),
timestamp = System.currentTimeMillis()
)
)
buffer.size >= maxBuffer
}
if (isFull) flush()
}

private fun send(events: List<Event>, retry: Boolean) {
service.postEvents(EventBatch(events)).enqueueWithResult { result ->
result.onSuccess {
Log.i("Flagsmith", "Sent ${events.size} events")
}.onFailure { err ->
if (retry) {
Log.w("Flagsmith", "Failed sending ${events.size} events, retrying - ${err.localizedMessage}")
executor.schedule({ send(events, retry = false) }, retryDelayMillis, TimeUnit.MILLISECONDS)
} else {
Log.e("Flagsmith", "Failed sending ${events.size} events, dropping them - ${err.localizedMessage}")
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ interface FlagsmithRetrofitService {
private const val USER_AGENT_HEADER = "User-Agent"

// x-release-please-start-version
private const val SDK_VERSION = "1.9.0"
internal const val SDK_VERSION = "1.9.0"
// x-release-please-end

fun userAgentInterceptor(): Interceptor {
Expand Down
Loading
Loading