Skip to content

Commit

Permalink
feat: evaluation v2 (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
bgiori authored Feb 29, 2024
1 parent 1888914 commit 51ab836
Show file tree
Hide file tree
Showing 25 changed files with 435 additions and 310 deletions.
1 change: 0 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.serializationRuntime}")
implementation("com.squareup.okhttp3:okhttp:${Versions.okhttp}")
implementation("com.amplitude:evaluation-core:${Versions.evaluationCore}")
implementation("com.amplitude:evaluation-serialization:${Versions.evaluationSerialization}")
implementation("com.amplitude:java-sdk:${Versions.amplitudeAnalytics}")
implementation("org.json:json:${Versions.json}")
}
Expand Down
5 changes: 2 additions & 3 deletions buildSrc/src/main/kotlin/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ object Versions {
const val kotlinLint = "10.3.0"
const val serializationPlugin = "1.8.10"
const val serializationRuntime = "1.4.1"
const val json = "20201115"
const val json = "20231013"
const val okhttp = "4.12.0"
const val evaluationCore = "1.1.1"
const val evaluationSerialization = "1.1.1"
const val evaluationCore = "2.0.0-beta.2"
const val amplitudeAnalytics = "1.12.0"
const val mockk = "1.13.9"
}
10 changes: 0 additions & 10 deletions src/main/kotlin/AssignmentConfiguration.kt

This file was deleted.

25 changes: 25 additions & 0 deletions src/main/kotlin/ExperimentUser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ data class ExperimentUser internal constructor(
@JvmField val library: String? = null,
@JvmField val userProperties: Map<String, Any?>? = null,
@JvmField val cohortIds: Set<String>? = null,
@JvmField val groups: Map<String, Set<String>>? = null,
@JvmField val groupProperties: Map<String, Map<String, Map<String, Any?>>>? = null,
) {

/**
Expand Down Expand Up @@ -90,6 +92,8 @@ data class ExperimentUser internal constructor(
private var library: String? = null
private var userProperties: MutableMap<String, Any?>? = null
private var cohortIds: Set<String>? = null
private var groups: MutableMap<String, Set<String>>? = null
private var groupProperties: MutableMap<String, MutableMap<String, MutableMap<String, Any?>>>? = null

fun userId(userId: String?) = apply { this.userId = userId }
fun deviceId(deviceId: String?) = apply { this.deviceId = deviceId }
Expand Down Expand Up @@ -119,6 +123,25 @@ data class ExperimentUser internal constructor(
fun cohortIds(cohortIds: Set<String>?) = apply {
this.cohortIds = cohortIds
}
fun groups(groups: Map<String, Set<String>>?) = apply {
this.groups = groups?.toMutableMap()
}
fun group(groupType: String, groupName: String) = apply {
this.groups = (this.groups ?: mutableMapOf()).apply { put(groupType, setOf(groupName)) }
}
fun groupProperties(groupProperties: Map<String, Map<String, Map<String, Any?>>>?) = apply {
this.groupProperties = groupProperties?.mapValues { groupTypes ->
groupTypes.value.toMutableMap().mapValues { groupNames ->
groupNames.value.toMutableMap()
}.toMutableMap()
}?.toMutableMap()
}
fun groupProperty(groupType: String, groupName: String, key: String, value: Any?) = apply {
this.groupProperties = (this.groupProperties ?: mutableMapOf()).apply {
getOrPut(groupType) { mutableMapOf(groupName to mutableMapOf()) }
.getOrPut(groupName) { mutableMapOf(key to value) }[key] = value
}
}

fun build(): ExperimentUser {
return ExperimentUser(
Expand All @@ -139,6 +162,8 @@ data class ExperimentUser internal constructor(
library = library,
userProperties = userProperties,
cohortIds = cohortIds,
groups = groups,
groupProperties = groupProperties,
)
}
}
Expand Down
57 changes: 30 additions & 27 deletions src/main/kotlin/LocalEvaluationClient.kt
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
package com.amplitude.experiment

import com.amplitude.Amplitude
import com.amplitude.Options
import com.amplitude.experiment.assignment.AmplitudeAssignmentService
import com.amplitude.experiment.assignment.Assignment
import com.amplitude.experiment.assignment.AssignmentService
import com.amplitude.experiment.assignment.InMemoryAssignmentFilter
import com.amplitude.experiment.evaluation.EvaluationEngine
import com.amplitude.experiment.evaluation.EvaluationEngineImpl
import com.amplitude.experiment.evaluation.FLAG_TYPE_HOLDOUT_GROUP
import com.amplitude.experiment.evaluation.FLAG_TYPE_MUTUAL_EXCLUSION_GROUP
import com.amplitude.experiment.evaluation.FlagResult
import com.amplitude.experiment.evaluation.serialization.SerialVariant
import com.amplitude.experiment.evaluation.topologicalSort
import com.amplitude.experiment.flag.FlagConfigApiImpl
import com.amplitude.experiment.flag.FlagConfigService
import com.amplitude.experiment.flag.FlagConfigServiceConfig
import com.amplitude.experiment.flag.FlagConfigServiceImpl
import com.amplitude.experiment.util.Logger
import com.amplitude.experiment.util.Once
import com.amplitude.experiment.util.toSerialExperimentUser
import com.amplitude.experiment.util.toVariant
import com.amplitude.experiment.util.filterDefaultVariants
import com.amplitude.experiment.util.toEvaluationContext
import com.amplitude.experiment.util.toVariants
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
Expand All @@ -28,7 +28,7 @@ class LocalEvaluationClient internal constructor(
) {
private val startLock = Once()
private val httpClient = OkHttpClient()
private val assignmentService: AssignmentService? = createAssignmentService()
private val assignmentService: AssignmentService? = createAssignmentService(apiKey)
private val serverUrl: HttpUrl = config.serverUrl.toHttpUrl()
private val evaluation: EvaluationEngine = EvaluationEngineImpl()
private val flagConfigService: FlagConfigService = FlagConfigServiceImpl(
Expand All @@ -42,37 +42,40 @@ class LocalEvaluationClient internal constructor(
}
}

private fun createAssignmentService(): AssignmentService? {
private fun createAssignmentService(deploymentKey: String): AssignmentService? {
if (config.assignmentConfiguration == null) return null
return AmplitudeAssignmentService(
Amplitude.getInstance().apply {
Amplitude.getInstance(deploymentKey).apply {
init(config.assignmentConfiguration.apiKey)
setEventUploadThreshold(config.assignmentConfiguration.eventUploadThreshold)
setEventUploadPeriodMillis(config.assignmentConfiguration.eventUploadPeriodMillis)
useBatchMode(config.assignmentConfiguration.useBatchMode)
setOptions(Options().setMinIdLength(1))
setServerUrl(config.assignmentConfiguration.serverUrl)
},
InMemoryAssignmentFilter(config.assignmentConfiguration.cacheCapacity),
InMemoryAssignmentFilter(config.assignmentConfiguration.cacheCapacity)
)
}

@JvmOverloads
@Deprecated(
"Use the evaluateV2 method. EvaluateV2 returns variant objects with default values (e.g. null/off) if the user is evaluated, but not assigned a variant.",
ReplaceWith("evaluateV2(user, flagKeys)")
)
fun evaluate(user: ExperimentUser, flagKeys: List<String> = listOf()): Map<String, Variant> {
val flagConfigs = flagConfigService.getFlagConfigs().toList()
val flagResults = evaluation.evaluate(flagConfigs, user.toSerialExperimentUser().convert())
val assignmentResults = mutableMapOf<String, FlagResult>()
val results = flagResults.filter { entry ->
val isVariant = !entry.value.isDefaultVariant
val isIncluded = (flagKeys.isEmpty() || flagKeys.contains(entry.key))
val isDeployed = entry.value.deployed
if (isIncluded || entry.value.type == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP || entry.value.type == FLAG_TYPE_HOLDOUT_GROUP) {
assignmentResults[entry.key] = entry.value
}
isVariant && isIncluded && isDeployed
}.map { entry ->
entry.key to SerialVariant(entry.value.variant).toVariant()
}.toMap()
assignmentService?.track(Assignment(user, assignmentResults))
return results
return evaluateV2(user, flagKeys.toSet()).filterDefaultVariants()
}

@JvmOverloads
fun evaluateV2(user: ExperimentUser, flagKeys: Set<String> = setOf()): Map<String, Variant> {
val flagConfigs = flagConfigService.getFlagConfigs().toMap()
val sortedFlagConfigs = topologicalSort(flagConfigs, flagKeys)
if (sortedFlagConfigs.isEmpty()) {
return mapOf()
}
val evaluationResults = evaluation.evaluate(user.toEvaluationContext(), sortedFlagConfigs)
Logger.d("evaluate - user=$user, result=$evaluationResults")
val variants = evaluationResults.toVariants()
assignmentService?.track(Assignment(user, variants))
return variants
}
}
9 changes: 9 additions & 0 deletions src/main/kotlin/LocalEvaluationConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,12 @@ class LocalEvaluationConfig internal constructor(
"flagConfigPollerRequestTimeoutMillis=$flagConfigPollerRequestTimeoutMillis)"
}
}

data class AssignmentConfiguration(
val apiKey: String,
val cacheCapacity: Int = 65536,
val eventUploadThreshold: Int = 10,
val eventUploadPeriodMillis: Int = 10000,
val useBatchMode: Boolean = true,
val serverUrl: String = "https://api2.amplitude.com/2/httpapi",
)
18 changes: 7 additions & 11 deletions src/main/kotlin/RemoteEvaluationClient.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
package com.amplitude.experiment

import com.amplitude.experiment.evaluation.serialization.SerialVariant
import com.amplitude.experiment.evaluation.EvaluationVariant
import com.amplitude.experiment.util.BackoffConfig
import com.amplitude.experiment.util.FetchException
import com.amplitude.experiment.util.Logger
import com.amplitude.experiment.util.backoff
import com.amplitude.experiment.util.toSerialExperimentUser
import com.amplitude.experiment.util.json
import com.amplitude.experiment.util.toJson
import com.amplitude.experiment.util.toVariant
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl
Expand All @@ -23,10 +22,6 @@ import okio.IOException
import java.util.concurrent.CompletableFuture
import java.util.concurrent.TimeUnit

private val json = Json {
ignoreUnknownKeys = true
}

class RemoteEvaluationClient internal constructor(
private val apiKey: String,
private val config: RemoteEvaluationConfig = RemoteEvaluationConfig(),
Expand Down Expand Up @@ -70,11 +65,12 @@ class RemoteEvaluationClient internal constructor(
val libraryUser = user.copyToBuilder().library("experiment-jvm-server/$LIBRARY_VERSION").build()
Logger.d("Fetch variants for user: $libraryUser")
// Build request to fetch variants for the user
val body = json.encodeToString(libraryUser.toSerialExperimentUser())
val body = libraryUser.toJson()
.toByteArray(Charsets.UTF_8)
.toRequestBody("application/json".toMediaType())
val url = serverUrl.newBuilder()
.addPathSegments("sdk/vardata")
.addPathSegments("sdk/v2/vardata")
.addQueryParameter("v", "0")
.build()
val request = Request.Builder()
.post(body)
Expand Down Expand Up @@ -110,7 +106,7 @@ class RemoteEvaluationClient internal constructor(
}

internal fun parseRemoteResponse(jsonString: String): Map<String, Variant> =
json.decodeFromString<HashMap<String, SerialVariant>>(
json.decodeFromString<HashMap<String, EvaluationVariant>>(
jsonString
).mapValues { it.value.toVariant() }

Expand Down
15 changes: 15 additions & 0 deletions src/main/kotlin/Variant.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package com.amplitude.experiment

import com.amplitude.experiment.evaluation.EvaluationVariant

data class Variant @JvmOverloads constructor(
@JvmField val value: String? = null,
@JvmField val payload: Any? = null,
@JvmField val key: String? = null,
@JvmField val metadata: Map<String, Any?>? = null,
) {
companion object {
/**
Expand All @@ -25,3 +29,14 @@ data class Variant @JvmOverloads constructor(
fun valueEquals(variant: Variant?, value: String?) = variant?.value == value
}
}

internal fun Variant.isNullOrEmpty(): Boolean =
this.key == null && this.value == null && this.payload == null && this.metadata == null

internal fun EvaluationVariant.isDefaultVariant(): Boolean {
return metadata?.get("default") as? Boolean ?: false
}

internal fun Variant.isDefaultVariant(): Boolean {
return metadata?.get("default") as? Boolean ?: false
}
6 changes: 3 additions & 3 deletions src/main/kotlin/assignment/Assignment.kt
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
package com.amplitude.experiment.assignment

import com.amplitude.experiment.ExperimentUser
import com.amplitude.experiment.evaluation.FlagResult
import com.amplitude.experiment.Variant

internal const val DAY_MILLIS: Long = 24 * 60 * 60 * 1000

internal data class Assignment(
val user: ExperimentUser,
val results: Map<String, FlagResult>,
val results: Map<String, Variant>,
val timestamp: Long = System.currentTimeMillis(),
)

internal fun Assignment.canonicalize(): String {
val sb = StringBuilder().append(this.user.userId?.trim(), " ", this.user.deviceId?.trim(), " ")
for (key in this.results.keys.sorted()) {
val value = this.results[key]
sb.append(key.trim(), " ", value?.variant?.key?.trim(), " ")
sb.append(key.trim(), " ", value?.key?.trim(), " ")
}
return sb.toString()
}
3 changes: 0 additions & 3 deletions src/main/kotlin/assignment/AssignmentFilter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ internal class InMemoryAssignmentFilter(size: Int, ttlMillis: Long = DAY_MILLIS)
private val cache = Cache<String, Unit>(size, ttlMillis)

override fun shouldTrack(assignment: Assignment): Boolean {
if (assignment.results.isEmpty()) {
return false
}
val canonicalAssignment = assignment.canonicalize()
val track = cache[canonicalAssignment] == null
if (track) {
Expand Down
36 changes: 25 additions & 11 deletions src/main/kotlin/assignment/AssignmentService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ package com.amplitude.experiment.assignment

import com.amplitude.Amplitude
import com.amplitude.Event
import com.amplitude.experiment.evaluation.FLAG_TYPE_MUTUAL_EXCLUSION_GROUP
import org.json.JSONObject

private object FlagType {
const val RELEASE = "release"
const val EXPERIMENT = "experiment"
const val MUTUAL_EXCLUSION_GROUP = "mutual-exclusion-group"
const val HOLDOUT_GROUP = "holdout-group"
const val RELEASE_GROUP = "release-group"
}

internal interface AssignmentService {
fun track(assignment: Assignment)
}
Expand All @@ -27,29 +34,36 @@ internal fun Assignment.toAmplitudeEvent(): Event {
this.user.userId,
this.user.deviceId
)
if (!user.groups.isNullOrEmpty()) {
event.groups = JSONObject(user.groups)
}
event.eventProperties = JSONObject().apply {
for ((flagKey, result) in this@toAmplitudeEvent.results) {
put("$flagKey.variant", result.variant.key)
put("$flagKey.details", result.description)
for ((flagKey, variant) in this@toAmplitudeEvent.results) {
val version = variant.metadata?.get("flagVersion")
val segmentName = variant.metadata?.get("segmentName")
val details = "v$version rule:$segmentName"
put("$flagKey.variant", variant.key)
put("$flagKey.details", details)
}
}
event.userProperties = JSONObject().apply {
val set = JSONObject()
val unset = JSONObject()
for ((flagKey, result) in this@toAmplitudeEvent.results) {
if (result.type == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP) {
// Don't set user properties for mutual exclusion groups.
for ((flagKey, variant) in this@toAmplitudeEvent.results) {
val flagType = variant.metadata?.get("flagType") as? String
val default = variant.metadata?.get("default") as? Boolean ?: false
if (flagType == FlagType.MUTUAL_EXCLUSION_GROUP) {
// Dont set user properties for mutual exclusion groups.
continue
} else if (result.isDefaultVariant) {
} else if (default) {
unset.put("[Experiment] $flagKey", "-")
} else {
set.put("[Experiment] $flagKey", result.variant.key)
set.put("[Experiment] $flagKey", variant.key)
}
}
put("\$set", set)
put("\$unset", unset)
}
event.insertId =
"${this.user.userId} ${this.user.deviceId} ${this.canonicalize().hashCode()} ${this.timestamp / DAY_MILLIS}"
event.insertId = "${this.user.userId} ${this.user.deviceId} ${this.canonicalize().hashCode()} ${this.timestamp / DAY_MILLIS}"
return event
}
Loading

0 comments on commit 51ab836

Please sign in to comment.