Skip to content
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

Feature/real time flags #5

Merged
merged 43 commits into from
Aug 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
710122b
Compiles and test, need to add some tests then get it into a sample app
gazreese Jul 1, 2023
104b318
Should be testing but not getting the errors through Fuse so can't ru…
gazreese Jul 3, 2023
fbddca4
Probably gone as far as I can with 2.x fuel, let's try the 3.x
gazreese Jul 5, 2023
2d31deb
Move to Retrofit - seems to be going well so far, test runs
gazreese Jul 5, 2023
5e259b5
Tidying up, setTrait test not working
gazreese Jul 6, 2023
76942b1
All the tests are passing so will finish the retrofit migration
gazreese Jul 6, 2023
70d21e2
Updated some of the logic and added setTraits
gazreese Jul 6, 2023
049a0f6
Checkpoint commit before trying generic converter
gazreese Jul 6, 2023
e555685
Generics working fine
gazreese Jul 6, 2023
297642d
All passing for flags and such with the new generic caching
gazreese Jul 6, 2023
90622ae
Mostly swapped to Retrofit, now need to do the analytics
gazreese Jul 6, 2023
9536809
Analytics now over to retrofit
gazreese Jul 7, 2023
817b73c
Add caching for the getFlags endpoint
gazreese Jul 7, 2023
7e6d429
Get rid of the last of Fuel
gazreese Jul 7, 2023
ef4674b
Another clear-out and all working fine on the tests
gazreese Jul 7, 2023
3b64e5f
Now using Retrofit cache, remove the old stuff
gazreese Jul 7, 2023
46ee338
Now just using HTTP caching
gazreese Jul 7, 2023
dd6c7ca
Delete the old caching logic
gazreese Jul 7, 2023
a9a74f8
Finishing off, should be done for defaults and caching
gazreese Jul 7, 2023
ffb3cc6
Remove unneeded todo
gazreese Jul 7, 2023
83d058d
Remove some more code
gazreese Jul 7, 2023
652e2f3
Still just playing around with it
gazreese Jul 11, 2023
94f8167
Move cache configuration to its own data class
gazreese Jul 11, 2023
b480ea4
Tidy up the cache config and the tests
gazreese Jul 11, 2023
a56afe0
Update the comments
gazreese Jul 11, 2023
6d87253
Now covers the caching tests
gazreese Jul 11, 2023
790ce47
Tidy up some more of the tests
gazreese Jul 11, 2023
3aa02ed
Some more tidying up
gazreese Jul 11, 2023
a5200d7
Default to caching disabled
gazreese Jul 11, 2023
cb1542a
Last few PR comments
gazreese Jul 12, 2023
340d4f3
Split the read and write timeout for HTTP
gazreese Jul 12, 2023
e898adb
Merge branch 'feature/cache-flags' into feature/real-time-flags
gazreese Aug 7, 2023
ebe1405
Initial basic implementation, let's try to get things hooked up to th…
gazreese Aug 8, 2023
96a89a7
Seems to be generally working
gazreese Aug 8, 2023
2e23c9c
Checkpoint commit, seems to be generally working now just need to get…
gazreese Aug 10, 2023
851f87e
Checkpoint commit before making the changes OK'd by Matthew to move t…
gazreese Aug 23, 2023
b6f6abf
Ensure that the event source just reconnects if it loses the connection
gazreese Aug 23, 2023
8aa0ec2
Events and timers now all hooked-up and working in the manual integra…
gazreese Aug 23, 2023
bfc3c34
Got the integration test working
gazreese Aug 24, 2023
e6f6368
Tidy everything up and move sensitive data to environment variables
gazreese Aug 24, 2023
526c64d
Add a new test to cover the event stream going through a reconnect cycle
gazreese Aug 25, 2023
a8f8792
Merge branch 'main' into feature/real-time-flags
gazreese Aug 25, 2023
a7e6c85
Added test for the live stream of flags, tidied up the imports and va…
gazreese Aug 25, 2023
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
14 changes: 14 additions & 0 deletions FlagsmithClient/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,16 @@ android {

dependencies {
implementation("com.google.code.gson:gson:2.10")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")

// HTTP Client
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")

// Server Sent Events
implementation("com.squareup.okhttp3:okhttp-sse:4.11.0")
testImplementation("com.squareup.okhttp3:okhttp-sse:4.11.0")

testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")
testImplementation("org.mock-server:mockserver-netty-no-dependencies:5.14.0")
Expand Down Expand Up @@ -101,6 +106,15 @@ kover {
}

tasks.withType(Test::class) {
// if the excludeIntegrationTests property is set
// then exclude tests with IntegrationTest in the name
// i.e. `gradle :FlagsmithClient:testDebugUnitTest --tests "com.flagsmith.*" -P excludeIntegrationTests`
if (project.hasProperty("excludeIntegrationTests")) {
exclude {
it.name.contains("IntegrationTest")
}
}

testLogging {
events(
TestLogEvent.FAILED,
Expand Down
96 changes: 87 additions & 9 deletions FlagsmithClient/src/main/java/com/flagsmith/Flagsmith.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
package com.flagsmith

import android.content.Context
import com.flagsmith.entities.*
import android.util.Log
import com.flagsmith.entities.Flag
import com.flagsmith.entities.Identity
import com.flagsmith.entities.IdentityFlagsAndTraits
import com.flagsmith.entities.Trait
import com.flagsmith.entities.TraitWithIdentity
import com.flagsmith.internal.FlagsmithAnalytics
import com.flagsmith.internal.FlagsmithEventService
import com.flagsmith.internal.FlagsmithEventTimeTracker
import com.flagsmith.internal.FlagsmithRetrofitService
import com.flagsmith.internal.enqueueWithResult
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onEach
import okhttp3.Cache

/**
* Flagsmith
Expand All @@ -20,28 +31,76 @@ import com.flagsmith.internal.enqueueWithResult
*/
class Flagsmith constructor(
private val environmentKey: String,
private val baseUrl: String = "https://edge.api.flagsmith.com/api/v1",
private val baseUrl: String = "https://edge.api.flagsmith.com/api/v1/",
private val eventSourceUrl: String? = null,
private val context: Context? = null,
private val enableAnalytics: Boolean = DEFAULT_ENABLE_ANALYTICS,
private val enableRealtimeUpdates: Boolean = false,
private val analyticsFlushPeriod: Int = DEFAULT_ANALYTICS_FLUSH_PERIOD_SECONDS,
private val cacheConfig: FlagsmithCacheConfig = FlagsmithCacheConfig(),
private val defaultFlags: List<Flag> = emptyList(),
private val requestTimeoutSeconds: Long = 4L,
private val readTimeoutSeconds: Long = 6L,
private val writeTimeoutSeconds: Long = 6L
) {
private val retrofit: FlagsmithRetrofitService = FlagsmithRetrofitService.create(
baseUrl = baseUrl, environmentKey = environmentKey, context = context, cacheConfig = cacheConfig,
requestTimeoutSeconds = requestTimeoutSeconds, readTimeoutSeconds = readTimeoutSeconds, writeTimeoutSeconds = writeTimeoutSeconds)
private val writeTimeoutSeconds: Long = 6L,
override var lastSeenAt: Double = 0.0 // from FlagsmithEventTimeTracker
) : FlagsmithEventTimeTracker {
private lateinit var retrofit: FlagsmithRetrofitService
private var cache: Cache? = null
private var lastUsedIdentity: String? = null
private val analytics: FlagsmithAnalytics? =
if (!enableAnalytics) null
else if (context != null) FlagsmithAnalytics(context, retrofit, analyticsFlushPeriod)
else throw IllegalArgumentException("Flagsmith requires a context to use the analytics feature")

private val eventService: FlagsmithEventService? =
if (!enableRealtimeUpdates) null
else FlagsmithEventService(eventSourceUrl = eventSourceUrl, environmentKey = environmentKey) { event ->
if (event.isSuccess) {
lastEventUpdate = event.getOrNull()?.updatedAt ?: lastEventUpdate

// Check whether this event is anything new
if (lastEventUpdate > lastSeenAt) {
// First evict the cache otherwise we'll be stuck with the old values
cache?.evictAll()
lastSeenAt = lastEventUpdate

// Now we can get the new values
getFeatureFlags(lastUsedIdentity) { res ->
if (res.isFailure) {
Log.e(
"Flagsmith",
"Error getting flags in SSE stream: ${res.exceptionOrNull()}"
)
} else {
Log.i("Flagsmith", "Got flags due to SSE event: $event")

// If the customer wants to subscribe to updates, emit the new flags
flagUpdateFlow.tryEmit(res.getOrNull() ?: emptyList())
}
}
}
}
}

// The last time we got an event from the SSE stream or via the API
private var lastEventUpdate: Double = 0.0

/// Stream of flag updates from the SSE stream if enabled
val flagUpdateFlow = MutableStateFlow<List<Flag>>(listOf())

init {
if (cacheConfig.enableCache && context == null) {
throw IllegalArgumentException("Flagsmith requires a context to use the cache feature")
}
if (enableRealtimeUpdates) {
getFlagUpdates()
}
val pair = FlagsmithRetrofitService.create(
baseUrl = baseUrl, environmentKey = environmentKey, context = context, cacheConfig = cacheConfig,
requestTimeoutSeconds = requestTimeoutSeconds, readTimeoutSeconds = readTimeoutSeconds,
writeTimeoutSeconds = writeTimeoutSeconds, timeTracker = this)
retrofit = pair.first
cache = pair.second
}

companion object {
Expand All @@ -50,8 +109,12 @@ class Flagsmith constructor(
}

fun getFeatureFlags(identity: String? = null, result: (Result<List<Flag>>) -> Unit) {
// Save the last used identity as we'll refresh with this if we get update events
lastUsedIdentity = identity

if (identity != null) {
retrofit.getIdentityFlagsAndTraits(identity).enqueueWithResult { res ->
flagUpdateFlow.tryEmit(res.getOrNull()?.flags ?: emptyList())
result(res.map { it.flags })
}
} else {
Expand All @@ -78,18 +141,19 @@ class Flagsmith constructor(
fun getTrait(id: String, identity: String, result: (Result<Trait?>) -> Unit) =
retrofit.getIdentityFlagsAndTraits(identity).enqueueWithResult { res ->
result(res.map { value -> value.traits.find { it.key == id } })
}
}.also { lastUsedIdentity = identity }

fun getTraits(identity: String, result: (Result<List<Trait>>) -> Unit) =
retrofit.getIdentityFlagsAndTraits(identity).enqueueWithResult { res ->
result(res.map { it.traits })
}
}.also { lastUsedIdentity = identity }

fun setTrait(trait: Trait, identity: String, result: (Result<TraitWithIdentity>) -> Unit) =
retrofit.postTraits(TraitWithIdentity(trait.key, trait.value, Identity(identity))).enqueueWithResult(result = result)

fun getIdentity(identity: String, result: (Result<IdentityFlagsAndTraits>) -> Unit) =
retrofit.getIdentityFlagsAndTraits(identity).enqueueWithResult(defaults = null, result = result)
.also { lastUsedIdentity = identity }

private fun getFeatureFlag(
featureId: String,
Expand All @@ -101,6 +165,20 @@ class Flagsmith constructor(
analytics?.trackEvent(featureId)
foundFlag
})
}.also { lastUsedIdentity = identity }

private fun getFlagUpdates() {
if (eventService == null) return
eventService.sseEventsFlow.onEach {
getFeatureFlags { res ->
if (res.isFailure) {
Log.e("Flagsmith", "Error getting flags in SSE stream: ${res.exceptionOrNull()}")
return@getFeatureFlags
}
}
}.catch {
Log.e("Flagsmith", "Error in SSE stream: $it")
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.flagsmith.entities

internal data class FeatureStatePutBody (
val enabled: Boolean,
val feature_state_value: Any?
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.flagsmith.entities

import com.google.gson.annotations.SerializedName

internal data class FlagEvent (
@SerializedName(value = "updated_at") val updatedAt: Double
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.flagsmith.entities


import com.google.gson.annotations.SerializedName
import java.io.Reader

data class Trait(
val identifier: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import android.util.Log
import org.json.JSONException
import org.json.JSONObject

class FlagsmithAnalytics constructor(
internal class FlagsmithAnalytics constructor(
private val context: Context,
private val retrofitService: FlagsmithRetrofitService,
private val flushPeriod: Int
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.flagsmith.internal

import android.util.Log
import com.flagsmith.entities.FlagEvent
import com.google.gson.Gson
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources
import java.util.concurrent.TimeUnit

internal class FlagsmithEventService constructor(
private val eventSourceUrl: String?,
private val environmentKey: String,
private val updates: (Result<FlagEvent>) -> Unit
) {
private val defaultEventSourceHost = "https://realtime.flagsmith.com/"

private val sseClient = OkHttpClient.Builder()
.addInterceptor(FlagsmithRetrofitService.envKeyInterceptor(environmentKey))
.connectTimeout(6, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.MINUTES)
.writeTimeout(10, TimeUnit.MINUTES)
.build()

private val defaultEventSourceUrl: String = defaultEventSourceHost + "sse/environments/" + environmentKey + "/stream"

private val sseRequest = Request.Builder()
.url(eventSourceUrl ?: defaultEventSourceUrl)
.header("Accept", "application/json")
.addHeader("Accept", "text/event-stream")
.build()

private var currentEventSource: EventSource? = null

var sseEventsFlow = MutableStateFlow(FlagEvent(updatedAt = 0.0))
private set

private val sseEventSourceListener = object : EventSourceListener() {
override fun onClosed(eventSource: EventSource) {
super.onClosed(eventSource)
Log.d(TAG, "onClosed: $eventSource")

// This isn't uncommon and is the nature of HTTP requests, so just reconnect
initEventSource()
}

override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
super.onEvent(eventSource, id, type, data)
Log.d(TAG, "onEvent: $data")
if (type != null && type == "environment_updated" && data.isNotEmpty()) {
val flagEvent = Gson().fromJson(data, FlagEvent::class.java)
sseEventsFlow.tryEmit(flagEvent)
updates(Result.success(flagEvent))
}
}

override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
super.onFailure(eventSource, t, response)
t?.printStackTrace()
Log.d(TAG, "onFailure: ${t?.message}")
if (t != null)
updates(Result.failure(t))
else
updates(Result.failure(Throwable("Unknown error")))
}

override fun onOpen(eventSource: EventSource, response: Response) {
super.onOpen(eventSource, response)
Log.d(TAG, "onOpen: $eventSource")
}
}

init {
initEventSource()
}

private fun initEventSource() {
currentEventSource?.cancel()
currentEventSource = EventSources.createFactory(sseClient)
.newEventSource(request = sseRequest, listener = sseEventSourceListener)
}

companion object {
private const val TAG = "FlagsmithEventService"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.flagsmith.internal

///
internal interface FlagsmithEventTimeTracker {
var lastSeenAt: Double
}
Loading