From 99d29cd200be773f7a3cd62c22580c40c28495a8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:54:51 +0000 Subject: [PATCH 1/2] deps: update dependency flagsmith/sdk-spec to v0.7.0 --- .github/workflows/sdk-spec.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index 00ef778..56ed66a 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -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 From 59dd3859b4589c08f32b59db3f4479137803804e Mon Sep 17 00:00:00 2001 From: "flagsmithsmith[bot]" <346993984+flagsmithsmith[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:26:48 +0000 Subject: [PATCH 2/2] feat: Track events and exposures, and get experiment flags Implements what SDK spec v0.6.0 requires: - `enableEvents` / `eventsBaseUrl` / `eventsFlushPeriod` options, sending buffered events in batches to Flagsmith's events API (`v1/events`). - `trackEvent` and `trackExposureEvent`, the latter recorded as `$flag_exposure`. - `getExperimentFlag`, which records an exposure to the served variant when the identity is enrolled in a running experiment. - `Flag.variant` and `Flag.metadata.experiment`, as returned by the API. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit a6265bdec688e30d1a805baf0707ed76bf022eab) --- .../src/main/java/com/flagsmith/Flagsmith.kt | 88 ++++++- .../main/java/com/flagsmith/entities/Event.kt | 16 ++ .../main/java/com/flagsmith/entities/Flag.kt | 17 +- .../internal/FlagsmithEventProcessor.kt | 138 +++++++++++ .../internal/FlagsmithRetrofitService.kt | 2 +- .../test/java/com/flagsmith/EventsTests.kt | 219 ++++++++++++++++++ .../com/flagsmith/SynchronousFlagsmith.kt | 3 + .../flagsmith/mockResponses/MockResponses.kt | 52 +++++ sdk-spec/src/main/java/android/util/Log.java | 4 + sdk-spec/steps.kt | 35 ++- 10 files changed, 570 insertions(+), 4 deletions(-) create mode 100644 FlagsmithClient/src/main/java/com/flagsmith/entities/Event.kt create mode 100644 FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithEventProcessor.kt create mode 100644 FlagsmithClient/src/test/java/com/flagsmith/EventsTests.kt diff --git a/FlagsmithClient/src/main/java/com/flagsmith/Flagsmith.kt b/FlagsmithClient/src/main/java/com/flagsmith/Flagsmith.kt index 2842383..9c291d5 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/Flagsmith.kt +++ b/FlagsmithClient/src/main/java/com/flagsmith/Flagsmith.kt @@ -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 @@ -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( @@ -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 @@ -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? = null, transient: Boolean = false, result: (Result>) -> Unit) { @@ -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? = null, + transient: Boolean = false, + result: (Result) -> 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? = null, + metadata: Map? = 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? = null, + metadata: Map? = 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() diff --git a/FlagsmithClient/src/main/java/com/flagsmith/entities/Event.kt b/FlagsmithClient/src/main/java/com/flagsmith/entities/Event.kt new file mode 100644 index 0000000..c76c055 --- /dev/null +++ b/FlagsmithClient/src/main/java/com/flagsmith/entities/Event.kt @@ -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?, + val metadata: Map, + /** Milliseconds since the epoch. */ + val timestamp: Long +) + +data class EventBatch(val events: List) diff --git a/FlagsmithClient/src/main/java/com/flagsmith/entities/Flag.kt b/FlagsmithClient/src/main/java/com/flagsmith/entities/Flag.kt index 8d527e5..ee93801 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/entities/Flag.kt +++ b/FlagsmithClient/src/main/java/com/flagsmith/entities/Flag.kt @@ -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( @@ -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 +) diff --git a/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithEventProcessor.kt b/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithEventProcessor.kt new file mode 100644 index 0000000..3e4ecf8 --- /dev/null +++ b/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithEventProcessor.kt @@ -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 +} + +/** + * 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() + private val exposureKeys = mutableSetOf>() + 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?, + metadata: Map? + ) = buffer(event, null, identifier, value, traits, metadata) + + fun trackExposureEvent( + featureName: String, + identifier: String, + value: Any?, + traits: Map?, + metadata: Map? + ) = 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?, + metadata: Map? + ) { + 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, 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}") + } + } + } + } +} diff --git a/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithRetrofitService.kt b/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithRetrofitService.kt index 872f3bd..c127971 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithRetrofitService.kt +++ b/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithRetrofitService.kt @@ -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 { diff --git a/FlagsmithClient/src/test/java/com/flagsmith/EventsTests.kt b/FlagsmithClient/src/test/java/com/flagsmith/EventsTests.kt new file mode 100644 index 0000000..22bc7d7 --- /dev/null +++ b/FlagsmithClient/src/test/java/com/flagsmith/EventsTests.kt @@ -0,0 +1,219 @@ +package com.flagsmith + +import com.flagsmith.entities.Trait +import com.flagsmith.mockResponses.MockResponses +import kotlinx.coroutines.runBlocking +import org.awaitility.kotlin.await +import org.awaitility.kotlin.untilAsserted +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.mockserver.integration.ClientAndServer +import org.mockserver.matchers.Times +import org.mockserver.model.HttpRequest.request +import org.mockserver.model.HttpResponse.response +import org.mockserver.model.JsonBody.json +import org.mockserver.verify.VerificationTimes +import java.time.Duration + +class EventsTests { + + private lateinit var mockServer: ClientAndServer + private lateinit var flagsmith: Flagsmith + + @Before + fun setup() { + mockServer = ClientAndServer.startClientAndServer() + flagsmith = Flagsmith( + environmentKey = "environment-key", + baseUrl = "http://localhost:${mockServer.localPort}", + enableAnalytics = false, + cacheConfig = FlagsmithCacheConfig(enableCache = false), + enableEvents = true, + eventsBaseUrl = "http://localhost:${mockServer.localPort}", + eventsFlushPeriod = 0 + ) + mockServer.`when`(request().withPath("/v1/events")) + .respond(response().withStatusCode(202)) + } + + @After + fun tearDown() { + mockServer.stop() + } + + @Test + fun testTrackedEventReachesFlagsmith() { + // Given / When + flagsmith.trackEvent( + "checkout_completed", + identity = "user_123", + value = 42, + metadata = mapOf("currency" to "GBP") + ) + flagsmith.flushEvents() + + // Then + await.atMost(Duration.ofSeconds(5)) untilAsserted { + mockServer.verify( + request() + .withPath("/v1/events") + .withMethod("POST") + .withHeader("X-Environment-Key", "environment-key") + .withBody( + json( + """ + { + "events": [ + { + "event": "checkout_completed", + "feature_name": null, + "identifier": "user_123", + "value": "42", + "traits": null, + "metadata": { "currency": "GBP" } + } + ] + } + """.trimIndent() + ) + ), + VerificationTimes.once() + ) + } + } + + @Test + fun testIdentityInExperimentIsExposedToTheVariantItWasServed() { + // Given + mockServer.`when`(request().withPath("/identities/").withMethod("POST")) + .respond(response().withStatusCode(200).withBody(MockResponses.postIdentityInExperiment)) + + // When + val flag = runBlocking { + flagsmith.getExperimentFlagSync("banner", "user_123", listOf(Trait("plan", "premium"))) + }.getOrThrow() + flagsmith.flushEvents() + + // Then + assertEquals("hello treatment", flag?.featureStateValue) + assertEquals("treatment", flag?.variant) + await.atMost(Duration.ofSeconds(5)) untilAsserted { + mockServer.verify( + request() + .withPath("/v1/events") + .withBody( + json( + """ + { + "events": [ + { + "event": "${'$'}flag_exposure", + "feature_name": "banner", + "identifier": "user_123", + "value": "treatment", + "traits": { "plan": "premium" }, + "metadata": { "experiment_id": 7 } + } + ] + } + """.trimIndent() + ) + ), + VerificationTimes.once() + ) + } + } + + @Test + fun testIdentityOutsideExperimentIsNotExposed() { + // Given + mockServer.`when`(request().withPath("/identities/").withMethod("POST")) + .respond(response().withStatusCode(200).withBody(MockResponses.postIdentityOutsideExperiment)) + + // When + val flag = runBlocking { + flagsmith.getExperimentFlagSync("banner", "user_123", listOf(Trait("plan", "premium"))) + }.getOrThrow() + flagsmith.flushEvents() + + // Then + assertEquals("hello", flag?.featureStateValue) + Thread.sleep(500) + mockServer.verify(request().withPath("/v1/events"), VerificationTimes.never()) + } + + @Test + fun testRepeatedExposuresAreSentOnce() { + // Given / When + repeat(2) { + flagsmith.trackExposureEvent( + "banner", + identity = "user_123", + value = "control", + metadata = mapOf("experiment_id" to 1) + ) + } + flagsmith.flushEvents() + + // Then + await.atMost(Duration.ofSeconds(5)) untilAsserted { + val requests = mockServer.retrieveRecordedRequests(request().withPath("/v1/events")) + assertEquals(1, requests.size) + assertEquals(1, Regex("flag_exposure").findAll(requests[0].bodyAsString).count()) + } + } + + @Test + fun testEventsAreRetriedOnceWhenFlagsmithFailsToReceiveThem() { + // Given + mockServer.reset() + mockServer.`when`(request().withPath("/v1/events"), Times.once()) + .respond(response().withStatusCode(500)) + mockServer.`when`(request().withPath("/v1/events")) + .respond(response().withStatusCode(202)) + + // When + flagsmith.trackEvent("checkout_completed", identity = "user_123") + flagsmith.flushEvents() + + // Then + await.atMost(Duration.ofSeconds(10)) untilAsserted { + mockServer.verify( + request().withPath("/v1/events").withBody(json("""{ "events": [ { "event": "checkout_completed" } ] }""")), + VerificationTimes.exactly(2) + ) + } + } + + @Test + fun testEventNamesStartingWithDollarAreReserved() { + // Given / When / Then + assertThrows(IllegalArgumentException::class.java) { + flagsmith.trackEvent("${'$'}flag_exposure", identity = "user_123") + } + } + + @Test + fun testEventsAreNotSentUnlessEnabled() { + // Given + val flagsmithWithoutEvents = Flagsmith( + environmentKey = "environment-key", + baseUrl = "http://localhost:${mockServer.localPort}", + enableAnalytics = false, + cacheConfig = FlagsmithCacheConfig(enableCache = false), + eventsBaseUrl = "http://localhost:${mockServer.localPort}" + ) + + // When + flagsmithWithoutEvents.trackEvent("checkout_completed", identity = "user_123") + flagsmithWithoutEvents.trackExposureEvent("banner", identity = "user_123", value = "control") + flagsmithWithoutEvents.flushEvents() + + // Then + Thread.sleep(500) + mockServer.verify(request().withPath("/v1/events"), VerificationTimes.never()) + } +} diff --git a/FlagsmithClient/src/test/java/com/flagsmith/SynchronousFlagsmith.kt b/FlagsmithClient/src/test/java/com/flagsmith/SynchronousFlagsmith.kt index 8667d5a..de868c6 100644 --- a/FlagsmithClient/src/test/java/com/flagsmith/SynchronousFlagsmith.kt +++ b/FlagsmithClient/src/test/java/com/flagsmith/SynchronousFlagsmith.kt @@ -31,3 +31,6 @@ suspend fun Flagsmith.setTraitsSync(traits: List, identity: String) : Res suspend fun Flagsmith.getIdentitySync(identity: String, transient: Boolean = false): Result = suspendCoroutine { cont -> this.getIdentity(identity, transient) { cont.resume(it) } } + +suspend fun Flagsmith.getExperimentFlagSync(featureName: String, identity: String, traits: List? = null): Result + = suspendCoroutine { cont -> this.getExperimentFlag(featureName, identity, traits) { cont.resume(it) } } diff --git a/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/MockResponses.kt b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/MockResponses.kt index 5164e63..94c688e 100644 --- a/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/MockResponses.kt +++ b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/MockResponses.kt @@ -178,6 +178,58 @@ object MockResponses { } """.trimIndent() + val postIdentityInExperiment = """ + { + "flags": [ + { + "feature_state_value": "hello treatment", + "feature": { + "type": "MULTIVARIATE", + "name": "banner", + "id": 35507 + }, + "enabled": true, + "variant": "treatment", + "metadata": { + "experiment": { + "id": 7, + "name": "banner experiment", + "in_experiment": true + } + } + } + ], + "traits": [ + { + "trait_key": "plan", + "trait_value": "premium" + } + ] + } + """.trimIndent() + + val postIdentityOutsideExperiment = """ + { + "flags": [ + { + "feature_state_value": "hello", + "feature": { + "type": "STANDARD", + "name": "banner", + "id": 35507 + }, + "enabled": true + } + ], + "traits": [ + { + "trait_key": "plan", + "trait_value": "premium" + } + ] + } + """.trimIndent() + val getFlags = """ [ { diff --git a/sdk-spec/src/main/java/android/util/Log.java b/sdk-spec/src/main/java/android/util/Log.java index 6f60da0..8973f27 100644 --- a/sdk-spec/src/main/java/android/util/Log.java +++ b/sdk-spec/src/main/java/android/util/Log.java @@ -9,6 +9,10 @@ public static int i(String tag, String msg) { return println("I", tag, msg); } + public static int w(String tag, String msg) { + return println("W", tag, msg); + } + public static int e(String tag, String msg) { return println("E", tag, msg); } diff --git a/sdk-spec/steps.kt b/sdk-spec/steps.kt index 5c185f2..1b8d03c 100644 --- a/sdk-spec/steps.kt +++ b/sdk-spec/steps.kt @@ -10,6 +10,8 @@ import java.util.concurrent.CompletableFuture /** A flag in the shape the spec expects. */ data class SpecFlag(val feature: String, val enabled: Boolean, val value: Any?) +fun Map.asTraits() = map { (key, value) -> Trait(key = key, traitValue = value) } + fun List.asSpecFlags() = map { SpecFlag(it.feature.name, it.enabled, it.featureStateValue) } /** Blocks until the SDK calls back with its result. */ @@ -24,6 +26,16 @@ fun createClient(environmentKey: String, apiUrl: String) = // Analytics requires an Android Context. Flagsmith(environmentKey = environmentKey, baseUrl = apiUrl, enableAnalytics = false) +// Create the SDK client with events enabled +fun createClientWithEvents(environmentKey: String, apiUrl: String, eventsUrl: String) = + Flagsmith( + environmentKey = environmentKey, + baseUrl = apiUrl, + enableAnalytics = false, + enableEvents = true, + eventsBaseUrl = eventsUrl, + ) + // When the application gets the environment flags fun getEnvironmentFlags(flagsmith: Flagsmith) = await { flagsmith.getFeatureFlags(result = it) }.asSpecFlags() @@ -33,7 +45,28 @@ fun getIdentityFlags(flagsmith: Flagsmith, identifier: String, traits: Map Trait(key = key, traitValue = value) }, + traits = traits.asTraits(), result = it, ) }.asSpecFlags() + +// When the application gets the experiment flag "{feature}" for identity "{identifier}" with traits: +fun getExperimentFlag(flagsmith: Flagsmith, feature: String, identifier: String, traits: Map) = + listOfNotNull( + await { + flagsmith.getExperimentFlag(feature, identity = identifier, traits = traits.asTraits(), result = it) + } + ).asSpecFlags() + +// When the application tracks event "{event}" for identity "{identifier}" with value {value} and metadata: +fun trackEvent(flagsmith: Flagsmith, event: String, identifier: String, value: Any?, metadata: Map) = + flagsmith.trackEvent(event, identity = identifier, value = value, metadata = metadata) + +// When the application tracks an exposure of feature "{feature}" for identity "{identifier}" with value {value} and metadata: +fun trackExposureEvent( + flagsmith: Flagsmith, + feature: String, + identifier: String, + value: Any?, + metadata: Map, +) = flagsmith.trackExposureEvent(feature, identity = identifier, value = value, metadata = metadata)