Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
- There are new separate DisqualifiedReason and NotEnrolledReason for global (experiment and rollout) opt-outs. ([#7400](https://github.com/mozilla/application-services/pull/7400))
- The Kotlin client now has per-feature update notifications. ([#7354](https://github.com/mozilla/application-services/pull/7354))
- The Nimbus client now understands Firefox Labs opt-ins and will not automatically enroll in them. ([#7403](https://github.com/mozilla/application-services/pull/7403))
- Firefox Labs support for Android. ([#7413](https://github.com/mozilla/application-services/pull/7413))

[Full Changelog](In progress)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEventType
import org.mozilla.experiments.nimbus.internal.EnrollmentStatusExtraDef
import org.mozilla.experiments.nimbus.internal.FeatureExposureExtraDef
import org.mozilla.experiments.nimbus.internal.FeatureUpdateDispatcher
import org.mozilla.experiments.nimbus.internal.FirefoxLabsEnrollStatus
import org.mozilla.experiments.nimbus.internal.FirefoxLabsMetadata
import org.mozilla.experiments.nimbus.internal.FirefoxLabsUnenrollStatus
import org.mozilla.experiments.nimbus.internal.GeckoPrefHandler
import org.mozilla.experiments.nimbus.internal.GeckoPrefState
import org.mozilla.experiments.nimbus.internal.MalformedFeatureConfigExtraDef
Expand Down Expand Up @@ -702,6 +705,69 @@ open class Nimbus(
nimbusClient.recordMalformedFeatureConfig(featureId, partId)
}

override fun getAvailableFirefoxLabs(): Deferred<List<FirefoxLabsMetadata>> {
return dbScope.async { getAvailableFirefoxLabsOnThisThread() }
}

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun getAvailableFirefoxLabsOnThisThread(): List<FirefoxLabsMetadata> {
return withCatchAll("getAvailableFirefoxLabs") {
nimbusClient.getAvailableFirefoxLabs()
} ?: emptyList()
}

override fun enrollInFirefoxLab(slug: String): Deferred<FirefoxLabsEnrollStatus> {
return dbScope.async { enrollInFirefoxLabOnThisThread(slug) }
}

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun enrollInFirefoxLabOnThisThread(slug: String): FirefoxLabsEnrollStatus {
return withCatchAll("enrollInFirefoxLab") {
val result = nimbusClient.enrollInFirefoxLab(slug)
if (result.enrollmentChangeEvents.isNotEmpty()) {
recordExperimentTelemetryEvents(result.enrollmentChangeEvents)
postEnrolmentCalculation()
updateDispatcher.notifyChanged(result.enrollmentChangeEvents)
}

result.status
} ?: FirefoxLabsEnrollStatus.ERROR
}

override fun unenrollFromFirefoxLab(slug: String): Deferred<FirefoxLabsUnenrollStatus> {
return dbScope.async { unenrollFromFirefoxLabOnThisThread(slug) }
}

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun unenrollFromFirefoxLabOnThisThread(slug: String): FirefoxLabsUnenrollStatus {
return withCatchAll("unenrollFromFirefoxLab") {
val result = nimbusClient.unenrollFromFirefoxLab(slug)
if (result.enrollmentChangeEvents.isNotEmpty()) {
recordExperimentTelemetryEvents(result.enrollmentChangeEvents)
postEnrolmentCalculation()
updateDispatcher.notifyChanged(result.enrollmentChangeEvents)
}

result.status
} ?: FirefoxLabsUnenrollStatus.ERROR
}

override fun unenrollFromAllFirefoxLabs() {
dbScope.launch { unenrollFromAllFirefoxLabsOnThisThread() }
}

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun unenrollFromAllFirefoxLabsOnThisThread() {
withCatchAll("unenrollFromAllFirefoxLabs") {
val enrollmentChangeEvents = nimbusClient.unenrollFromAllFirefoxLabs()
if (enrollmentChangeEvents.isNotEmpty()) {
recordExperimentTelemetryEvents(enrollmentChangeEvents)
postEnrolmentCalculation()
updateDispatcher.notifyChanged(enrollmentChangeEvents)
}
}
}

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun buildExperimentContext(
context: Context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import org.mozilla.experiments.nimbus.internal.AvailableExperiment
import org.mozilla.experiments.nimbus.internal.EnrolledExperiment
import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEvent
import org.mozilla.experiments.nimbus.internal.ExperimentBranch
import org.mozilla.experiments.nimbus.internal.FirefoxLabsEnrollStatus
import org.mozilla.experiments.nimbus.internal.FirefoxLabsMetadata
import org.mozilla.experiments.nimbus.internal.FirefoxLabsUnenrollStatus
import org.mozilla.experiments.nimbus.internal.GeckoPrefState
import org.mozilla.experiments.nimbus.internal.NimbusException
import org.mozilla.experiments.nimbus.internal.PrefUnenrollReason
import org.mozilla.experiments.nimbus.internal.PreviousGeckoPrefState
import java.time.Duration
Expand Down Expand Up @@ -208,6 +212,29 @@ interface NimbusInterface : FeaturesInterface, NimbusMessagingInterface, NimbusE
geckoPrefStates: List<GeckoPrefState>,
) = Unit

/**
* Return the list of available Firefox Labs opt-ins.
*/
fun getAvailableFirefoxLabs(): Deferred<List<FirefoxLabsMetadata>> =
CompletableDeferred(emptyList<FirefoxLabsMetadata>())

/**
* Attempt to enroll in a Firefox Labs opt-in.
*/
fun enrollInFirefoxLab(slug: String): Deferred<FirefoxLabsEnrollStatus> =
CompletableDeferred(FirefoxLabsEnrollStatus.NO_EXPERIMENT)

/**
* Attempt to unenroll from a Firefox Labs opt-in.
*/
fun unenrollFromFirefoxLab(slug: String): Deferred<FirefoxLabsUnenrollStatus> =
CompletableDeferred(FirefoxLabsUnenrollStatus.NO_EXPERIMENT)

/**
* Attempt to unenroll from all Firefox Labs.
*/
fun unenrollFromAllFirefoxLabs() = Unit

/**
* Reset internal state in response to application-level telemetry reset.
* Consumers should call this method when the user resets the telemetry state of the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ class NimbusTests {
branchSlug = "test-branch",
userFacingDescription = "A test experiment for testing experiments",
userFacingName = "Test Experiment",
isRollout = false,
),
)

Expand Down
80 changes: 58 additions & 22 deletions components/nimbus/src/enrollment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ pub enum EnrolledReason {
Qualified,
/// Explicit opt-in.
OptIn,
/// Opted-in via Firefox Labs
#[cfg(feature = "stateful")]
/// Opt-in via Firefox Labs.
FirefoxLabsOptIn,
}

Expand All @@ -40,6 +41,7 @@ impl Display for EnrolledReason {
match self {
EnrolledReason::Qualified => "Qualified",
EnrolledReason::OptIn => "OptIn",
#[cfg(feature = "stateful")]
EnrolledReason::FirefoxLabsOptIn => "FirefoxLabsOptIn",
},
f,
Expand Down Expand Up @@ -72,6 +74,7 @@ pub enum NotEnrolledReason {
RolloutsOptOut,

/// This is a Firefox Labs opt-in and we have not opted-in.
#[cfg(feature = "stateful")]
FirefoxLabs,

/// This state represents several cases:
Expand Down Expand Up @@ -105,6 +108,7 @@ impl Display for NotEnrolledReason {
NotEnrolledReason::NotTargeted => "NotTargeted",
NotEnrolledReason::ExperimentsOptOut => "ExperimentsOptOut",
NotEnrolledReason::RolloutsOptOut => "RolloutsOptOut",
#[cfg(feature = "stateful")]
NotEnrolledReason::FirefoxLabs => "FirefoxLabs",
NotEnrolledReason::OptOut => "OptOut",
},
Expand Down Expand Up @@ -138,17 +142,21 @@ pub enum DisqualifiedReason {
Error,
/// The user opted-out from this experiment.
OptOut,
/// The user opted-out from all rollouts.
/// The user opted-out from all experiments.
ExperimentsOptOut,
/// The user reset their telemetry identifiers.
// The user opted-out from all rollouts.
RolloutsOptOut,
/// The targeting has changed for an experiment.
NotTargeted,
/// The bucketing has changed for an experiment.
NotSelected,
/// A pref used in the experiment was set by the user.
#[cfg(feature = "stateful")]
PrefUnenrollReason { reason: PrefUnenrollReason },
PrefUnenrollReason {
reason: PrefUnenrollReason,
},
#[cfg(feature = "stateful")]
FirefoxLabsOptOut,
}

impl Display for DisqualifiedReason {
Expand All @@ -166,12 +174,35 @@ impl Display for DisqualifiedReason {
PrefUnenrollReason::Changed => "PrefChanged",
PrefUnenrollReason::FailedToSet => "PrefFailedToSet",
},
#[cfg(feature = "stateful")]
DisqualifiedReason::FirefoxLabsOptOut => "FirefoxLabsOptOut",
},
f,
)
}
}

impl DisqualifiedReason {
// TODO(bug 2046987): Unify with Display impl?
fn for_enrollment_change_event(&self) -> &'static str {
match self {
DisqualifiedReason::NotSelected => "bucketing",
DisqualifiedReason::NotTargeted => "targeting",
DisqualifiedReason::OptOut => "optout",
DisqualifiedReason::ExperimentsOptOut => "experiments-opt-out",
DisqualifiedReason::RolloutsOptOut => "rollouts-opt-out",
DisqualifiedReason::Error => "error",
#[cfg(feature = "stateful")]
DisqualifiedReason::PrefUnenrollReason { reason } => match reason {
PrefUnenrollReason::Changed => "pref_changed",
PrefUnenrollReason::FailedToSet => "pref_failed_to_set",
},
#[cfg(feature = "stateful")]
DisqualifiedReason::FirefoxLabsOptOut => "FirefoxLabsOptOut",
}
}
}

// The previous state of a Gecko pref before enrollment took place.
// ⚠️ Attention : Changes to this type should be accompanied by a new test ⚠️
// ⚠️ in `src/stateful/tests/test_enrollment_bw_compat.rs` below, and may require a DB migration. ⚠️
Expand Down Expand Up @@ -290,6 +321,7 @@ impl ExperimentEnrollment {
pub(crate) fn from_explicit_opt_in(
experiment: &Experiment,
branch_slug: &str,
reason: EnrolledReason,
out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
) -> Result<Self> {
if !experiment.has_branch(branch_slug) {
Expand All @@ -308,7 +340,7 @@ impl ExperimentEnrollment {
}
let enrollment = Self {
slug: experiment.slug.clone(),
status: EnrollmentStatus::new_enrolled(EnrolledReason::OptIn, branch_slug),
status: EnrollmentStatus::new_enrolled(reason, branch_slug),
};
out_enrollment_events.push(enrollment.get_change_event(Some(experiment)));
Ok(enrollment)
Expand Down Expand Up @@ -545,14 +577,15 @@ impl ExperimentEnrollment {
&self,
experiment: Option<&Experiment>,
out_enrollment_events: &mut Vec<EnrollmentChangeEvent>,
reason: DisqualifiedReason,
#[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>,
) -> ExperimentEnrollment {
match self.status {
EnrollmentStatus::Enrolled { .. } => {
#[cfg(feature = "stateful")]
self.maybe_revert_all_gecko_pref_states(gecko_pref_store);

let enrollment = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
let enrollment = self.disqualify_from_enrolled(reason);
out_enrollment_events.push(enrollment.get_change_event(experiment));
enrollment
}
Expand Down Expand Up @@ -654,9 +687,16 @@ impl ExperimentEnrollment {
) -> Self {
let updated = match self.status {
EnrollmentStatus::Enrolled { .. } => {
let disqualified = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
out_enrollment_events.push(disqualified.get_change_event(experiment));
disqualified
if let Some(experiment) = experiment
&& experiment.is_firefox_labs_opt_in
{
// Firefox Labs is unrelated to telemetry.
self.clone()
} else {
let disqualified = self.disqualify_from_enrolled(DisqualifiedReason::OptOut);
out_enrollment_events.push(disqualified.get_change_event(experiment));
disqualified
}
}
EnrollmentStatus::NotEnrolled { .. }
| EnrollmentStatus::Disqualified { .. }
Expand Down Expand Up @@ -707,19 +747,7 @@ impl ExperimentEnrollment {
EnrollmentStatus::Disqualified { branch, reason, .. } => EnrollmentChangeEvent::new(
&self.slug,
branch,
match reason {
DisqualifiedReason::NotSelected => Some("bucketing"),
DisqualifiedReason::NotTargeted => Some("targeting"),
DisqualifiedReason::OptOut => Some("optout"),
DisqualifiedReason::ExperimentsOptOut => Some("experiments-opt-out"),
DisqualifiedReason::RolloutsOptOut => Some("rollouts-opt-out"),
DisqualifiedReason::Error => Some("error"),
#[cfg(feature = "stateful")]
DisqualifiedReason::PrefUnenrollReason { reason } => match reason {
PrefUnenrollReason::Changed => Some("pref_changed"),
PrefUnenrollReason::FailedToSet => Some("pref_failed_to_set"),
},
},
Some(reason.for_enrollment_change_event()),
EnrollmentChangeEventType::Disqualification,
experiment,
),
Expand Down Expand Up @@ -866,6 +894,14 @@ impl EnrollmentStatus {
pub fn is_enrolled(&self) -> bool {
matches!(self, EnrollmentStatus::Enrolled { .. })
}

pub fn is_enrolled_with_reason(&self, expected_reason: EnrolledReason) -> bool {
matches!(
self,
EnrollmentStatus::Enrolled { reason, ..}
if *reason == expected_reason
)
}
}

pub(crate) trait ExperimentMetadata {
Expand Down
2 changes: 1 addition & 1 deletion components/nimbus/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pub use crate::metrics::detail::*;

use crate::enrollment::NotEnrolledReason;
#[derive(Serialize, Deserialize, Clone)]
#[cfg_attr(test, derive(Debug))]
#[cfg_attr(test, derive(Debug, Default, Eq, PartialEq))]
pub struct EnrollmentStatusExtraDef {
pub branch: Option<String>,
pub conflict_slug: Option<String>,
Expand Down
Loading