Skip to content
Merged
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 @@ -30,6 +30,7 @@
### Nimbus

- Reasons are now reported in unenrollment events. ([#7480](https://github.com/mozilla/application-services/pull/7480))
- The available Firefox Labs are now filtered by targeting and bucketing. ([#7481](https://github.com/mozilla/application-services/pull/7481))

# v153.0 (_2026-06-15_)

Expand Down
209 changes: 109 additions & 100 deletions components/nimbus/src/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,79 +50,123 @@ pub fn split_locale(locale: String) -> (Option<String>, Option<String>) {

/// Determine the enrolment status for an experiment.
///
/// # Arguments:
/// - `available_randomization_units` The app provided available randomization units
/// - `targeting_attributes` The attributes to use when evaluating targeting
/// - `exp` The `Experiment` to evaluate.
///
/// # Returns:
/// An `ExperimentEnrollment` - you need to inspect the EnrollmentStatus to
/// determine if the user is actually enrolled.
///
/// # Errors:
///
/// The function can return errors in one of the following cases (but not limited to):
/// # Errors
///
/// - If the bucket sampling failed (i.e we could not find if the user should or should not be enrolled in the experiment based on the bucketing)
/// - If an error occurs while determining the branch the user should be enrolled in any of the experiments
/// The function can return an error when branch selection fails due to an
/// invalid bucketing configuration.
pub fn evaluate_enrollment(
available_randomization_units: &AvailableRandomizationUnits,
exp: &Experiment,
th: &NimbusTargetingHelper,
experiment: &Experiment,
targeting_helper: &NimbusTargetingHelper,
) -> Result<ExperimentEnrollment> {
if let ExperimentAvailable::Unavailable { reason } = is_experiment_available(th, exp, true) {
return Ok(ExperimentEnrollment {
slug: exp.slug.clone(),
status: EnrollmentStatus::NotEnrolled { reason },
});
}
let status = match can_enroll(available_randomization_units, targeting_helper, experiment) {
CanEnrollResult::Unavailable { reason } => EnrollmentStatus::NotEnrolled { reason },
CanEnrollResult::NotTargeted => EnrollmentStatus::NotEnrolled {
reason: NotEnrolledReason::NotTargeted,
},
CanEnrollResult::NotSelected => EnrollmentStatus::NotEnrolled {
reason: NotEnrolledReason::NotSelected,
},
CanEnrollResult::TargetingError { reason } => EnrollmentStatus::Error { reason },
CanEnrollResult::NoRandomizationUnit => {
info!(
"Could not find a suitable randomization unit for {}. Skipping experiment.",
experiment.slug,
);
EnrollmentStatus::Error {
reason: "No randomization unit".into(),
}
}

CanEnrollResult::Enrollable { randomization_id } => EnrollmentStatus::new_enrolled(
EnrolledReason::Qualified,
&choose_branch(&experiment.slug, &experiment.branches, randomization_id)?.slug,
),
};

Ok(ExperimentEnrollment {
slug: experiment.slug.clone(),
status,
})
}

/// Whether or not an experiment can be enrolled.
pub enum CanEnrollResult<'aru> {
/// The experiment is enrollable.
Enrollable {
/// The randomization ID that should be used for branch selection.
randomization_id: &'aru str,
},

/// The experiment is not available for a reason outlined in [`NotEnrolledReason`]
Unavailable {
/// The reason the enrollment is not available.
reason: NotEnrolledReason,
},

/// The experiment is not enrollable due to a targeting error.
TargetingError {
/// The stringified error.
reason: String,
},

/// The experiment is not enrollable because targeting expression evaluated
/// to false.
NotTargeted,

// Get targeting out of the way - "if let chains" are experimental,
// otherwise we could improve this.
if let Some(expr) = &exp.targeting
&& let Some(status) = targeting(expr, th)
/// The experiment is not enrollable because randomization ID did not fall
/// into a selected bucket.
NotSelected,

/// The experiment is not enrollable because it requires a randomization
/// unit that is not available.
NoRandomizationUnit,
}

/// Determine whether or not it is possible to enroll in the given experiment.
pub fn can_enroll<'aru>(
available_randomization_units: &'aru AvailableRandomizationUnits,
targeting_helper: &NimbusTargetingHelper,
experiment: &Experiment,
) -> CanEnrollResult<'aru> {
if let ExperimentAvailable::Unavailable { reason } =
is_experiment_available(targeting_helper, experiment, true)
{
return Ok(ExperimentEnrollment {
slug: exp.slug.clone(),
status,
});
return CanEnrollResult::Unavailable { reason };
}
Ok(ExperimentEnrollment {
slug: exp.slug.clone(),
status: {
let bucket_config = exp.bucket_config.clone();
match available_randomization_units.get_value(&bucket_config.randomization_unit) {
Some(id) => {
if sampling::bucket_sample(
vec![id.to_owned(), bucket_config.namespace],
bucket_config.start,
bucket_config.count,
bucket_config.total,
)? {
EnrollmentStatus::new_enrolled(
EnrolledReason::Qualified,
&choose_branch(&exp.slug, &exp.branches, id)?.clone().slug,
)
} else {
EnrollmentStatus::NotEnrolled {
reason: NotEnrolledReason::NotSelected,
}
}
}
None => {
// XXX: When we link in glean, it would be nice if we could emit
// a failure telemetry event here.
info!(
"Could not find a suitable randomization unit for {}. Skipping experiment.",
&exp.slug
);
EnrollmentStatus::Error {
reason: "No randomization unit".into(),
}
}

if let Some(targeting_expression) = &experiment.targeting {
match targeting_helper.eval_jexl(targeting_expression) {
Err(e) => {
return CanEnrollResult::TargetingError {
reason: e.to_string(),
};
}
},
})
Ok(false) => return CanEnrollResult::NotTargeted,
Ok(true) => {}
};
}

let Some(randomization_id) =
available_randomization_units.get_value(&experiment.bucket_config.randomization_unit)
else {
return CanEnrollResult::NoRandomizationUnit;
};

let Ok(is_sampled) = sampling::bucket_sample(
[randomization_id, &experiment.bucket_config.namespace],
experiment.bucket_config.start,
experiment.bucket_config.count,
experiment.bucket_config.total,
) else {
return CanEnrollResult::NoRandomizationUnit;
};

if is_sampled {
CanEnrollResult::Enrollable { randomization_id }
} else {
CanEnrollResult::NotSelected
}
}

/// Whether or not an experiment is available.
Expand Down Expand Up @@ -222,41 +266,6 @@ pub(crate) fn choose_branch<'a>(
branches.get(index).ok_or(NimbusError::OutOfBoundsError)
}

/// Checks if the client is targeted by an experiment
/// This api evaluates the JEXL statement retrieved from the server
/// against the application context provided by the client
///
/// # Arguments
/// - `expression_statement`: The JEXL statement provided by the server
/// - `targeting_attributes`: The client attributes to target against
///
/// If this app can not be targeted, returns an EnrollmentStatus to indicate
/// why. Returns None if we should continue to evaluate the enrollment status.
///
/// In practice, if this returns an EnrollmentStatus, it will be either
/// EnrollmentStatus::NotEnrolled, or EnrollmentStatus::Error in the following
/// cases (But not limited to):
/// - The `expression_statement` is not a valid JEXL statement
/// - The `expression_statement` expects fields that do not exist in the AppContext definition
/// - The result of evaluating the statement against the context is not a boolean
/// - jexl-rs returned an error
pub(crate) fn targeting(
expression_statement: &str,
targeting_helper: &NimbusTargetingHelper,
) -> Option<EnrollmentStatus> {
match targeting_helper.eval_jexl(expression_statement.to_string()) {
Ok(res) => match res {
true => None,
false => Some(EnrollmentStatus::NotEnrolled {
reason: NotEnrolledReason::NotTargeted,
}),
},
Err(e) => Some(EnrollmentStatus::Error {
reason: e.to_string(),
}),
}
}

#[cfg(test)]
mod unit_tests {
use super::*;
Expand Down
6 changes: 3 additions & 3 deletions components/nimbus/src/nimbus.udl
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ interface GeckoPrefHandler {
void set_gecko_prefs_state(sequence<GeckoPrefState> new_prefs_state);

void set_gecko_prefs_original_values(sequence<OriginalGeckoPref> original_gecko_prefs);

};

dictionary GeckoPref {
Expand Down Expand Up @@ -469,12 +469,12 @@ interface NimbusTargetingHelper {
/// Execute the given jexl expression and evaluate against the existing targeting parameters and context passed to
/// the helper at construction.
[Throws=NimbusError]
boolean eval_jexl(string expression);
boolean eval_jexl([ByRef] string expression);

/// Evaluate a JEXL expression and return debug results as JSON.
/// For CLI testing and debugging.
[Throws=NimbusError]
string eval_jexl_debug(string expression);
string eval_jexl_debug([ByRef] string expression);
};

interface NimbusStringHelper {
Expand Down
4 changes: 2 additions & 2 deletions components/nimbus/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ pub enum RandomizationUnit {
UserId,
}

#[derive(Default)]
#[derive(Clone, Default)]
pub struct AvailableRandomizationUnits {
pub user_id: Option<String>,
pub nimbus_id: Option<String>,
Expand Down Expand Up @@ -377,7 +377,7 @@ impl AvailableRandomizationUnits {
}
}

pub fn get_value<'a>(&'a self, wanted: &'a RandomizationUnit) -> Option<&'a str> {
pub fn get_value(&self, wanted: &RandomizationUnit) -> Option<&str> {
match wanted {
RandomizationUnit::NimbusId => self.nimbus_id.as_deref(),
RandomizationUnit::UserId => self.user_id.as_deref(),
Expand Down
16 changes: 8 additions & 8 deletions components/nimbus/src/stateful/dbcache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ use crate::enrollment::{
EnrolledFeature, EnrolledFeatureConfig, ExperimentEnrollment, map_features_by_feature_id,
};
use crate::error::{NimbusError, Result, warn};
use crate::evaluator::{ExperimentAvailable, is_experiment_available};
use crate::evaluator::{CanEnrollResult, can_enroll};
use crate::stateful::enrollment::get_enrollments;
use crate::stateful::firefox_labs::FirefoxLabsMetadata;
use crate::stateful::gecko_prefs::GeckoPrefStore;
use crate::stateful::persistence::{Database, StoreId, Writer};
use crate::targeting::NimbusTargetingHelper;
use crate::{EnrolledExperiment, Experiment};
use crate::{AvailableRandomizationUnits, EnrolledExperiment, Experiment};

// This module manages an in-memory cache of the database, so that some
// functions exposed by nimbus can return results without blocking on any
Expand Down Expand Up @@ -219,6 +219,7 @@ impl DatabaseCache {

pub fn get_available_firefox_labs_metadata(
&self,
available_randomization_units: &AvailableRandomizationUnits,
targeting_helper: &NimbusTargetingHelper,
coenrolling_feature_ids: &[String],
) -> Result<Vec<FirefoxLabsMetadata>> {
Expand All @@ -237,13 +238,12 @@ impl DatabaseCache {
}

let enrolled = data.experiments_by_slug.contains_key(&experiment.slug);
let enrollable = matches!(
can_enroll(available_randomization_units, targeting_helper, experiment,),
CanEnrollResult::Enrollable { .. }
);

// We call is_experiment_available with is_release=true
// because being able to enroll in experiments for different channels is a bug.
//
// See-also https://bugzilla.mozilla.org/show_bug.cgi?id=1909348
if is_experiment_available(targeting_helper, experiment, true)
== ExperimentAvailable::Available
if enrollable
&& (enrolled
|| (features_available(
experiment,
Expand Down
14 changes: 10 additions & 4 deletions components/nimbus/src/stateful/nimbus_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1003,14 +1003,20 @@ impl NimbusClient {
}

pub fn get_available_firefox_labs(&self) -> Result<Vec<FirefoxLabsMetadata>> {
let targeting_attributes = self.get_targeting_attributes();
let mut state = self.mutable_state.lock().unwrap();
state.update_time_to_now(Utc::now());

let targeting_helper = NimbusTargetingHelper::with_targeting_attributes(
&targeting_attributes,
&state.targeting_attributes,
self.event_store.clone(),
self.gecko_prefs.clone(),
);
self.database_cache
.get_available_firefox_labs_metadata(&targeting_helper, &self.coenrolling_feature_ids)

self.database_cache.get_available_firefox_labs_metadata(
&state.available_randomization_units,
&targeting_helper,
&self.coenrolling_feature_ids,
)
}

pub fn enroll_in_firefox_lab(&self, slug: &str) -> Result<FirefoxLabsEnrollResult> {
Expand Down
10 changes: 5 additions & 5 deletions components/nimbus/src/targeting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,20 @@ impl NimbusTargetingHelper {
}
}

pub fn eval_jexl(&self, expr: String) -> Result<bool> {
pub fn eval_jexl(&self, expr: &str) -> Result<bool> {
cfg_if::cfg_if! {
if #[cfg(feature = "stateful")] {
jexl_eval(&expr, &self.context, self.event_store.clone(), self.gecko_pref_store.clone())
jexl_eval(expr, &self.context, self.event_store.clone(), self.gecko_pref_store.clone())
} else {
jexl_eval(&expr, &self.context)
jexl_eval(expr, &self.context)
}
}
}

#[cfg(feature = "stateful")]
pub fn eval_jexl_debug(&self, expression: String) -> Result<String> {
pub fn eval_jexl_debug(&self, expression: &str) -> Result<String> {
let eval_result = jexl_eval_raw(
&expression,
expression,
&self.context,
self.event_store.clone(),
self.gecko_pref_store.clone(),
Expand Down
Loading