mirror of
https://gitflic.ru/project/openide/openide.git
synced 2025-12-13 15:52:01 +07:00
[ab] IJPL-186222: Introduce a simpler API for running AB experiments
(cherry picked from commit d37a06ebb60306d38f9e5a04d98df6669f07946a) (cherry picked from commit f9ef6adbd996974ebedfe1f46e5d28e959ee8c01) IJ-CR-176469 GitOrigin-RevId: c98c80f169ec9f508971dd84a45a63050b0c101e
This commit is contained in:
committed by
intellij-monorepo-bot
parent
e20812f272
commit
2df6273b02
@@ -23,4 +23,32 @@ jvm_library(
|
|||||||
],
|
],
|
||||||
runtime_deps = [":experiment_resources"]
|
runtime_deps = [":experiment_resources"]
|
||||||
)
|
)
|
||||||
### auto-generated section `build intellij.platform.experiment` end
|
|
||||||
|
jvm_library(
|
||||||
|
name = "experiment_test_lib",
|
||||||
|
visibility = ["//visibility:public"],
|
||||||
|
srcs = glob(["test/**/*.kt", "test/**/*.java", "test/**/*.form"], allow_empty = True),
|
||||||
|
associates = [":experiment"],
|
||||||
|
deps = [
|
||||||
|
"@lib//:kotlin-stdlib",
|
||||||
|
"//platform/core-api:core",
|
||||||
|
"//platform/statistics",
|
||||||
|
"//platform/statistics:statistics_test_lib",
|
||||||
|
"//platform/editor-ui-api:editor-ui",
|
||||||
|
"//platform/projectModel-api:projectModel",
|
||||||
|
"//platform/projectModel-impl",
|
||||||
|
"//platform/platform-impl:ide-impl",
|
||||||
|
"@lib//:junit5",
|
||||||
|
],
|
||||||
|
runtime_deps = [":experiment_resources"]
|
||||||
|
)
|
||||||
|
### auto-generated section `build intellij.platform.experiment` end
|
||||||
|
|
||||||
|
### auto-generated section `test intellij.platform.experiment` start
|
||||||
|
load("@community//build:tests-options.bzl", "jps_test")
|
||||||
|
|
||||||
|
jps_test(
|
||||||
|
name = "experiment_test",
|
||||||
|
runtime_deps = [":experiment_test_lib"]
|
||||||
|
)
|
||||||
|
### auto-generated section `test intellij.platform.experiment` end
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
|
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
@@ -15,5 +16,6 @@
|
|||||||
<orderEntry type="module" module-name="intellij.platform.projectModel" />
|
<orderEntry type="module" module-name="intellij.platform.projectModel" />
|
||||||
<orderEntry type="module" module-name="intellij.platform.projectModel.impl" />
|
<orderEntry type="module" module-name="intellij.platform.projectModel.impl" />
|
||||||
<orderEntry type="module" module-name="intellij.platform.ide.impl" />
|
<orderEntry type="module" module-name="intellij.platform.ide.impl" />
|
||||||
|
<orderEntry type="library" scope="TEST" name="JUnit5" level="project" />
|
||||||
</component>
|
</component>
|
||||||
</module>
|
</module>
|
||||||
@@ -4,18 +4,16 @@ package com.intellij.platform.experiment.ab.demo
|
|||||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||||
import com.intellij.openapi.actionSystem.AnAction
|
import com.intellij.openapi.actionSystem.AnAction
|
||||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||||
import com.intellij.openapi.application.ApplicationManager
|
import com.intellij.platform.experiment.ab.impl.ABExperimentOption
|
||||||
import com.intellij.openapi.components.service
|
import com.intellij.platform.experiment.ab.impl.getUserBucketNumber
|
||||||
import com.intellij.platform.experiment.ab.impl.experiment.ABExperiment
|
import com.intellij.platform.experiment.ab.impl.reportableName
|
||||||
import com.intellij.platform.experiment.ab.impl.experiment.ABExperimentImpl
|
|
||||||
|
|
||||||
internal class ABExperimentDemoAction : AnAction() {
|
internal class ABExperimentDemoAction : AnAction() {
|
||||||
override fun actionPerformed(e: AnActionEvent) {
|
override fun actionPerformed(e: AnActionEvent) {
|
||||||
val service = ApplicationManager.getApplication().service<ABExperiment>() as ABExperimentImpl
|
val experimentValues = ABExperimentOption.entries.filter { it != ABExperimentOption.UNASSIGNED }
|
||||||
|
for (experimentValue in experimentValues) {
|
||||||
println("User experiment option is: " + service.getUserExperimentOption())
|
println("Experiment value: $experimentValue; reportable name: ${experimentValue.reportableName()}; user bucket: ${getUserBucketNumber()} is enabled: ${experimentValue.isEnabled()}")
|
||||||
println("User experiment option id is: " + service.getUserExperimentOptionId())
|
}
|
||||||
println("Is control experiment option enabled: " + service.isControlExperimentOptionEnabled())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
|
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||||
|
package com.intellij.platform.experiment.ab.impl
|
||||||
|
|
||||||
|
import com.intellij.internal.statistic.eventLog.fus.MachineIdManager
|
||||||
|
import com.intellij.openapi.application.ApplicationInfo.getInstance
|
||||||
|
import com.intellij.openapi.diagnostic.debug
|
||||||
|
import com.intellij.openapi.diagnostic.logger
|
||||||
|
import com.intellij.openapi.diagnostic.runAndLogException
|
||||||
|
import com.intellij.platform.experiment.ab.impl.ABExperimentOption.*
|
||||||
|
import com.intellij.platform.experiment.ab.impl.statistic.ABExperimentCountCollector
|
||||||
|
import org.jetbrains.annotations.VisibleForTesting
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete list of all available AB experiments.
|
||||||
|
*
|
||||||
|
* The plugins are welcome to use [ABExperimentOption.isEnabled] to check whether the experiment is enabled on the user's machine
|
||||||
|
*/
|
||||||
|
enum class ABExperimentOption {
|
||||||
|
EXPERIMENT_1,
|
||||||
|
EXPERIMENT_2,
|
||||||
|
EXPERIMENT_3,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A group for users which are not assigned to any experiment.
|
||||||
|
*/
|
||||||
|
UNASSIGNED;
|
||||||
|
|
||||||
|
fun isEnabled(): Boolean {
|
||||||
|
require(this != UNASSIGNED) {
|
||||||
|
"UNASSIGNED experiment option is not supposed to be used in the isEnabled() method"
|
||||||
|
}
|
||||||
|
val decision = thisUserDecision
|
||||||
|
ABExperimentCountCollector.logABExperimentOptionUsed(decision)
|
||||||
|
return isAllowed(decision.option) && decision.option == this && !decision.isControlGroup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total number of "containers" where users are distributed. Each user belongs exactly to one bucket.
|
||||||
|
* Each bucket has at most one experimental option enabled, which are controlled by [experimentsPartition].
|
||||||
|
*/
|
||||||
|
internal const val NUMBER_OF_BUCKETS: Int = 1024
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping of buckets to experiments.
|
||||||
|
* Each experiment is assigned a non-overlapping range of buckets in [0..[NUMBER_OF_BUCKETS])
|
||||||
|
* The buckets that are not assigned to any experiment are considered to be in the UNASSIGNED experiment (i.e., no experiments are enabled for them).
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val experimentsPartition: List<ExperimentAssignment> = listOf(
|
||||||
|
ExperimentAssignment(experiment = EXPERIMENT_1, experimentBuckets = (0 until 180).toSet(), controlBuckets = (180 until 256).toSet()),
|
||||||
|
ExperimentAssignment(experiment = EXPERIMENT_2, experimentBuckets = (256 until 384).toSet(), controlBuckets = (384 until 512).toSet()),
|
||||||
|
ExperimentAssignment(experiment = EXPERIMENT_3, experimentBuckets = (512 until 640).toSet(), controlBuckets = (640 until 768).toSet()),
|
||||||
|
// the rest belongs to the unassigned experiment
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method can be configured to allow options only in particular IDEs.
|
||||||
|
*/
|
||||||
|
fun isAllowed(option: ABExperimentOption): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= IMPLEMENTATION ====================
|
||||||
|
|
||||||
|
internal data class ABExperimentDecision(val option: ABExperimentOption, val isControlGroup: Boolean, val bucketNumber: Int)
|
||||||
|
|
||||||
|
internal fun ABExperimentOption.reportableName(): String {
|
||||||
|
return toString().lowercase().replace('_', '.')
|
||||||
|
}
|
||||||
|
|
||||||
|
private val thisUserDecision: ABExperimentDecision by lazy {
|
||||||
|
val currentBucket = getUserBucketNumber()
|
||||||
|
val option = experimentsPartition.find {
|
||||||
|
it.experimentBuckets.contains(currentBucket) || it.controlBuckets.contains(currentBucket)
|
||||||
|
} ?: return@lazy ABExperimentDecision(option = UNASSIGNED, isControlGroup = true, bucketNumber = currentBucket)
|
||||||
|
ABExperimentDecision(option = option.experiment, isControlGroup = option.controlBuckets.contains(currentBucket), bucketNumber = currentBucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ExperimentAssignment(val experiment: ABExperimentOption, val experimentBuckets: Set<Int>, val controlBuckets: Set<Int>)
|
||||||
|
|
||||||
|
internal fun getUserBucketNumber(): Int {
|
||||||
|
val overridingBucket = Integer.getInteger("ide.ab.test.overriding.bucket")
|
||||||
|
if (overridingBucket != null) {
|
||||||
|
LOG.info("Overriding bucket number: $overridingBucket")
|
||||||
|
return overridingBucket
|
||||||
|
}
|
||||||
|
val deviceId = LOG.runAndLogException {
|
||||||
|
MachineIdManager.getAnonymizedMachineId(getDeviceIdPurpose())
|
||||||
|
}
|
||||||
|
|
||||||
|
val bucketNumber = deviceId.hashCode().absoluteValue % NUMBER_OF_BUCKETS
|
||||||
|
LOG.debug { "User bucket number is: $bucketNumber." }
|
||||||
|
return bucketNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDeviceIdPurpose(): String {
|
||||||
|
return "A/B Experiment" + getInstance().shortVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LOG = logger<ABExperimentOption>()
|
||||||
@@ -4,47 +4,22 @@ package com.intellij.platform.experiment.ab.impl.statistic
|
|||||||
import com.intellij.internal.statistic.eventLog.EventLogGroup
|
import com.intellij.internal.statistic.eventLog.EventLogGroup
|
||||||
import com.intellij.internal.statistic.eventLog.events.EventFields
|
import com.intellij.internal.statistic.eventLog.events.EventFields
|
||||||
import com.intellij.internal.statistic.service.fus.collectors.CounterUsagesCollector
|
import com.intellij.internal.statistic.service.fus.collectors.CounterUsagesCollector
|
||||||
import com.intellij.platform.experiment.ab.impl.experiment.ABExperimentOptionId
|
import com.intellij.platform.experiment.ab.impl.ABExperimentDecision
|
||||||
import com.intellij.platform.experiment.ab.impl.experiment.OPTION_ID_FREE_GROUP
|
import com.intellij.platform.experiment.ab.impl.reportableName
|
||||||
import com.intellij.platform.experiment.ab.impl.experiment.getJbABExperimentOptionList
|
|
||||||
|
|
||||||
internal object ABExperimentCountCollector : CounterUsagesCollector() {
|
internal object ABExperimentCountCollector : CounterUsagesCollector() {
|
||||||
private val GROUP = EventLogGroup("experiment.ab", 3)
|
private val GROUP = EventLogGroup("experiment.ab", 4)
|
||||||
|
|
||||||
/**
|
|
||||||
* For the case when user enables a plugin and then disables it.
|
|
||||||
*
|
|
||||||
* When the plugin is disabled, then a corresponding option is missing and the validation rule will reject such an option id,
|
|
||||||
* because the option is not present at runtime.
|
|
||||||
*
|
|
||||||
* To overcome such cases, an original option id is replaced with an artificial one for statistics.
|
|
||||||
*/
|
|
||||||
internal val OPTION_ID_MISSING = ABExperimentOptionId("missing.option")
|
|
||||||
|
|
||||||
private val AB_EXPERIMENT_OPTION_USED = GROUP.registerEvent(
|
private val AB_EXPERIMENT_OPTION_USED = GROUP.registerEvent(
|
||||||
"option.used",
|
"option.used",
|
||||||
EventFields.StringValidatedByCustomRule("id", ABExperimentOptionIdValidationRule::class.java),
|
EventFields.StringValidatedByCustomRule("id", ABExperimentOptionIdValidationRule::class.java),
|
||||||
EventFields.Int("group"),
|
EventFields.Boolean("is_control_group"),
|
||||||
EventFields.Int("bucket")
|
EventFields.Int("bucket")
|
||||||
)
|
)
|
||||||
|
|
||||||
fun logABExperimentOptionUsed(userOptionId: ABExperimentOptionId?, userGroupNumber: Int, userBucketNumber: Int) {
|
fun logABExperimentOptionUsed(decision: ABExperimentDecision) {
|
||||||
if (userOptionId == null) {
|
val optionName = decision.option.reportableName()
|
||||||
return
|
AB_EXPERIMENT_OPTION_USED.log(optionName, decision.isControlGroup, decision.bucketNumber)
|
||||||
}
|
|
||||||
|
|
||||||
if (userOptionId == OPTION_ID_FREE_GROUP) {
|
|
||||||
AB_EXPERIMENT_OPTION_USED.log(userOptionId.value, userGroupNumber, userBucketNumber)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val option = getJbABExperimentOptionList().find { it.id.value == userOptionId.value }
|
|
||||||
if (option != null) {
|
|
||||||
AB_EXPERIMENT_OPTION_USED.log(option.id.value, userGroupNumber, userBucketNumber)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
AB_EXPERIMENT_OPTION_USED.log(OPTION_ID_MISSING.value, userGroupNumber, userBucketNumber)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getGroup(): EventLogGroup = GROUP
|
override fun getGroup(): EventLogGroup = GROUP
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ package com.intellij.platform.experiment.ab.impl.statistic
|
|||||||
import com.intellij.internal.statistic.eventLog.validator.ValidationResultType
|
import com.intellij.internal.statistic.eventLog.validator.ValidationResultType
|
||||||
import com.intellij.internal.statistic.eventLog.validator.rules.EventContext
|
import com.intellij.internal.statistic.eventLog.validator.rules.EventContext
|
||||||
import com.intellij.internal.statistic.eventLog.validator.rules.impl.CustomValidationRule
|
import com.intellij.internal.statistic.eventLog.validator.rules.impl.CustomValidationRule
|
||||||
import com.intellij.platform.experiment.ab.impl.experiment.OPTION_ID_FREE_GROUP
|
import com.intellij.platform.experiment.ab.impl.ABExperimentOption
|
||||||
import com.intellij.platform.experiment.ab.impl.experiment.getJbABExperimentOptionList
|
import com.intellij.platform.experiment.ab.impl.reportableName
|
||||||
import com.intellij.platform.experiment.ab.impl.statistic.ABExperimentCountCollector.OPTION_ID_MISSING
|
|
||||||
|
|
||||||
internal class ABExperimentOptionIdValidationRule : CustomValidationRule() {
|
internal class ABExperimentOptionIdValidationRule : CustomValidationRule() {
|
||||||
override fun getRuleId(): String = "ab_experiment_option_id"
|
override fun getRuleId(): String = "ab_experiment_option_id"
|
||||||
|
|
||||||
override fun doValidate(data: String, context: EventContext): ValidationResultType {
|
override fun doValidate(data: String, context: EventContext): ValidationResultType {
|
||||||
return if (getJbABExperimentOptionList().any { it.id.value == data } ||
|
val optionNames = ABExperimentOption.entries
|
||||||
data == OPTION_ID_FREE_GROUP.value ||
|
.filter { it != ABExperimentOption.UNASSIGNED }
|
||||||
data == OPTION_ID_MISSING.value) {
|
.map { it.reportableName() }
|
||||||
|
return if (data in optionNames) {
|
||||||
ValidationResultType.ACCEPTED
|
ValidationResultType.ACCEPTED
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||||
|
package com.intellij.platform.experiment.ab
|
||||||
|
|
||||||
|
import com.intellij.platform.experiment.ab.impl.NUMBER_OF_BUCKETS
|
||||||
|
import com.intellij.platform.experiment.ab.impl.experimentsPartition
|
||||||
|
import org.junit.jupiter.api.Assertions
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class ABExperimentSanityTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no intersections in experiment partitions`() {
|
||||||
|
for (experiment1 in experimentsPartition) {
|
||||||
|
for (experiment2 in experimentsPartition) {
|
||||||
|
if (experiment1.experiment == experiment2.experiment) {
|
||||||
|
assertEmptyIntersection(experiment1.controlBuckets, experiment2.experimentBuckets)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
assertEmptyIntersection(experiment1.experimentBuckets, experiment2.controlBuckets)
|
||||||
|
assertEmptyIntersection(experiment1.controlBuckets, experiment2.controlBuckets)
|
||||||
|
assertEmptyIntersection(experiment1.experimentBuckets, experiment2.experimentBuckets)
|
||||||
|
assertEmptyIntersection(experiment1.controlBuckets, experiment2.experimentBuckets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all ranges are within bounds`() {
|
||||||
|
for (experiment in experimentsPartition) {
|
||||||
|
assertWithinBounds(experiment.controlBuckets)
|
||||||
|
assertWithinBounds(experiment.experimentBuckets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertEmptyIntersection(range1: Set<*>, range2: Set<*>) {
|
||||||
|
Assertions.assertTrue(range1.intersect(range2).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertWithinBounds(range: Set<Int>) = Assertions.assertTrue(range.subtract(0 until NUMBER_OF_BUCKETS).isEmpty())
|
||||||
|
}
|
||||||
@@ -97,6 +97,7 @@ jvm_library(
|
|||||||
"//platform/whatsNew:whatsNew_test_lib",
|
"//platform/whatsNew:whatsNew_test_lib",
|
||||||
"//platform/util/coroutines",
|
"//platform/util/coroutines",
|
||||||
"//platform/experiment",
|
"//platform/experiment",
|
||||||
|
"//platform/experiment:experiment_test_lib",
|
||||||
"@lib//:jna",
|
"@lib//:jna",
|
||||||
"//python/services/system-python",
|
"//python/services/system-python",
|
||||||
"//python/services/system-python:system-python_test_lib",
|
"//python/services/system-python:system-python_test_lib",
|
||||||
|
|||||||
Reference in New Issue
Block a user