Skip to content

Commit 1ee69ac

Browse files
Bug 2045490 - Set experiments inactive when unenrolling (#7427)
Now when experiments unenroll at runtime, Glean's experiment state will be updated to mark the experiments as inactive. Post-(un)enrollment logic has all been refactored into a single function to ensure that that all enrollments and unenrollments are handled identically.
1 parent a9a997c commit 1ee69ac

5 files changed

Lines changed: 94 additions & 82 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
- Attempting to opt-out from an experiment that is not currently enrolled is now a no-op. ([#7399](https://github.com/mozilla/application-services/pull/7399))
2525
- All enrollment updates (opt-in, opt-out, reseting telemetry identifiers, unenrolling for pref conflicts) now trigger feature invalidation in Android clients. ([#7405](https://github.com/mozilla/application-services/pull/7405))
2626
- Changing experiment and/or rollout participation no longer triggers a double update. Enrollment change telemetry is now appropriately triggered when this occurs. ([#7401](https://github.com/mozilla/application-services/pull/7401))
27+
- Experiments will be marked as inactive in Glean when unenrolling. ([#7427](https://github.com/mozilla/application-services/pull/7427))
2728

2829
## ✨ What's New ✨
2930

components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt

Lines changed: 75 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ open class Nimbus(
286286
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
287287
internal fun initializeOnThisThread() = withCatchAll("initialize") {
288288
nimbusClient.initialize()
289-
postEnrolmentCalculation(initial = true)
289+
postEnrolmentCalculation(emptyList(), initial = true)
290290
}
291291

292292
override fun fetchExperiments() {
@@ -333,33 +333,31 @@ open class Nimbus(
333333
}
334334
}
335335

336-
override fun applyPendingExperiments(): Job =
336+
override fun applyPendingExperiments(initial: Boolean): Job =
337337
dbScope.launch {
338338
withContext(NonCancellable) {
339-
applyPendingExperimentsOnThisThread()
339+
applyPendingExperimentsOnThisThread(initial)
340340
}
341341
}
342342

343343
@WorkerThread
344344
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
345-
internal fun applyPendingExperimentsOnThisThread() = withCatchAll("applyPendingExperiments") {
346-
try {
347-
var events: List<EnrollmentChangeEvent>?
348-
val time = measureTimeMillis {
349-
events = nimbusClient.applyPendingExperiments()
345+
internal fun applyPendingExperimentsOnThisThread(initial: Boolean = false) {
346+
withCatchAll("applyPendingExperiments") {
347+
try {
348+
var enrollmentChangeEvents: List<EnrollmentChangeEvent>?
349+
val time = measureTimeMillis {
350+
enrollmentChangeEvents = nimbusClient.applyPendingExperiments()
351+
}
352+
NimbusHealth.applyPendingExperimentsTime.accumulateSingleSample(time)
353+
354+
// SAFETY: events is only null at declaration time and is
355+
// immediately assigned a non-null value inside the
356+
// measureTimeMillis lambda.
357+
postEnrolmentCalculation(enrollmentChangeEvents!!, initial)
358+
} catch (e: NimbusException.InvalidExperimentFormat) {
359+
reportError("Invalid experiment format", e)
350360
}
351-
NimbusHealth.applyPendingExperimentsTime.accumulateSingleSample(time)
352-
353-
// SAFETY: events is only null at declaration time and is
354-
// immediately assigned a non-null value inside the
355-
// measureTimeMillis lambda.
356-
recordExperimentTelemetryEvents(events!!)
357-
358-
// Get the experiments to record in telemetry
359-
postEnrolmentCalculation()
360-
updateDispatcher.notifyChanged(events)
361-
} catch (e: NimbusException.InvalidExperimentFormat) {
362-
reportError("Invalid experiment format", e)
363361
}
364362
}
365363

@@ -395,31 +393,52 @@ open class Nimbus(
395393
withContext(NonCancellable) {
396394
if (experimentsJson != null) {
397395
setExperimentsLocallyOnThisThread(experimentsJson)
398-
applyPendingExperimentsOnThisThread()
396+
applyPendingExperimentsOnThisThread(initial = true)
399397
} else {
400398
initializeOnThisThread()
401399
}
402400
}
403401
}
404402

405403
@WorkerThread
406-
private fun postEnrolmentCalculation(initial: Boolean = false) {
407-
nimbusClient.getActiveExperiments().also { experiments ->
408-
recordExperimentTelemetry(experiments)
404+
private fun postEnrolmentCalculation(
405+
enrollmentChangeEvents: List<EnrollmentChangeEvent>,
406+
initial: Boolean = false,
407+
) {
408+
val experiments = nimbusClient.getActiveExperiments()
409+
410+
if (initial) {
411+
// During initialization we need to report the experiment status of
412+
// all pre-existing experiments.
413+
for (experiment in experiments) {
414+
Glean.setExperimentActive(experiment.slug, experiment.branchSlug)
415+
}
416+
}
417+
418+
if (initial || enrollmentChangeEvents.isNotEmpty()) {
419+
// During initialization we need to inform the application when we've
420+
// finished applying updates, even if there is nothing enrolled.
421+
recordExperimentTelemetryEvents(enrollmentChangeEvents)
409422
updateObserver { observer ->
410423
observer.onUpdatesApplied(experiments)
411424
}
425+
}
412426

413-
if (initial) {
414-
val featureIds = mutableSetOf<String>()
415-
for (experiment in experiments) {
416-
for (featureId in experiment.featureIds) {
417-
featureIds.add(featureId)
418-
}
427+
if (initial) {
428+
// Likewise, during initialization we need to include trigger
429+
// updates for all pre-existing experiments.
430+
val featureIds = mutableSetOf<String>()
431+
for (experiment in experiments) {
432+
for (featureId in experiment.featureIds) {
433+
featureIds.add(featureId)
419434
}
420-
421-
updateDispatcher.notifyFeatures(featureIds)
422435
}
436+
437+
updateDispatcher.notifyFeatures(featureIds)
438+
} else {
439+
// However, during subsequent enrollment changes we only need to
440+
// trigger updates to features that changed.
441+
updateDispatcher.notifyChanged(enrollmentChangeEvents)
423442
}
424443
}
425444

@@ -445,24 +464,16 @@ open class Nimbus(
445464
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
446465
internal fun setExperimentParticipationOnThisThread(active: Boolean) =
447466
withCatchAll("setExperimentParticipation") {
448-
val enrolmentChanges = nimbusClient.setExperimentParticipation(active)
449-
if (enrolmentChanges.isNotEmpty()) {
450-
recordExperimentTelemetryEvents(enrolmentChanges)
451-
postEnrolmentCalculation()
452-
updateDispatcher.notifyChanged(enrolmentChanges)
453-
}
467+
val enrollmentChangeEvents = nimbusClient.setExperimentParticipation(active)
468+
postEnrolmentCalculation(enrollmentChangeEvents)
454469
}
455470

456471
@WorkerThread
457472
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
458473
internal fun setRolloutParticipationOnThisThread(active: Boolean) =
459474
withCatchAll("setRolloutParticipation") {
460-
val enrolmentChanges = nimbusClient.setRolloutParticipation(active)
461-
if (enrolmentChanges.isNotEmpty()) {
462-
recordExperimentTelemetryEvents(enrolmentChanges)
463-
postEnrolmentCalculation()
464-
updateDispatcher.notifyChanged(enrolmentChanges)
465-
}
475+
val enrollmentChangeEvents = nimbusClient.setRolloutParticipation(active)
476+
postEnrolmentCalculation(enrollmentChangeEvents)
466477
}
467478

468479
override fun optOut(experimentId: String) {
@@ -489,9 +500,7 @@ open class Nimbus(
489500
): List<EnrollmentChangeEvent>? {
490501
return withCatchAll("unenrollForGeckoPref") {
491502
val enrollmentChangeEvents = nimbusClient.unenrollForGeckoPref(geckoPrefState, prefUnenrollReason)
492-
recordExperimentTelemetryEvents(enrollmentChangeEvents)
493-
postEnrolmentCalculation()
494-
updateDispatcher.notifyChanged(enrollmentChangeEvents)
503+
postEnrolmentCalculation(enrollmentChangeEvents)
495504
enrollmentChangeEvents
496505
}
497506
}
@@ -521,9 +530,7 @@ open class Nimbus(
521530
internal fun optOutOnThisThread(experimentId: String) {
522531
withCatchAll("optOut") {
523532
val enrollmentChangeEvents = nimbusClient.optOut(experimentId)
524-
recordExperimentTelemetryEvents(enrollmentChangeEvents)
525-
postEnrolmentCalculation()
526-
updateDispatcher.notifyChanged(enrollmentChangeEvents)
533+
postEnrolmentCalculation(enrollmentChangeEvents)
527534
}
528535
}
529536

@@ -539,9 +546,7 @@ open class Nimbus(
539546
internal fun resetTelemetryIdentifiersOnThisThread() {
540547
withCatchAll("resetTelemetryIdentifiers") {
541548
val enrollmentChangeEvents = nimbusClient.resetTelemetryIdentifiers()
542-
recordExperimentTelemetryEvents(enrollmentChangeEvents)
543-
postEnrolmentCalculation()
544-
updateDispatcher.notifyChanged(enrollmentChangeEvents)
549+
postEnrolmentCalculation(enrollmentChangeEvents)
545550
}
546551
}
547552

@@ -557,9 +562,7 @@ open class Nimbus(
557562
internal fun optInWithBranchOnThisThread(experimentId: String, branch: String) {
558563
withCatchAll("optIn") {
559564
val enrollmentChangeEvents = nimbusClient.optInWithBranch(experimentId, branch)
560-
recordExperimentTelemetryEvents(enrollmentChangeEvents)
561-
postEnrolmentCalculation()
562-
updateDispatcher.notifyChanged(enrollmentChangeEvents)
565+
postEnrolmentCalculation(enrollmentChangeEvents)
563566
}
564567
}
565568

@@ -637,6 +640,11 @@ open class Nimbus(
637640
branch = event.branchSlug,
638641
),
639642
)
643+
644+
Glean.setExperimentActive(
645+
event.experimentSlug,
646+
event.branchSlug,
647+
)
640648
}
641649

642650
EnrollmentChangeEventType.DISQUALIFICATION -> {
@@ -646,6 +654,10 @@ open class Nimbus(
646654
branch = event.branchSlug,
647655
),
648656
)
657+
658+
Glean.setExperimentInactive(
659+
event.experimentSlug,
660+
)
649661
}
650662

651663
EnrollmentChangeEventType.UNENROLLMENT -> {
@@ -655,6 +667,10 @@ open class Nimbus(
655667
branch = event.branchSlug,
656668
),
657669
)
670+
671+
Glean.setExperimentInactive(
672+
event.experimentSlug,
673+
)
658674
}
659675

660676
EnrollmentChangeEventType.ENROLL_FAILED -> {
@@ -717,11 +733,7 @@ open class Nimbus(
717733
internal fun enrollInFirefoxLabOnThisThread(slug: String): FirefoxLabsEnrollStatus {
718734
return withCatchAll("enrollInFirefoxLab") {
719735
val result = nimbusClient.enrollInFirefoxLab(slug)
720-
if (result.enrollmentChangeEvents.isNotEmpty()) {
721-
recordExperimentTelemetryEvents(result.enrollmentChangeEvents)
722-
postEnrolmentCalculation()
723-
updateDispatcher.notifyChanged(result.enrollmentChangeEvents)
724-
}
736+
postEnrolmentCalculation(result.enrollmentChangeEvents)
725737

726738
result.status
727739
} ?: FirefoxLabsEnrollStatus.ERROR
@@ -735,11 +747,7 @@ open class Nimbus(
735747
internal fun unenrollFromFirefoxLabOnThisThread(slug: String): FirefoxLabsUnenrollStatus {
736748
return withCatchAll("unenrollFromFirefoxLab") {
737749
val result = nimbusClient.unenrollFromFirefoxLab(slug)
738-
if (result.enrollmentChangeEvents.isNotEmpty()) {
739-
recordExperimentTelemetryEvents(result.enrollmentChangeEvents)
740-
postEnrolmentCalculation()
741-
updateDispatcher.notifyChanged(result.enrollmentChangeEvents)
742-
}
750+
postEnrolmentCalculation(result.enrollmentChangeEvents)
743751

744752
result.status
745753
} ?: FirefoxLabsUnenrollStatus.ERROR
@@ -753,11 +761,7 @@ open class Nimbus(
753761
internal fun unenrollFromAllFirefoxLabsOnThisThread() {
754762
withCatchAll("unenrollFromAllFirefoxLabs") {
755763
val enrollmentChangeEvents = nimbusClient.unenrollFromAllFirefoxLabs()
756-
if (enrollmentChangeEvents.isNotEmpty()) {
757-
recordExperimentTelemetryEvents(enrollmentChangeEvents)
758-
postEnrolmentCalculation()
759-
updateDispatcher.notifyChanged(enrollmentChangeEvents)
760-
}
764+
postEnrolmentCalculation(enrollmentChangeEvents)
761765
}
762766
}
763767

components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/NimbusBuilder.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ abstract class AbstractNimbusBuilder<T : NimbusInterface>(val context: Context)
120120
val job = if (initialExperiments != null && (isFirstRun || isLocalBuild())) {
121121
applyLocalExperiments(initialExperiments!!)
122122
} else {
123-
applyPendingExperiments()
123+
applyPendingExperiments(initial = true)
124124
}
125125

126126
// We always want initialize Nimbus to happen ASAP and before any features (engine/UI)

components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/NimbusInterface.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,11 @@ interface NimbusInterface : FeaturesInterface, NimbusMessagingInterface, NimbusE
129129
* `setExperimentsLocally`, and then informs Glean of new experiment enrolment.
130130
*
131131
* Notifies `onUpdatesApplied` once enrolments are recalculated.
132+
*
133+
* @param initial Whether or not this is called during Nimbus initialization
134+
* by the NimbusBuilder.
132135
*/
133-
fun applyPendingExperiments(): Job = Job()
136+
fun applyPendingExperiments(initial: Boolean = false): Job = Job()
134137

135138
/**
136139
* Apply the set of local experiments.

components/nimbus/android/src/test/java/org/mozilla/experiments/nimbus/NimbusTests.kt

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -433,9 +433,13 @@ class NimbusTests {
433433
NimbusEvents.disqualification.testGetValue(),
434434
)
435435

436+
assertTrue(Glean.testIsExperimentActive("test-experiment"))
437+
436438
// Opt out of the specific experiment
437439
nimbus.optOutOnThisThread("test-experiment")
438440

441+
assertFalse(Glean.testIsExperimentActive("test-experiment"))
442+
439443
// Use the Glean test API to check that the valid event is present
440444
assertNotNull("Event must have a value", NimbusEvents.disqualification.testGetValue())
441445
val disqualificationEvents = NimbusEvents.disqualification.testGetValue()!!
@@ -450,7 +454,7 @@ class NimbusTests {
450454
}
451455

452456
@Test
453-
fun `toggling the global opt out generates the correct Glean event`() {
457+
fun `toggling the experiment opt out generates the correct Glean event`() {
454458
// Load the experiment in nimbus so and optIn so that it will be active. This is necessary
455459
// because recordExposure checks for active experiments before recording.
456460
nimbus.setUpTestExperiments(packageName, appInfo)
@@ -461,9 +465,13 @@ class NimbusTests {
461465
NimbusEvents.disqualification.testGetValue(),
462466
)
463467

468+
assertTrue(Glean.testIsExperimentActive("test-experiment"))
469+
464470
// Opt out of experiments but keep rollouts
465471
nimbus.setExperimentParticipationOnThisThread(false)
466472

473+
assertFalse(Glean.testIsExperimentActive("test-experiment"))
474+
467475
// Use the Glean test API to check that the valid event is present
468476
assertNotNull("Event must have a value", NimbusEvents.disqualification.testGetValue())
469477
val disqualificationEvents = NimbusEvents.disqualification.testGetValue()!!
@@ -915,15 +923,9 @@ class NimbusTests {
915923
val handler = TestGeckoPrefHandler()
916924

917925
val nimbus = createNimbus(geckoPrefHandler = handler)
926+
nimbus.setUpTestExperiments(packageName, appInfo)
918927

919-
suspend fun getString(): String {
920-
return testExperimentsJsonString(packageName, appInfo, "true")
921-
}
922-
923-
val job = nimbus.applyLocalExperiments(::getString)
924-
runBlocking {
925-
job.join()
926-
}
928+
assertTrue(Glean.testIsExperimentActive("test-experiment"))
927929

928930
assertEquals(1, handler.setValues?.size)
929931
assertEquals("42", handler.setValues?.get(0)?.enrollmentValue?.prefValue)
@@ -933,6 +935,8 @@ class NimbusTests {
933935
PrefUnenrollReason.FAILED_TO_SET,
934936
)
935937

938+
assertFalse(Glean.testIsExperimentActive("test-experiment"))
939+
936940
assertNotNull(events)
937941
assertEquals(1, events!!.size)
938942
assertEquals(EnrollmentChangeEventType.DISQUALIFICATION, events[0].change)

0 commit comments

Comments
 (0)