diff --git a/Confidence/build.gradle.kts b/Confidence/build.gradle.kts index 519198c0..8ce7c5fe 100644 --- a/Confidence/build.gradle.kts +++ b/Confidence/build.gradle.kts @@ -7,6 +7,7 @@ plugins { id("signing") id("org.jetbrains.kotlinx.binary-compatibility-validator") id("org.jetbrains.kotlinx.kover") + alias(libs.plugins.protobuf) } val providerVersion = project.extra["version"].toString() @@ -60,6 +61,20 @@ dependencies { testImplementation(libs.mockWebServer) testImplementation(libs.mockk) testImplementation(libs.kotlinxCoroutinesTest) + testImplementation(libs.protobufJava) +} + +protobuf { + protoc { + artifact = libs.protoc.get().toString() + } + generateProtoTasks { + all().configureEach { + builtins { + create("java") + } + } + } } publishing { diff --git a/Confidence/src/main/java/com/spotify/confidence/Confidence.kt b/Confidence/src/main/java/com/spotify/confidence/Confidence.kt index c132c337..c55d91b0 100644 --- a/Confidence/src/main/java/com/spotify/confidence/Confidence.kt +++ b/Confidence/src/main/java/com/spotify/confidence/Confidence.kt @@ -40,7 +40,8 @@ class Confidence internal constructor( private val flagApplierClient: FlagApplierClient, private val parent: ConfidenceContextProvider? = null, private val region: ConfidenceRegion = ConfidenceRegion.GLOBAL, - private val debugLogger: DebugLogger? + private val debugLogger: DebugLogger?, + internal val telemetry: Telemetry = Telemetry(SDK_ID, Telemetry.Library.CONFIDENCE, SDK_VERSION) ) : Contextual, EventSender { private val removedKeys = mutableListOf() private val contextMap = MutableStateFlow(initialContext) @@ -123,6 +124,12 @@ class Confidence internal constructor( apply(flagName, resolveToken) } } + val (telemetryReason, telemetryErrorCode) = Telemetry.mapEvaluationReason( + eval.reason, + eval.errorCode + ) + telemetry.trackEvaluation(telemetryReason, telemetryErrorCode) + // we are using a custom serializer so that the Json is serialized correctly in the logs val contextJson = Json.encodeToJsonElement( MapSerializer(String.serializer(), NetworkConfidenceValueSerializer), @@ -219,6 +226,11 @@ class Confidence internal constructor( it.getContext().filterKeys { key -> !removedKeys.contains(key) } + contextMap.value } ?: contextMap.value + @Suppress("unused") + private fun setTelemetryLibraryOpenFeature() { + telemetry.library = Telemetry.Library.OPEN_FEATURE + } + override fun withContext(context: Map): EventSender = Confidence( clientSecret, dispatcher, @@ -230,7 +242,8 @@ class Confidence internal constructor( flagApplierClient, this, region, - debugLogger + debugLogger, + telemetry ).also { it.putContext(context) } @@ -377,6 +390,7 @@ object ConfidenceFactory { DebugLoggerImpl(loggingLevel, clientSecret) } val sdkMetadata = SdkMetadata(SDK_ID, SDK_VERSION) + val telemetry = Telemetry(SDK_ID, Telemetry.Library.CONFIDENCE, SDK_VERSION) val engine = EventSenderEngineImpl.instance( context, clientSecret, @@ -387,7 +401,7 @@ object ConfidenceFactory { ) val flagApplierClient = FlagApplierClientImpl( clientSecret, - sdkMetadata, + telemetry, region, dispatcher ) @@ -399,7 +413,7 @@ object ConfidenceFactory { .callTimeout(timeoutMillis, TimeUnit.MILLISECONDS) .build(), dispatcher = dispatcher, - sdkMetadata = sdkMetadata, + telemetry = telemetry, debugLogger = debugLogger ) @@ -419,7 +433,8 @@ object ConfidenceFactory { flagResolver = flagResolver, diskStorage = FileDiskStorage.create(context), flagApplierClient = flagApplierClient, - debugLogger = debugLogger + debugLogger = debugLogger, + telemetry = telemetry ) } } diff --git a/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt b/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt index 819535f6..f9f9e404 100644 --- a/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt +++ b/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt @@ -2,7 +2,6 @@ package com.spotify.confidence import com.spotify.confidence.client.ResolveResponse import com.spotify.confidence.client.Sdk -import com.spotify.confidence.client.SdkMetadata import com.spotify.confidence.client.await import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -17,6 +16,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import java.net.SocketTimeoutException internal interface FlagResolver { suspend fun resolve(flags: List, context: Map): Result @@ -26,7 +26,7 @@ internal class RemoteFlagResolver( private val clientSecret: String, private val region: ConfidenceRegion, private val httpClient: OkHttpClient, - private val sdkMetadata: SdkMetadata, + private val telemetry: Telemetry, private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val baseUrl: HttpUrl? = null, private val debugLogger: DebugLogger? = null @@ -38,18 +38,37 @@ internal class RemoteFlagResolver( "application/json" ) override suspend fun resolve(flags: List, context: Map): Result { - val sdk = Sdk(sdkMetadata.sdkId, sdkMetadata.sdkVersion) + val sdk = telemetry.sdk val request = ResolveFlagsRequest(flags.map { "flags/$it" }, context, clientSecret, false, sdk) val response = withContext(dispatcher) { val jsonRequest = Json.encodeToString(request) - val httpRequest = Request.Builder() + val requestBuilder = Request.Builder() .url("${baseUrl()}/v1/flags:resolve") .headers(headers) .post(jsonRequest.toRequestBody()) - .build() - httpClient.newCall(httpRequest).await().toResolveFlags() + telemetry.encodedHeaderValue()?.let { headerValue -> + requestBuilder.addHeader(Telemetry.HEADER_NAME, headerValue) + } + + val httpRequest = requestBuilder.build() + + val startTime = System.nanoTime() + var status = Telemetry.RequestStatus.SUCCESS + try { + val result = httpClient.newCall(httpRequest).await() + result.toResolveFlags() + } catch (e: SocketTimeoutException) { + status = Telemetry.RequestStatus.TIMEOUT + throw e + } catch (e: Exception) { + status = Telemetry.RequestStatus.ERROR + throw e + } finally { + val elapsedMs = (System.nanoTime() - startTime) / 1_000_000 + telemetry.trackResolveLatency(elapsedMs, status) + } } return when (response) { diff --git a/Confidence/src/main/java/com/spotify/confidence/Telemetry.kt b/Confidence/src/main/java/com/spotify/confidence/Telemetry.kt new file mode 100644 index 00000000..440e60be --- /dev/null +++ b/Confidence/src/main/java/com/spotify/confidence/Telemetry.kt @@ -0,0 +1,278 @@ +package com.spotify.confidence + +import android.util.Base64 +import com.spotify.confidence.client.Sdk +import java.io.ByteArrayOutputStream + +internal class Telemetry( + private val sdkId: String, + @Volatile var library: Library, + private val sdkVersion: String +) { + private val lock = Any() + private val evaluationTraces = mutableListOf() + private val resolveLatencyTraces = mutableListOf() + + val sdk: Sdk = Sdk(sdkId, sdkVersion) + + fun trackEvaluation(reason: EvaluationReason, errorCode: EvaluationErrorCode) { + synchronized(lock) { + if (evaluationTraces.size < MAX_TRACES) { + evaluationTraces.add(EvaluationTrace(reason, errorCode)) + } + } + } + + fun trackResolveLatency(durationMs: Long, status: RequestStatus) { + synchronized(lock) { + if (resolveLatencyTraces.size < MAX_TRACES) { + resolveLatencyTraces.add(ResolveLatencyTrace(durationMs, status)) + } + } + } + + fun encodedHeaderValue(): String? { + val snapshot = synchronized(lock) { + val s = Snapshot( + evaluations = evaluationTraces.toList(), + resolveTraces = resolveLatencyTraces.toList(), + library = library + ) + evaluationTraces.clear() + resolveLatencyTraces.clear() + s + } + + if (snapshot.evaluations.isEmpty() && snapshot.resolveTraces.isEmpty()) return null + + val bytes = encodeMonitoring(snapshot) + return Base64.encodeToString(bytes, Base64.NO_WRAP) + } + + private data class Snapshot( + val evaluations: List, + val resolveTraces: List, + val library: Library + ) + + private fun encodeMonitoring(snapshot: Snapshot): ByteArray { + val out = ByteArrayOutputStream() + + val libraryTracesBytes = encodeLibraryTraces(snapshot) + out.writeTag(1, WIRE_TYPE_LENGTH_DELIMITED) + out.writeVarint(libraryTracesBytes.size.toLong()) + out.write(libraryTracesBytes) + + if (Platform.KOTLIN.value != 0) { + out.writeTag(2, WIRE_TYPE_VARINT) + out.writeVarint(Platform.KOTLIN.value.toLong()) + } + + return out.toByteArray() + } + + private fun encodeLibraryTraces(snapshot: Snapshot): ByteArray { + val out = ByteArrayOutputStream() + + if (snapshot.library.value != 0) { + out.writeTag(1, WIRE_TYPE_VARINT) + out.writeVarint(snapshot.library.value.toLong()) + } + + val versionBytes = sdkVersion.toByteArray(Charsets.UTF_8) + out.writeTag(2, WIRE_TYPE_LENGTH_DELIMITED) + out.writeVarint(versionBytes.size.toLong()) + out.write(versionBytes) + + for (resolve in snapshot.resolveTraces) { + val traceBytes = encodeResolveLatencyTrace(resolve) + out.writeTag(3, WIRE_TYPE_LENGTH_DELIMITED) + out.writeVarint(traceBytes.size.toLong()) + out.write(traceBytes) + } + for (eval in snapshot.evaluations) { + val traceBytes = encodeEvaluationTrace(eval) + out.writeTag(3, WIRE_TYPE_LENGTH_DELIMITED) + out.writeVarint(traceBytes.size.toLong()) + out.write(traceBytes) + } + + return out.toByteArray() + } + + private fun encodeResolveLatencyTrace(trace: ResolveLatencyTrace): ByteArray { + val out = ByteArrayOutputStream() + + out.writeTag(1, WIRE_TYPE_VARINT) + out.writeVarint(TraceId.RESOLVE_LATENCY.value.toLong()) + + val requestTraceBytes = encodeRequestTrace(trace) + out.writeTag(3, WIRE_TYPE_LENGTH_DELIMITED) + out.writeVarint(requestTraceBytes.size.toLong()) + out.write(requestTraceBytes) + + return out.toByteArray() + } + + private fun encodeRequestTrace(trace: ResolveLatencyTrace): ByteArray { + val out = ByteArrayOutputStream() + + if (trace.durationMs != 0L) { + out.writeTag(1, WIRE_TYPE_VARINT) + out.writeVarint(trace.durationMs) + } + + if (trace.status.value != 0) { + out.writeTag(2, WIRE_TYPE_VARINT) + out.writeVarint(trace.status.value.toLong()) + } + + return out.toByteArray() + } + + private fun encodeEvaluationTrace(trace: EvaluationTrace): ByteArray { + val out = ByteArrayOutputStream() + + out.writeTag(1, WIRE_TYPE_VARINT) + out.writeVarint(TraceId.FLAG_EVALUATION.value.toLong()) + + val evalTraceBytes = encodeEvalTraceBody(trace) + if (evalTraceBytes.isNotEmpty()) { + out.writeTag(5, WIRE_TYPE_LENGTH_DELIMITED) + out.writeVarint(evalTraceBytes.size.toLong()) + out.write(evalTraceBytes) + } + + return out.toByteArray() + } + + private fun encodeEvalTraceBody(trace: EvaluationTrace): ByteArray { + val out = ByteArrayOutputStream() + + if (trace.reason.value != 0) { + out.writeTag(1, WIRE_TYPE_VARINT) + out.writeVarint(trace.reason.value.toLong()) + } + + if (trace.errorCode.value != 0) { + out.writeTag(2, WIRE_TYPE_VARINT) + out.writeVarint(trace.errorCode.value.toLong()) + } + + return out.toByteArray() + } + + companion object { + const val HEADER_NAME = "X-CONFIDENCE-TELEMETRY" + private const val MAX_TRACES = 100 + + private const val WIRE_TYPE_VARINT = 0 + private const val WIRE_TYPE_LENGTH_DELIMITED = 2 + + fun mapEvaluationReason( + reason: ResolveReason, + errorCode: ConfidenceError.ErrorCode? + ): Pair { + if (errorCode != null) { + return when (errorCode) { + ConfidenceError.ErrorCode.FLAG_NOT_FOUND -> + Pair(EvaluationReason.ERROR, EvaluationErrorCode.FLAG_NOT_FOUND) + ConfidenceError.ErrorCode.PARSE_ERROR -> + Pair(EvaluationReason.ERROR, EvaluationErrorCode.PARSE_ERROR) + ConfidenceError.ErrorCode.INVALID_CONTEXT -> + Pair(EvaluationReason.ERROR, EvaluationErrorCode.INVALID_CONTEXT) + ConfidenceError.ErrorCode.PROVIDER_NOT_READY -> + Pair(EvaluationReason.ERROR, EvaluationErrorCode.PROVIDER_NOT_READY) + else -> + Pair(EvaluationReason.ERROR, EvaluationErrorCode.GENERAL) + } + } + return when (reason) { + ResolveReason.RESOLVE_REASON_MATCH -> + Pair(EvaluationReason.TARGETING_MATCH, EvaluationErrorCode.UNSPECIFIED) + ResolveReason.RESOLVE_REASON_NO_SEGMENT_MATCH -> + Pair(EvaluationReason.DEFAULT, EvaluationErrorCode.UNSPECIFIED) + ResolveReason.RESOLVE_REASON_NO_TREATMENT_MATCH -> + Pair(EvaluationReason.DEFAULT, EvaluationErrorCode.UNSPECIFIED) + ResolveReason.RESOLVE_REASON_STALE -> + Pair(EvaluationReason.STALE, EvaluationErrorCode.UNSPECIFIED) + ResolveReason.RESOLVE_REASON_FLAG_ARCHIVED -> + Pair(EvaluationReason.DISABLED, EvaluationErrorCode.UNSPECIFIED) + ResolveReason.RESOLVE_REASON_TARGETING_KEY_ERROR -> + Pair(EvaluationReason.ERROR, EvaluationErrorCode.TARGETING_KEY_MISSING) + ResolveReason.ERROR -> + Pair(EvaluationReason.ERROR, EvaluationErrorCode.GENERAL) + else -> + Pair(EvaluationReason.UNSPECIFIED, EvaluationErrorCode.UNSPECIFIED) + } + } + + private fun ByteArrayOutputStream.writeTag(fieldNumber: Int, wireType: Int) { + writeVarint(((fieldNumber shl 3) or wireType).toLong()) + } + + private fun ByteArrayOutputStream.writeVarint(value: Long) { + var v = value + while (v and 0x7FL.inv() != 0L) { + write(((v and 0x7F) or 0x80).toInt()) + v = v ushr 7 + } + write(v.toInt()) + } + } + + enum class Platform(val value: Int) { + KOTLIN(2) + } + + enum class Library(val value: Int) { + CONFIDENCE(1), + OPEN_FEATURE(2) + } + + enum class TraceId(val value: Int) { + RESOLVE_LATENCY(1), + FLAG_EVALUATION(3) + } + + enum class RequestStatus(val value: Int) { + UNSPECIFIED(0), + SUCCESS(1), + ERROR(2), + TIMEOUT(3) + } + + enum class EvaluationReason(val value: Int) { + UNSPECIFIED(0), + TARGETING_MATCH(1), + DEFAULT(2), + STALE(3), + DISABLED(4), + CACHED(5), + STATIC(6), + SPLIT(7), + ERROR(8) + } + + enum class EvaluationErrorCode(val value: Int) { + UNSPECIFIED(0), + PROVIDER_NOT_READY(1), + FLAG_NOT_FOUND(2), + PARSE_ERROR(3), + TYPE_MISMATCH(4), + TARGETING_KEY_MISSING(5), + INVALID_CONTEXT(6), + PROVIDER_FATAL(7), + GENERAL(8) + } + + private data class EvaluationTrace( + val reason: EvaluationReason, + val errorCode: EvaluationErrorCode + ) + + private data class ResolveLatencyTrace( + val durationMs: Long, + val status: RequestStatus + ) +} diff --git a/Confidence/src/main/java/com/spotify/confidence/client/FlagApplierClientImpl.kt b/Confidence/src/main/java/com/spotify/confidence/client/FlagApplierClientImpl.kt index 81ec3c37..0af66ae3 100644 --- a/Confidence/src/main/java/com/spotify/confidence/client/FlagApplierClientImpl.kt +++ b/Confidence/src/main/java/com/spotify/confidence/client/FlagApplierClientImpl.kt @@ -2,6 +2,7 @@ package com.spotify.confidence.client import com.spotify.confidence.ConfidenceRegion import com.spotify.confidence.Result +import com.spotify.confidence.Telemetry import com.spotify.confidence.client.network.ApplyFlagsInteractor import com.spotify.confidence.client.network.ApplyFlagsInteractorImpl import com.spotify.confidence.client.network.ApplyFlagsRequest @@ -13,7 +14,7 @@ import okhttp3.OkHttpClient internal class FlagApplierClientImpl : FlagApplierClient { private val clientSecret: String - private val sdkMetadata: SdkMetadata + private val telemetry: Telemetry private val okHttpClient: OkHttpClient private val baseUrl: String private val headers: Headers @@ -23,12 +24,12 @@ internal class FlagApplierClientImpl : FlagApplierClient { constructor( clientSecret: String, - sdkMetadata: SdkMetadata, + telemetry: Telemetry, region: ConfidenceRegion, dispatcher: CoroutineDispatcher = Dispatchers.IO ) { this.clientSecret = clientSecret - this.sdkMetadata = sdkMetadata + this.telemetry = telemetry this.okHttpClient = OkHttpClient() this.headers = Headers.headersOf( "Content-Type", @@ -53,13 +54,13 @@ internal class FlagApplierClientImpl : FlagApplierClient { internal constructor( clientSecret: String = "", - sdkMetadata: SdkMetadata = SdkMetadata("", ""), + telemetry: Telemetry = Telemetry("", Telemetry.Library.CONFIDENCE, ""), baseUrl: HttpUrl, clock: Clock = Clock.CalendarBacked.systemUTC(), dispatcher: CoroutineDispatcher = Dispatchers.IO ) { this.clientSecret = clientSecret - this.sdkMetadata = sdkMetadata + this.telemetry = telemetry this.okHttpClient = OkHttpClient() this.headers = Headers.headersOf( "Content-Type", @@ -79,14 +80,21 @@ internal class FlagApplierClientImpl : FlagApplierClient { } override suspend fun apply(flags: List, resolveToken: String): Result { + val sdk = telemetry.sdk val request = ApplyFlagsRequest( flags.map { AppliedFlag("flags/${it.flag}", it.applyTime) }, clock.currentTime(), clientSecret, resolveToken, - Sdk(sdkMetadata.sdkId, sdkMetadata.sdkVersion) + sdk ) - val result = applyInteractor(request).runCatching { + + val extraHeaders = mutableMapOf() + telemetry.encodedHeaderValue()?.let { headerValue -> + extraHeaders[Telemetry.HEADER_NAME] = headerValue + } + + val result = applyInteractor.invoke(request, extraHeaders).runCatching { if (isSuccessful) { Result.Success(Unit) } else { diff --git a/Confidence/src/main/java/com/spotify/confidence/client/network/ApplyFlagsInteractor.kt b/Confidence/src/main/java/com/spotify/confidence/client/network/ApplyFlagsInteractor.kt index 83127994..aa0bc9bd 100644 --- a/Confidence/src/main/java/com/spotify/confidence/client/network/ApplyFlagsInteractor.kt +++ b/Confidence/src/main/java/com/spotify/confidence/client/network/ApplyFlagsInteractor.kt @@ -15,7 +15,9 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import java.util.Date -internal interface ApplyFlagsInteractor : suspend (ApplyFlagsRequest) -> (Response) +internal interface ApplyFlagsInteractor { + suspend fun invoke(request: ApplyFlagsRequest, extraHeaders: Map = emptyMap()): Response +} internal class ApplyFlagsInteractorImpl( private val httpClient: OkHttpClient, @@ -31,13 +33,18 @@ internal class ApplyFlagsInteractorImpl( "application/json" ) } - override suspend fun invoke(request: ApplyFlagsRequest): Response = + override suspend fun invoke(request: ApplyFlagsRequest, extraHeaders: Map): Response = withContext(dispatcher) { - val httpRequest = Request.Builder() + val requestBuilder = Request.Builder() .url("$baseUrl/v1/flags:apply") .headers(headers) .post(Json.encodeToString(request).toRequestBody()) - .build() + + for ((key, value) in extraHeaders) { + requestBuilder.addHeader(key, value) + } + + val httpRequest = requestBuilder.build() return@withContext httpClient.newCall(httpRequest).await() } diff --git a/Confidence/src/test/java/com/spotify/confidence/ConfidenceRemoteClientTests.kt b/Confidence/src/test/java/com/spotify/confidence/ConfidenceRemoteClientTests.kt index 5cdd5f6b..3c449cb8 100644 --- a/Confidence/src/test/java/com/spotify/confidence/ConfidenceRemoteClientTests.kt +++ b/Confidence/src/test/java/com/spotify/confidence/ConfidenceRemoteClientTests.kt @@ -2,6 +2,7 @@ package com.spotify.confidence +import android.util.Base64 import com.spotify.confidence.ConfidenceError.ParseError import com.spotify.confidence.client.AppliedFlag import com.spotify.confidence.client.Clock @@ -9,7 +10,9 @@ import com.spotify.confidence.client.FlagApplierClientImpl import com.spotify.confidence.client.Flags import com.spotify.confidence.client.ResolveFlags import com.spotify.confidence.client.ResolvedFlag -import com.spotify.confidence.client.SdkMetadata +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import junit.framework.TestCase.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -31,16 +34,21 @@ import java.util.Date internal class ConfidenceRemoteClientTests { private val mockWebServer = MockWebServer() - private val sdkMetadata = SdkMetadata(SDK_ID + "_TEST", "") + private val testTelemetry = Telemetry(SDK_ID + "_TEST", Telemetry.Library.CONFIDENCE, "") @Before fun setup() { + mockkStatic(Base64::class) + every { Base64.encodeToString(any(), any()) } answers { + java.util.Base64.getEncoder().encodeToString(firstArg()) + } mockWebServer.start() } @After fun tearDown() { mockWebServer.shutdown() + unmockkStatic(Base64::class) } @Test @@ -112,7 +120,7 @@ internal class ConfidenceRemoteClientTests { baseUrl = mockWebServer.url("/v1/flags:resolve"), dispatcher = testDispatcher, httpClient = OkHttpClient(), - sdkMetadata = SdkMetadata("", "") + telemetry = Telemetry("", Telemetry.Library.CONFIDENCE, "") ) .resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) val expectedFlags = Flags( @@ -174,7 +182,7 @@ internal class ConfidenceRemoteClientTests { baseUrl = mockWebServer.url("/v1/flags:resolve"), dispatcher = testDispatcher, httpClient = OkHttpClient(), - sdkMetadata = SdkMetadata("", "") + telemetry = Telemetry("", Telemetry.Library.CONFIDENCE, "") ).resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) val expectedParsed = ResolveFlags( Flags( @@ -230,7 +238,7 @@ internal class ConfidenceRemoteClientTests { baseUrl = mockWebServer.url("/v1/flags:resolve"), dispatcher = testDispatcher, httpClient = OkHttpClient(), - sdkMetadata = SdkMetadata("", "") + telemetry = Telemetry("", Telemetry.Library.CONFIDENCE, "") ) .resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) val expectedParsed = ResolveFlags( @@ -290,7 +298,7 @@ internal class ConfidenceRemoteClientTests { baseUrl = mockWebServer.url("/v1/flags:resolve"), dispatcher = testDispatcher, httpClient = OkHttpClient(), - sdkMetadata = SdkMetadata("", "") + telemetry = Telemetry("", Telemetry.Library.CONFIDENCE, "") ) .resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) } @@ -336,7 +344,7 @@ internal class ConfidenceRemoteClientTests { baseUrl = mockWebServer.url("/v1/flags:resolve"), dispatcher = testDispatcher, httpClient = OkHttpClient(), - sdkMetadata = SdkMetadata("", "") + telemetry = Telemetry("", Telemetry.Library.CONFIDENCE, "") ) .resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) } @@ -378,7 +386,7 @@ internal class ConfidenceRemoteClientTests { baseUrl = mockWebServer.url("/v1/flags:resolve"), dispatcher = testDispatcher, httpClient = OkHttpClient(), - sdkMetadata = SdkMetadata("", "") + telemetry = Telemetry("", Telemetry.Library.CONFIDENCE, "") ) .resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) } @@ -422,7 +430,7 @@ internal class ConfidenceRemoteClientTests { baseUrl = mockWebServer.url("/v1/flags:resolve"), dispatcher = testDispatcher, httpClient = OkHttpClient(), - sdkMetadata = SdkMetadata("", "") + telemetry = Telemetry("", Telemetry.Library.CONFIDENCE, "") ) .resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) } @@ -468,7 +476,7 @@ internal class ConfidenceRemoteClientTests { baseUrl = mockWebServer.url("/v1/flags:resolve"), dispatcher = testDispatcher, httpClient = OkHttpClient(), - sdkMetadata = SdkMetadata("", "") + telemetry = Telemetry("", Telemetry.Library.CONFIDENCE, "") ).resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) val flags = (parsedResponse as Result.Success).data.flags assertEquals(ConfidenceValue.Integer(400), flags[0].value["myinteger"]) @@ -512,7 +520,7 @@ internal class ConfidenceRemoteClientTests { baseUrl = mockWebServer.url("/v1/flags:resolve"), dispatcher = testDispatcher, httpClient = OkHttpClient(), - sdkMetadata = SdkMetadata("", "") + telemetry = Telemetry("", Telemetry.Library.CONFIDENCE, "") ).resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) val flags = (parsedResponse as Result.Success).data.flags assertEquals(ConfidenceValue.Integer(-5), flags[0].value["myinteger"]) @@ -556,7 +564,7 @@ internal class ConfidenceRemoteClientTests { baseUrl = mockWebServer.url("/v1/flags:resolve"), dispatcher = testDispatcher, httpClient = OkHttpClient(), - sdkMetadata = SdkMetadata("", "") + telemetry = Telemetry("", Telemetry.Library.CONFIDENCE, "") ).resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) val flags = (parsedResponse as Result.Success).data.flags assertEquals(ConfidenceValue.Integer(0), flags[0].value["myinteger"]) @@ -626,7 +634,7 @@ internal class ConfidenceRemoteClientTests { baseUrl = mockWebServer.url("/v1/flags:resolve"), dispatcher = testDispatcher, httpClient = OkHttpClient(), - sdkMetadata = SdkMetadata("", "") + telemetry = Telemetry("", Telemetry.Library.CONFIDENCE, "") ).resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) val values = (parsedResponse as Result.Success).data.flags[0].value @@ -700,8 +708,9 @@ internal class ConfidenceRemoteClientTests { baseUrl = mockWebServer.url("/v1/flags:resolve"), dispatcher = testDispatcher, httpClient = OkHttpClient(), - sdkMetadata = SdkMetadata( + telemetry = Telemetry( "SDK_ID_KOTLIN_PROVIDER_TEST", + Telemetry.Library.CONFIDENCE, "" ) ) @@ -761,7 +770,7 @@ internal class ConfidenceRemoteClientTests { } FlagApplierClientImpl( "secret1", - sdkMetadata, + Telemetry(SDK_ID + "_TEST", Telemetry.Library.CONFIDENCE, ""), mockWebServer.url("/v1/flags:apply"), mockClock, dispatcher = testDispatcher @@ -769,6 +778,76 @@ internal class ConfidenceRemoteClientTests { .apply(listOf(AppliedFlag("flag1", applyDate)), "token1") } + @Test + fun testApplyRequestIncludesTelemetryHeader() = runTest { + val testDispatcher = UnconfinedTestDispatcher(testScheduler) + val applyDate = Date.from(Instant.parse("2023-03-01T14:01:46.123Z")) + val sendDate = Date.from(Instant.parse("2023-03-01T14:03:46.124Z")) + val mockClock: Clock = mock() + whenever(mockClock.currentTime()).thenReturn(sendDate) + + val telemetry = Telemetry(SDK_ID + "_TEST", Telemetry.Library.CONFIDENCE, "1.0.0") + // Pre-populate telemetry so the header will be present + telemetry.trackEvaluation( + Telemetry.EvaluationReason.TARGETING_MATCH, + Telemetry.EvaluationErrorCode.UNSPECIFIED + ) + + var recordedRequest: RecordedRequest? = null + mockWebServer.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + recordedRequest = request + return MockResponse().setResponseCode(200) + } + } + + FlagApplierClientImpl( + "secret1", + telemetry, + mockWebServer.url("/v1/flags:apply"), + mockClock, + dispatcher = testDispatcher + ).apply(listOf(AppliedFlag("flag1", applyDate)), "token1") + + val header = recordedRequest?.getHeader(Telemetry.HEADER_NAME) + assertTrue( + "Expected ${Telemetry.HEADER_NAME} header on apply request", + header != null && header.isNotEmpty() + ) + } + + @Test + fun testApplyRequestOmitsHeaderWhenNoTelemetry() = runTest { + val testDispatcher = UnconfinedTestDispatcher(testScheduler) + val applyDate = Date.from(Instant.parse("2023-03-01T14:01:46.123Z")) + val sendDate = Date.from(Instant.parse("2023-03-01T14:03:46.124Z")) + val mockClock: Clock = mock() + whenever(mockClock.currentTime()).thenReturn(sendDate) + + val telemetry = Telemetry(SDK_ID + "_TEST", Telemetry.Library.CONFIDENCE, "1.0.0") + + var recordedRequest: RecordedRequest? = null + mockWebServer.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + recordedRequest = request + return MockResponse().setResponseCode(200) + } + } + + FlagApplierClientImpl( + "secret1", + telemetry, + mockWebServer.url("/v1/flags:apply"), + mockClock, + dispatcher = testDispatcher + ).apply(listOf(AppliedFlag("flag1", applyDate)), "token1") + + assertTrue( + "No telemetry header expected when no events tracked", + recordedRequest?.getHeader(Telemetry.HEADER_NAME) == null + ) + } + @Test fun testApplyReturnsSuccessfulAfter200() = runTest { val testDispatcher = UnconfinedTestDispatcher(testScheduler) @@ -784,7 +863,7 @@ internal class ConfidenceRemoteClientTests { } val result = FlagApplierClientImpl( "secret1", - sdkMetadata, + testTelemetry, mockWebServer.url("/v1/flags:apply"), mockClock, dispatcher = testDispatcher @@ -809,7 +888,7 @@ internal class ConfidenceRemoteClientTests { } val result = FlagApplierClientImpl( "secret1", - sdkMetadata, + testTelemetry, mockWebServer.url("/v1/flags:apply"), mockClock, dispatcher = testDispatcher diff --git a/Confidence/src/test/java/com/spotify/confidence/FlagResolveNetworkIntegrationTest.kt b/Confidence/src/test/java/com/spotify/confidence/FlagResolveNetworkIntegrationTest.kt index 8c425f39..83ac3fa0 100644 --- a/Confidence/src/test/java/com/spotify/confidence/FlagResolveNetworkIntegrationTest.kt +++ b/Confidence/src/test/java/com/spotify/confidence/FlagResolveNetworkIntegrationTest.kt @@ -3,10 +3,17 @@ package com.spotify.confidence import android.content.Context +import android.util.Base64 import com.spotify.confidence.cache.FileDiskStorage -import com.spotify.confidence.client.SdkMetadata +import com.spotify.telemetry.v1.Types.LibraryTraces +import com.spotify.telemetry.v1.Types.Monitoring +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -21,6 +28,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.nio.file.Files +import com.spotify.telemetry.v1.Types.LibraryTraces.Trace.EvaluationTrace.EvaluationReason as ProtoReason private val resolveResponsePayload = """ { @@ -59,9 +67,14 @@ internal class FlagResolveNetworkIntegrationTest { private val flagApplierClient: com.spotify.confidence.client.FlagApplierClient = mock() private val mockContext: Context = mock() private lateinit var confidence: Confidence + private lateinit var telemetry: Telemetry @Before fun setup() = runTest { + mockkStatic(Base64::class) + every { Base64.encodeToString(any(), any()) } answers { + java.util.Base64.getEncoder().encodeToString(firstArg()) + } whenever(mockContext.filesDir).thenReturn(Files.createTempDirectory("tmpTests").toFile()) mockWebServer.start() val testDispatcher = UnconfinedTestDispatcher(testScheduler) @@ -74,13 +87,14 @@ internal class FlagResolveNetworkIntegrationTest { whenever(flagApplierClient.apply(any(), any())).thenReturn(Result.Success(Unit)) + telemetry = Telemetry("test", Telemetry.Library.CONFIDENCE, "0.0.0") val flagResolver = RemoteFlagResolver( clientSecret = "test-secret", region = ConfidenceRegion.GLOBAL, baseUrl = mockWebServer.url(""), dispatcher = testDispatcher, httpClient = OkHttpClient(), - sdkMetadata = SdkMetadata("test", "0.0.0") + telemetry = telemetry ) val context = mapOf("targeting_key" to ConfidenceValue.String("test-user")) @@ -94,13 +108,15 @@ internal class FlagResolveNetworkIntegrationTest { flagApplierClient = flagApplierClient, diskStorage = FileDiskStorage.create(mockContext), region = ConfidenceRegion.GLOBAL, - debugLogger = null + debugLogger = null, + telemetry = telemetry ) } @After fun tearDown() { mockWebServer.shutdown() + unmockkStatic(Base64::class) } @Test @@ -140,4 +156,132 @@ internal class FlagResolveNetworkIntegrationTest { assertEquals(ConfidenceValue.Integer(400), struct.map["int_property"]) assertEquals(ConfidenceValue.Null, struct.map["bool_property"]) } + + @Test + fun testGetFlagTracksEvaluationTelemetry() = runTest { + confidence.fetchAndActivate() + advanceUntilIdle() + + // Flush any pending telemetry from the resolve call + telemetry.encodedHeaderValue() + + // Evaluate flags - each getFlag should track an evaluation + confidence.getFlag("test-flag.str_property", "default") + confidence.getFlag("test-flag.int_property", 0) + confidence.getFlag("nonexistent-flag", "default") // FLAG_NOT_FOUND + + // Snapshot the telemetry and decode with real protobuf + val headerValue = telemetry.encodedHeaderValue() + assertNotNull("Expected telemetry after flag evaluations", headerValue) + + val monitoring = Monitoring.parseFrom(java.util.Base64.getDecoder().decode(headerValue)) + val traces = monitoring.getLibraryTraces(0).tracesList + + assertEquals(3, traces.size) + + // First two evaluations: RESOLVE_REASON_MATCH, no error → TARGETING_MATCH + assertEquals(LibraryTraces.TraceId.TRACE_ID_FLAG_EVALUATION, traces[0].id) + assertEquals( + ProtoReason.EVALUATION_REASON_TARGETING_MATCH, + traces[0].evaluationTrace.reason + ) + + assertEquals(LibraryTraces.TraceId.TRACE_ID_FLAG_EVALUATION, traces[1].id) + assertEquals( + ProtoReason.EVALUATION_REASON_TARGETING_MATCH, + traces[1].evaluationTrace.reason + ) + + // Third evaluation: nonexistent flag → ERROR with FLAG_NOT_FOUND + assertEquals(LibraryTraces.TraceId.TRACE_ID_FLAG_EVALUATION, traces[2].id) + assertEquals(ProtoReason.EVALUATION_REASON_ERROR, traces[2].evaluationTrace.reason) + } + + @Test + fun testGetFlagTelemetryAppearsOnNextResolve() = runTest { + confidence.fetchAndActivate() + advanceUntilIdle() + + // Consume the initial resolve request + mockWebServer.takeRequest() + + // Evaluate a flag → this should track telemetry + confidence.getFlag("test-flag.str_property", "default") + + // Enqueue a second resolve response, then trigger it + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(resolveResponsePayload) + ) + confidence.fetchAndActivate() + advanceUntilIdle() + + // The second resolve request should carry the evaluation + latency telemetry + val secondRequest = mockWebServer.takeRequest() + val header = secondRequest.getHeader(Telemetry.HEADER_NAME) + assertNotNull("Second resolve should carry telemetry from getFlag", header) + + val monitoring = Monitoring.parseFrom(java.util.Base64.getDecoder().decode(header)) + val traces = monitoring.getLibraryTraces(0).tracesList + + // Should have at least the evaluation trace and the latency from the first resolve + val evalTraces = traces.filter { + it.id == LibraryTraces.TraceId.TRACE_ID_FLAG_EVALUATION + } + val latencyTraces = traces.filter { + it.id == LibraryTraces.TraceId.TRACE_ID_RESOLVE_LATENCY + } + assertEquals("Expected 1 evaluation trace", 1, evalTraces.size) + assertEquals( + ProtoReason.EVALUATION_REASON_TARGETING_MATCH, + evalTraces[0].evaluationTrace.reason + ) + assertEquals("Expected 1 latency trace", 1, latencyTraces.size) + } + + @Test + fun testMultipleResolvesAccumulateLatencyTraces() = runTest { + // First fetchAndActivate (setup already enqueued a response) + confidence.fetchAndActivate() + advanceUntilIdle() + mockWebServer.takeRequest() + + // Flush the initial telemetry so we start clean + telemetry.encodedHeaderValue() + + // Do 3 more fetches - each adds a resolve latency trace. + // Each subsequent resolve flushes the previous one's latency via the header, + // so we verify the 4th request carries the 3rd resolve's latency. + repeat(3) { + mockWebServer.enqueue( + MockResponse().setResponseCode(200).setBody(resolveResponsePayload) + ) + confidence.fetchAndActivate() + advanceUntilIdle() + } + + // Consume the 3 resolve requests and inspect the last one + mockWebServer.takeRequest() // resolve #1: header carries nothing (we flushed) + mockWebServer.takeRequest() // resolve #2: header carries latency from #1 + val thirdRequest = mockWebServer.takeRequest() // resolve #3: header carries latency from #2 + + val header = thirdRequest.getHeader(Telemetry.HEADER_NAME) + assertNotNull("Third resolve should carry latency from second resolve", header) + + val monitoring = Monitoring.parseFrom(java.util.Base64.getDecoder().decode(header)) + val traces = monitoring.getLibraryTraces(0).tracesList + val latencyTraces = traces.filter { + it.id == LibraryTraces.TraceId.TRACE_ID_RESOLVE_LATENCY + } + assertEquals("Expected 1 latency trace (from previous resolve)", 1, latencyTraces.size) + assertTrue( + "Latency should be non-negative", + latencyTraces[0].requestTrace.millisecondDuration >= 0 + ) + + // The 3rd resolve's own latency is still pending in telemetry + val remaining = telemetry.encodedHeaderValue() + assertNotNull("Third resolve latency should still be pending", remaining) + } } diff --git a/Confidence/src/test/java/com/spotify/confidence/TelemetryTest.kt b/Confidence/src/test/java/com/spotify/confidence/TelemetryTest.kt new file mode 100644 index 00000000..b45bed55 --- /dev/null +++ b/Confidence/src/test/java/com/spotify/confidence/TelemetryTest.kt @@ -0,0 +1,756 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.spotify.confidence + +import android.util.Base64 +import com.spotify.telemetry.v1.Types.LibraryTraces +import com.spotify.telemetry.v1.Types.Monitoring +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import com.spotify.telemetry.v1.Types.LibraryTraces.Trace.EvaluationTrace.EvaluationErrorCode as ProtoErrorCode +import com.spotify.telemetry.v1.Types.LibraryTraces.Trace.EvaluationTrace.EvaluationReason as ProtoReason +import com.spotify.telemetry.v1.Types.LibraryTraces.Trace.RequestTrace.Status as ProtoStatus +import com.spotify.telemetry.v1.Types.Platform as ProtoPlatform + +private fun decodeMonitoring(headerValue: String): Monitoring { + val bytes = java.util.Base64.getDecoder().decode(headerValue) + return Monitoring.parseFrom(bytes) +} + +class TelemetryTest { + + @Before + fun setup() { + mockkStatic(Base64::class) + every { Base64.encodeToString(any(), any()) } answers { + java.util.Base64.getEncoder().encodeToString(firstArg()) + } + } + + @After + fun tearDown() { + unmockkStatic(Base64::class) + } + + // --- Reason mapping tests --- + + @Test + fun testMapMatch() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.RESOLVE_REASON_MATCH, + null + ) + assertEquals(Telemetry.EvaluationReason.TARGETING_MATCH, reason) + assertEquals(Telemetry.EvaluationErrorCode.UNSPECIFIED, errorCode) + } + + @Test + fun testMapNoSegmentMatch() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.RESOLVE_REASON_NO_SEGMENT_MATCH, + null + ) + assertEquals(Telemetry.EvaluationReason.DEFAULT, reason) + assertEquals(Telemetry.EvaluationErrorCode.UNSPECIFIED, errorCode) + } + + @Test + fun testMapNoTreatmentMatch() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.RESOLVE_REASON_NO_TREATMENT_MATCH, + null + ) + assertEquals(Telemetry.EvaluationReason.DEFAULT, reason) + assertEquals(Telemetry.EvaluationErrorCode.UNSPECIFIED, errorCode) + } + + @Test + fun testMapStale() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.RESOLVE_REASON_STALE, + null + ) + assertEquals(Telemetry.EvaluationReason.STALE, reason) + assertEquals(Telemetry.EvaluationErrorCode.UNSPECIFIED, errorCode) + } + + @Test + fun testMapFlagArchived() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.RESOLVE_REASON_FLAG_ARCHIVED, + null + ) + assertEquals(Telemetry.EvaluationReason.DISABLED, reason) + assertEquals(Telemetry.EvaluationErrorCode.UNSPECIFIED, errorCode) + } + + @Test + fun testMapTargetingKeyError() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.RESOLVE_REASON_TARGETING_KEY_ERROR, + null + ) + assertEquals(Telemetry.EvaluationReason.ERROR, reason) + assertEquals(Telemetry.EvaluationErrorCode.TARGETING_KEY_MISSING, errorCode) + } + + @Test + fun testMapError() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.ERROR, + null + ) + assertEquals(Telemetry.EvaluationReason.ERROR, reason) + assertEquals(Telemetry.EvaluationErrorCode.GENERAL, errorCode) + } + + @Test + fun testMapUnspecified() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.RESOLVE_REASON_UNSPECIFIED, + null + ) + assertEquals(Telemetry.EvaluationReason.UNSPECIFIED, reason) + assertEquals(Telemetry.EvaluationErrorCode.UNSPECIFIED, errorCode) + } + + @Test + fun testMapDefault() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.DEFAULT, + null + ) + assertEquals(Telemetry.EvaluationReason.UNSPECIFIED, reason) + assertEquals(Telemetry.EvaluationErrorCode.UNSPECIFIED, errorCode) + } + + // --- Error code mapping takes priority over reason --- + + @Test + fun testMapErrorCodeFlagNotFound() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.RESOLVE_REASON_MATCH, + ConfidenceError.ErrorCode.FLAG_NOT_FOUND + ) + assertEquals(Telemetry.EvaluationReason.ERROR, reason) + assertEquals(Telemetry.EvaluationErrorCode.FLAG_NOT_FOUND, errorCode) + } + + @Test + fun testMapErrorCodeParseError() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.RESOLVE_REASON_MATCH, + ConfidenceError.ErrorCode.PARSE_ERROR + ) + assertEquals(Telemetry.EvaluationReason.ERROR, reason) + assertEquals(Telemetry.EvaluationErrorCode.PARSE_ERROR, errorCode) + } + + @Test + fun testMapErrorCodeInvalidContext() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.RESOLVE_REASON_MATCH, + ConfidenceError.ErrorCode.INVALID_CONTEXT + ) + assertEquals(Telemetry.EvaluationReason.ERROR, reason) + assertEquals(Telemetry.EvaluationErrorCode.INVALID_CONTEXT, errorCode) + } + + @Test + fun testMapErrorCodeProviderNotReady() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.RESOLVE_REASON_MATCH, + ConfidenceError.ErrorCode.PROVIDER_NOT_READY + ) + assertEquals(Telemetry.EvaluationReason.ERROR, reason) + assertEquals(Telemetry.EvaluationErrorCode.PROVIDER_NOT_READY, errorCode) + } + + @Test + fun testMapErrorCodeResolveStale() { + val (reason, errorCode) = Telemetry.mapEvaluationReason( + ResolveReason.RESOLVE_REASON_MATCH, + ConfidenceError.ErrorCode.RESOLVE_STALE + ) + assertEquals(Telemetry.EvaluationReason.ERROR, reason) + assertEquals(Telemetry.EvaluationErrorCode.GENERAL, errorCode) + } + + // --- Snapshot and clear --- + + @Test + fun testEncodedHeaderValueReturnsNullWhenEmpty() { + val telemetry = Telemetry("test-sdk", Telemetry.Library.CONFIDENCE, "1.0.0") + assertNull(telemetry.encodedHeaderValue()) + } + + @Test + fun testSnapshotAndClear() { + val telemetry = Telemetry("test-sdk", Telemetry.Library.CONFIDENCE, "1.0.0") + telemetry.trackEvaluation( + Telemetry.EvaluationReason.TARGETING_MATCH, + Telemetry.EvaluationErrorCode.UNSPECIFIED + ) + telemetry.trackResolveLatency(150, Telemetry.RequestStatus.SUCCESS) + + assertNotNull(telemetry.encodedHeaderValue()) + assertNull(telemetry.encodedHeaderValue()) + } + + // --- Protobuf round-trip: encode then parse with generated code --- + + @Test + fun testEvaluationTraceRoundTrip() { + val telemetry = Telemetry("test-sdk", Telemetry.Library.CONFIDENCE, "1.0.0") + telemetry.trackEvaluation( + Telemetry.EvaluationReason.TARGETING_MATCH, + Telemetry.EvaluationErrorCode.UNSPECIFIED + ) + + val monitoring = decodeMonitoring(telemetry.encodedHeaderValue()) + + assertEquals(ProtoPlatform.PLATFORM_KOTLIN, monitoring.platform) + assertEquals(1, monitoring.libraryTracesCount) + + val lib = monitoring.getLibraryTraces(0) + assertEquals(LibraryTraces.Library.LIBRARY_CONFIDENCE, lib.library) + assertEquals("1.0.0", lib.libraryVersion) + assertEquals(1, lib.tracesCount) + + val trace = lib.getTraces(0) + assertEquals(LibraryTraces.TraceId.TRACE_ID_FLAG_EVALUATION, trace.id) + assertTrue(trace.hasEvaluationTrace()) + + val eval = trace.evaluationTrace + assertEquals(ProtoReason.EVALUATION_REASON_TARGETING_MATCH, eval.reason) + assertEquals(ProtoErrorCode.EVALUATION_ERROR_CODE_UNSPECIFIED, eval.errorCode) + } + + @Test + fun testResolveLatencyRoundTrip() { + val telemetry = Telemetry("test-sdk", Telemetry.Library.CONFIDENCE, "2.0.0") + telemetry.trackResolveLatency(142, Telemetry.RequestStatus.SUCCESS) + + val monitoring = decodeMonitoring(telemetry.encodedHeaderValue()) + + assertEquals(ProtoPlatform.PLATFORM_KOTLIN, monitoring.platform) + + val lib = monitoring.getLibraryTraces(0) + assertEquals(LibraryTraces.Library.LIBRARY_CONFIDENCE, lib.library) + assertEquals("2.0.0", lib.libraryVersion) + + val trace = lib.getTraces(0) + assertEquals(LibraryTraces.TraceId.TRACE_ID_RESOLVE_LATENCY, trace.id) + assertTrue(trace.hasRequestTrace()) + + val req = trace.requestTrace + assertEquals(142L, req.millisecondDuration) + assertEquals(ProtoStatus.STATUS_SUCCESS, req.status) + } + + @Test + fun testOpenFeatureLibraryRoundTrip() { + val telemetry = Telemetry("test-sdk", Telemetry.Library.OPEN_FEATURE, "3.0.0") + telemetry.trackEvaluation( + Telemetry.EvaluationReason.ERROR, + Telemetry.EvaluationErrorCode.FLAG_NOT_FOUND + ) + + val monitoring = decodeMonitoring(telemetry.encodedHeaderValue()) + val lib = monitoring.getLibraryTraces(0) + + assertEquals(LibraryTraces.Library.LIBRARY_OPEN_FEATURE, lib.library) + assertEquals("3.0.0", lib.libraryVersion) + + val eval = lib.getTraces(0).evaluationTrace + assertEquals(ProtoReason.EVALUATION_REASON_ERROR, eval.reason) + assertEquals(ProtoErrorCode.EVALUATION_ERROR_CODE_FLAG_NOT_FOUND, eval.errorCode) + } + + @Test + fun testMultipleTracesRoundTrip() { + val telemetry = Telemetry("test-sdk", Telemetry.Library.CONFIDENCE, "1.0.0") + telemetry.trackResolveLatency(100, Telemetry.RequestStatus.SUCCESS) + telemetry.trackResolveLatency(500, Telemetry.RequestStatus.TIMEOUT) + telemetry.trackEvaluation( + Telemetry.EvaluationReason.TARGETING_MATCH, + Telemetry.EvaluationErrorCode.UNSPECIFIED + ) + telemetry.trackEvaluation( + Telemetry.EvaluationReason.DISABLED, + Telemetry.EvaluationErrorCode.UNSPECIFIED + ) + + val monitoring = decodeMonitoring(telemetry.encodedHeaderValue()) + val traces = monitoring.getLibraryTraces(0).tracesList + assertEquals(4, traces.size) + + // Resolve latency traces first + assertEquals(LibraryTraces.TraceId.TRACE_ID_RESOLVE_LATENCY, traces[0].id) + assertEquals(100L, traces[0].requestTrace.millisecondDuration) + assertEquals(ProtoStatus.STATUS_SUCCESS, traces[0].requestTrace.status) + + assertEquals(LibraryTraces.TraceId.TRACE_ID_RESOLVE_LATENCY, traces[1].id) + assertEquals(500L, traces[1].requestTrace.millisecondDuration) + assertEquals(ProtoStatus.STATUS_TIMEOUT, traces[1].requestTrace.status) + + // Then evaluation traces + assertEquals(LibraryTraces.TraceId.TRACE_ID_FLAG_EVALUATION, traces[2].id) + assertEquals(ProtoReason.EVALUATION_REASON_TARGETING_MATCH, traces[2].evaluationTrace.reason) + + assertEquals(LibraryTraces.TraceId.TRACE_ID_FLAG_EVALUATION, traces[3].id) + assertEquals(ProtoReason.EVALUATION_REASON_DISABLED, traces[3].evaluationTrace.reason) + } + + @Test + fun testResolveLatencyErrorStatus() { + val telemetry = Telemetry("test-sdk", Telemetry.Library.CONFIDENCE, "1.0.0") + telemetry.trackResolveLatency(250, Telemetry.RequestStatus.ERROR) + + val monitoring = decodeMonitoring(telemetry.encodedHeaderValue()) + val req = monitoring.getLibraryTraces(0).getTraces(0).requestTrace + assertEquals(250L, req.millisecondDuration) + assertEquals(ProtoStatus.STATUS_ERROR, req.status) + } + + @Test + fun testAllEvaluationReasonsEncodeCorrectly() { + data class Case( + val reason: Telemetry.EvaluationReason, + val errorCode: Telemetry.EvaluationErrorCode, + val expectedReason: ProtoReason, + val expectedErrorCode: ProtoErrorCode + ) + + val cases = listOf( + Case( + Telemetry.EvaluationReason.UNSPECIFIED, + Telemetry.EvaluationErrorCode.UNSPECIFIED, + ProtoReason.EVALUATION_REASON_UNSPECIFIED, + ProtoErrorCode.EVALUATION_ERROR_CODE_UNSPECIFIED + ), + Case( + Telemetry.EvaluationReason.TARGETING_MATCH, + Telemetry.EvaluationErrorCode.UNSPECIFIED, + ProtoReason.EVALUATION_REASON_TARGETING_MATCH, + ProtoErrorCode.EVALUATION_ERROR_CODE_UNSPECIFIED + ), + Case( + Telemetry.EvaluationReason.DEFAULT, + Telemetry.EvaluationErrorCode.UNSPECIFIED, + ProtoReason.EVALUATION_REASON_DEFAULT, + ProtoErrorCode.EVALUATION_ERROR_CODE_UNSPECIFIED + ), + Case( + Telemetry.EvaluationReason.STALE, + Telemetry.EvaluationErrorCode.UNSPECIFIED, + ProtoReason.EVALUATION_REASON_STALE, + ProtoErrorCode.EVALUATION_ERROR_CODE_UNSPECIFIED + ), + Case( + Telemetry.EvaluationReason.DISABLED, + Telemetry.EvaluationErrorCode.UNSPECIFIED, + ProtoReason.EVALUATION_REASON_DISABLED, + ProtoErrorCode.EVALUATION_ERROR_CODE_UNSPECIFIED + ), + Case( + Telemetry.EvaluationReason.CACHED, + Telemetry.EvaluationErrorCode.UNSPECIFIED, + ProtoReason.EVALUATION_REASON_CACHED, + ProtoErrorCode.EVALUATION_ERROR_CODE_UNSPECIFIED + ), + Case( + Telemetry.EvaluationReason.STATIC, + Telemetry.EvaluationErrorCode.UNSPECIFIED, + ProtoReason.EVALUATION_REASON_STATIC, + ProtoErrorCode.EVALUATION_ERROR_CODE_UNSPECIFIED + ), + Case( + Telemetry.EvaluationReason.SPLIT, + Telemetry.EvaluationErrorCode.UNSPECIFIED, + ProtoReason.EVALUATION_REASON_SPLIT, + ProtoErrorCode.EVALUATION_ERROR_CODE_UNSPECIFIED + ), + Case( + Telemetry.EvaluationReason.ERROR, + Telemetry.EvaluationErrorCode.GENERAL, + ProtoReason.EVALUATION_REASON_ERROR, + ProtoErrorCode.EVALUATION_ERROR_CODE_GENERAL + ), + Case( + Telemetry.EvaluationReason.ERROR, + Telemetry.EvaluationErrorCode.PROVIDER_NOT_READY, + ProtoReason.EVALUATION_REASON_ERROR, + ProtoErrorCode.EVALUATION_ERROR_CODE_PROVIDER_NOT_READY + ), + Case( + Telemetry.EvaluationReason.ERROR, + Telemetry.EvaluationErrorCode.FLAG_NOT_FOUND, + ProtoReason.EVALUATION_REASON_ERROR, + ProtoErrorCode.EVALUATION_ERROR_CODE_FLAG_NOT_FOUND + ), + Case( + Telemetry.EvaluationReason.ERROR, + Telemetry.EvaluationErrorCode.PARSE_ERROR, + ProtoReason.EVALUATION_REASON_ERROR, + ProtoErrorCode.EVALUATION_ERROR_CODE_PARSE_ERROR + ), + Case( + Telemetry.EvaluationReason.ERROR, + Telemetry.EvaluationErrorCode.INVALID_CONTEXT, + ProtoReason.EVALUATION_REASON_ERROR, + ProtoErrorCode.EVALUATION_ERROR_CODE_INVALID_CONTEXT + ), + Case( + Telemetry.EvaluationReason.ERROR, + Telemetry.EvaluationErrorCode.TARGETING_KEY_MISSING, + ProtoReason.EVALUATION_REASON_ERROR, + ProtoErrorCode.EVALUATION_ERROR_CODE_TARGETING_KEY_MISSING + ), + Case( + Telemetry.EvaluationReason.ERROR, + Telemetry.EvaluationErrorCode.TYPE_MISMATCH, + ProtoReason.EVALUATION_REASON_ERROR, + ProtoErrorCode.EVALUATION_ERROR_CODE_TYPE_MISMATCH + ), + Case( + Telemetry.EvaluationReason.ERROR, + Telemetry.EvaluationErrorCode.PROVIDER_FATAL, + ProtoReason.EVALUATION_REASON_ERROR, + ProtoErrorCode.EVALUATION_ERROR_CODE_PROVIDER_FATAL + ) + ) + + for (case in cases) { + val telemetry = Telemetry("test-sdk", Telemetry.Library.CONFIDENCE, "1.0.0") + telemetry.trackEvaluation(case.reason, case.errorCode) + + val monitoring = decodeMonitoring(telemetry.encodedHeaderValue()) + val eval = monitoring.getLibraryTraces(0).getTraces(0).evaluationTrace + + assertEquals("reason for ${case.reason}", case.expectedReason, eval.reason) + assertEquals("errorCode for ${case.errorCode}", case.expectedErrorCode, eval.errorCode) + } + } + + // --- Library attribution --- + + @Test + fun testDefaultLibraryIsConfidence() { + assertEquals( + Telemetry.Library.CONFIDENCE, + Telemetry("x", Telemetry.Library.CONFIDENCE, "x").library + ) + } + + @Test + fun testLibraryCanBeChangedToOpenFeature() { + val telemetry = Telemetry("x", Telemetry.Library.CONFIDENCE, "x") + telemetry.library = Telemetry.Library.OPEN_FEATURE + assertEquals(Telemetry.Library.OPEN_FEATURE, telemetry.library) + } + + @Test + fun testSetTelemetryLibraryOpenFeatureViaReflection() { + val confidence = Confidence( + clientSecret = "", + dispatcher = kotlinx.coroutines.Dispatchers.Unconfined, + eventSenderEngine = mock(), + diskStorage = mock(), + flagResolver = mock(), + flagApplierClient = mock(), + debugLogger = null + ) + + assertEquals(Telemetry.Library.CONFIDENCE, confidence.telemetry.library) + + val method = confidence.javaClass.getDeclaredMethod("setTelemetryLibraryOpenFeature") + method.isAccessible = true + method.invoke(confidence) + + assertEquals(Telemetry.Library.OPEN_FEATURE, confidence.telemetry.library) + + confidence.telemetry.trackEvaluation( + Telemetry.EvaluationReason.DEFAULT, + Telemetry.EvaluationErrorCode.UNSPECIFIED + ) + val monitoring = decodeMonitoring(confidence.telemetry.encodedHeaderValue()!!) + assertEquals( + LibraryTraces.Library.LIBRARY_OPEN_FEATURE, + monitoring.getLibraryTraces(0).library + ) + } + + @Test + fun testWithContextSharesTelemetryInstance() { + val confidence = Confidence( + clientSecret = "", + dispatcher = kotlinx.coroutines.Dispatchers.Unconfined, + eventSenderEngine = mock(), + diskStorage = mock(), + flagResolver = mock(), + flagApplierClient = mock(), + debugLogger = null + ) + + val child = confidence.withContext( + mapOf("key" to ConfidenceValue.String("value")) + ) as Confidence + + // Should be the exact same instance + assertTrue( + "withContext should share telemetry instance", + confidence.telemetry === child.telemetry + ) + } + + // --- SDK property --- + + @Test + fun testSdkProperty() { + val sdk = Telemetry("my-sdk-id", Telemetry.Library.CONFIDENCE, "2.0.0").sdk + assertEquals("my-sdk-id", sdk.id) + assertEquals("2.0.0", sdk.version) + } + + // --- Thread safety --- + + @Test + fun testConcurrentWrites() { + val telemetry = Telemetry("test-sdk", Telemetry.Library.CONFIDENCE, "1.0.0") + val threadCount = 10 + val iterationsPerThread = 100 + val executor = Executors.newFixedThreadPool(threadCount) + val latch = CountDownLatch(threadCount) + + for (i in 0 until threadCount) { + executor.submit { + try { + for (j in 0 until iterationsPerThread) { + telemetry.trackEvaluation( + Telemetry.EvaluationReason.TARGETING_MATCH, + Telemetry.EvaluationErrorCode.UNSPECIFIED + ) + telemetry.trackResolveLatency(50, Telemetry.RequestStatus.SUCCESS) + } + } finally { + latch.countDown() + } + } + } + + latch.await() + executor.shutdown() + + val monitoring = decodeMonitoring(telemetry.encodedHeaderValue()) + val traces = monitoring.getLibraryTraces(0).tracesList + // Each list is capped at 100, so max 200 total (100 eval + 100 latency) + assertTrue("Traces should be capped at 200", traces.size <= 200) + assertTrue("Should have some traces", traces.size > 0) + + assertNull(telemetry.encodedHeaderValue()) + } + + @Test + fun testTracesAreCappedAt100() { + val telemetry = Telemetry("test-sdk", Telemetry.Library.CONFIDENCE, "1.0.0") + repeat(150) { + telemetry.trackEvaluation( + Telemetry.EvaluationReason.TARGETING_MATCH, + Telemetry.EvaluationErrorCode.UNSPECIFIED + ) + } + repeat(150) { + telemetry.trackResolveLatency(50, Telemetry.RequestStatus.SUCCESS) + } + + val monitoring = decodeMonitoring(telemetry.encodedHeaderValue()) + val traces = monitoring.getLibraryTraces(0).tracesList + val evalTraces = traces.filter { + it.id == LibraryTraces.TraceId.TRACE_ID_FLAG_EVALUATION + } + val latencyTraces = traces.filter { + it.id == LibraryTraces.TraceId.TRACE_ID_RESOLVE_LATENCY + } + assertEquals("Evaluation traces capped at 100", 100, evalTraces.size) + assertEquals("Latency traces capped at 100", 100, latencyTraces.size) + } + + @Test + fun testConcurrentWritesAndReads() { + val telemetry = Telemetry("test-sdk", Telemetry.Library.CONFIDENCE, "1.0.0") + val threadCount = 5 + val iterationsPerThread = 50 + val executor = Executors.newFixedThreadPool(threadCount * 2) + val latch = CountDownLatch(threadCount * 2) + + for (i in 0 until threadCount) { + executor.submit { + try { + for (j in 0 until iterationsPerThread) { + telemetry.trackEvaluation( + Telemetry.EvaluationReason.DEFAULT, + Telemetry.EvaluationErrorCode.UNSPECIFIED + ) + } + } finally { + latch.countDown() + } + } + } + + for (i in 0 until threadCount) { + executor.submit { + try { + for (j in 0 until iterationsPerThread) { + telemetry.encodedHeaderValue() + } + } finally { + latch.countDown() + } + } + } + + latch.await() + executor.shutdown() + } + + // --- Wire-level: header on HTTP resolve requests --- + + private val emptyResolveResponse = """ + { "resolvedFlags": [], "resolveToken": "token1" } + """.trimIndent() + + @Test + fun testResolveRequestIncludesTelemetryHeader() = runTest { + val mockWebServer = MockWebServer() + mockWebServer.start() + try { + val testDispatcher = UnconfinedTestDispatcher(testScheduler) + val telemetry = Telemetry("test-sdk", Telemetry.Library.CONFIDENCE, "1.0.0") + + telemetry.trackEvaluation( + Telemetry.EvaluationReason.TARGETING_MATCH, + Telemetry.EvaluationErrorCode.UNSPECIFIED + ) + + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(emptyResolveResponse)) + + RemoteFlagResolver( + clientSecret = "", + region = ConfidenceRegion.GLOBAL, + baseUrl = mockWebServer.url("/v1/flags:resolve"), + dispatcher = testDispatcher, + httpClient = OkHttpClient(), + telemetry = telemetry + ).resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) + + val recorded = mockWebServer.takeRequest() + val headerValue = recorded.getHeader(Telemetry.HEADER_NAME) + assertNotNull("Expected ${Telemetry.HEADER_NAME} header", headerValue) + + val monitoring = decodeMonitoring(headerValue!!) + + assertEquals(ProtoPlatform.PLATFORM_KOTLIN, monitoring.platform) + + val lib = monitoring.getLibraryTraces(0) + assertEquals(LibraryTraces.Library.LIBRARY_CONFIDENCE, lib.library) + assertEquals("1.0.0", lib.libraryVersion) + + val trace = lib.getTraces(0) + assertEquals(LibraryTraces.TraceId.TRACE_ID_FLAG_EVALUATION, trace.id) + assertEquals( + ProtoReason.EVALUATION_REASON_TARGETING_MATCH, + trace.evaluationTrace.reason + ) + } finally { + mockWebServer.shutdown() + } + } + + @Test + fun testResolveRequestOmitsHeaderWhenNoTelemetry() = runTest { + val mockWebServer = MockWebServer() + mockWebServer.start() + try { + val testDispatcher = UnconfinedTestDispatcher(testScheduler) + val telemetry = Telemetry("test-sdk", Telemetry.Library.CONFIDENCE, "1.0.0") + + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(emptyResolveResponse)) + + RemoteFlagResolver( + clientSecret = "", + region = ConfidenceRegion.GLOBAL, + baseUrl = mockWebServer.url("/v1/flags:resolve"), + dispatcher = testDispatcher, + httpClient = OkHttpClient(), + telemetry = telemetry + ).resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) + + assertNull(mockWebServer.takeRequest().getHeader(Telemetry.HEADER_NAME)) + } finally { + mockWebServer.shutdown() + } + } + + @Test + fun testResolveFlushLifecycle() = runTest { + val mockWebServer = MockWebServer() + mockWebServer.start() + try { + val testDispatcher = UnconfinedTestDispatcher(testScheduler) + val telemetry = Telemetry("test-sdk", Telemetry.Library.CONFIDENCE, "1.0.0") + + telemetry.trackEvaluation( + Telemetry.EvaluationReason.ERROR, + Telemetry.EvaluationErrorCode.FLAG_NOT_FOUND + ) + + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(emptyResolveResponse)) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(emptyResolveResponse)) + + val resolver = RemoteFlagResolver( + clientSecret = "", + region = ConfidenceRegion.GLOBAL, + baseUrl = mockWebServer.url("/v1/flags:resolve"), + dispatcher = testDispatcher, + httpClient = OkHttpClient(), + telemetry = telemetry + ) + + // Call 1: carries the evaluation trace + resolver.resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) + val m1 = decodeMonitoring(mockWebServer.takeRequest().getHeader(Telemetry.HEADER_NAME)!!) + val traces1 = m1.getLibraryTraces(0).tracesList + assertEquals(1, traces1.size) + assertEquals(LibraryTraces.TraceId.TRACE_ID_FLAG_EVALUATION, traces1[0].id) + + // Call 2: evaluation was flushed; latency from call 1 is now pending + resolver.resolve(listOf(), mapOf("targeting_key" to ConfidenceValue.String("user1"))) + val m2 = decodeMonitoring(mockWebServer.takeRequest().getHeader(Telemetry.HEADER_NAME)!!) + val traces2 = m2.getLibraryTraces(0).tracesList + assertEquals(1, traces2.size) + assertEquals(LibraryTraces.TraceId.TRACE_ID_RESOLVE_LATENCY, traces2[0].id) + assertEquals(ProtoStatus.STATUS_SUCCESS, traces2[0].requestTrace.status) + } finally { + mockWebServer.shutdown() + } + } +} diff --git a/Confidence/src/test/proto/confidence/telemetry/v1/types.proto b/Confidence/src/test/proto/confidence/telemetry/v1/types.proto new file mode 100644 index 00000000..851db7b8 --- /dev/null +++ b/Confidence/src/test/proto/confidence/telemetry/v1/types.proto @@ -0,0 +1,99 @@ +syntax = "proto3"; + +package confidence.telemetry.v1; +option java_package = "com.spotify.telemetry.v1"; + +enum Platform { + PLATFORM_UNSPECIFIED = 0; + PLATFORM_JAVA = 1; + PLATFORM_KOTLIN = 2; + PLATFORM_SWIFT = 3; + PLATFORM_JS_WEB = 4; + PLATFORM_JS_SERVER = 5; + PLATFORM_PYTHON = 6; + PLATFORM_GO = 7; + PLATFORM_RUBY = 8; + PLATFORM_RUST = 9; + PLATFORM_FLUTTER_IOS = 10; + PLATFORM_FLUTTER_ANDROID = 11; +} + +message Monitoring { + repeated LibraryTraces library_traces = 1; + Platform platform = 2; +} + +message LibraryTraces { + Library library = 1; + string library_version = 2; + repeated Trace traces = 3; + + message Trace { + TraceId id = 1; + + oneof traceData { + uint64 millisecond_duration = 2 [deprecated = true]; + RequestTrace request_trace = 3; + CountTrace count_trace = 4; + EvaluationTrace evaluation_trace = 5; + } + + message CountTrace {} + + message RequestTrace { + uint64 millisecond_duration = 1; + Status status = 2; + + enum Status { + STATUS_UNSPECIFIED = 0; + STATUS_SUCCESS = 1; + STATUS_ERROR = 2; + STATUS_TIMEOUT = 3; + STATUS_CACHED = 4; + } + } + + message EvaluationTrace { + EvaluationReason reason = 1; + EvaluationErrorCode error_code = 2; + + enum EvaluationReason { + EVALUATION_REASON_UNSPECIFIED = 0; + EVALUATION_REASON_TARGETING_MATCH = 1; + EVALUATION_REASON_DEFAULT = 2; + EVALUATION_REASON_STALE = 3; + EVALUATION_REASON_DISABLED = 4; + EVALUATION_REASON_CACHED = 5; + EVALUATION_REASON_STATIC = 6; + EVALUATION_REASON_SPLIT = 7; + EVALUATION_REASON_ERROR = 8; + } + + enum EvaluationErrorCode { + EVALUATION_ERROR_CODE_UNSPECIFIED = 0; + EVALUATION_ERROR_CODE_PROVIDER_NOT_READY = 1; + EVALUATION_ERROR_CODE_FLAG_NOT_FOUND = 2; + EVALUATION_ERROR_CODE_PARSE_ERROR = 3; + EVALUATION_ERROR_CODE_TYPE_MISMATCH = 4; + EVALUATION_ERROR_CODE_TARGETING_KEY_MISSING = 5; + EVALUATION_ERROR_CODE_INVALID_CONTEXT = 6; + EVALUATION_ERROR_CODE_PROVIDER_FATAL = 7; + EVALUATION_ERROR_CODE_GENERAL = 8; + } + } + } + + enum Library { + LIBRARY_UNKNOWN = 0; + LIBRARY_CONFIDENCE = 1; + LIBRARY_OPEN_FEATURE = 2; + LIBRARY_REACT = 3; + } + + enum TraceId { + TRACE_ID_UNSPECIFIED = 0; + TRACE_ID_RESOLVE_LATENCY = 1; + TRACE_ID_STALE_FLAG = 2 [deprecated = true]; + TRACE_ID_FLAG_EVALUATION = 3; + } +} diff --git a/Provider/src/main/java/com/spotify/confidence/openfeature/ConfidenceFeatureProvider.kt b/Provider/src/main/java/com/spotify/confidence/openfeature/ConfidenceFeatureProvider.kt index efafb6f6..d7c48bac 100644 --- a/Provider/src/main/java/com/spotify/confidence/openfeature/ConfidenceFeatureProvider.kt +++ b/Provider/src/main/java/com/spotify/confidence/openfeature/ConfidenceFeatureProvider.kt @@ -137,6 +137,13 @@ class ConfidenceFeatureProvider private constructor( hooks: List> = listOf(), metadata: ProviderMetadata = ConfidenceMetadata() ): ConfidenceFeatureProvider { + try { + val method = confidence.javaClass.getDeclaredMethod("setTelemetryLibraryOpenFeature") + method.isAccessible = true + method.invoke(confidence) + } catch (_: Exception) { + // Best effort - telemetry will default to CONFIDENCE library + } return ConfidenceFeatureProvider( hooks = hooks, metadata = metadata, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0fb5d679..81296a94 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,10 @@ composeUi = "1.2.0" liveData = "1.2.0" lifecycle = "2.6.1" +# Protobuf +protobuf = "4.29.3" +protobufPlugin = "0.9.4" + # Test libs androidXJunitTest = "1.1.3" mockWebServer = "4.9.1" @@ -55,6 +59,8 @@ okHttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okHtt openFeatureSDK = { group = "dev.openfeature", name = "kotlin-sdk", version.ref = "openFeatureSDK" } uiTestManifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "composeUi" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +protobufJava = { group = "com.google.protobuf", name = "protobuf-java", version.ref = "protobuf" } +protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } [plugins] androidApplication = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -66,3 +72,4 @@ kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", versio kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } nexusPublish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexusPublish" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "composeCompiler" } +protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }