Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import {
PrefFlipsFeature,
REASON_PREFFLIPS_FAILED,
} from "resource://nimbus/lib/PrefFlipsFeature.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
ClientID: "resource://gre/modules/ClientID.sys.mjs",
ExperimentStore: "resource://nimbus/lib/ExperimentStore.sys.mjs",
FirstStartup: "resource://gre/modules/FirstStartup.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs",
PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs",
RemoteSettingsExperimentLoader:
"resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs",
EnrollmentsContext:
"resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs",
Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs",
TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "log", () => {
const { Logger } = ChromeUtils.importESModule(
"resource://messaging-system/lib/Logger.sys.mjs"
);
return new Logger("ExperimentManager");
});
const TELEMETRY_EVENT_OBJECT = "nimbus_experiment";
const TELEMETRY_EXPERIMENT_ACTIVE_PREFIX = "nimbus-";
const TELEMETRY_DEFAULT_EXPERIMENT_TYPE = "nimbus";
const UPLOAD_ENABLED_PREF = "datareporting.healthreport.uploadEnabled";
const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled";
const STUDIES_ENABLED_CHANGED = "nimbus:studies-enabled-changed";
const ENROLLMENT_STATUS = {
ENROLLED: "Enrolled",
NOT_ENROLLED: "NotEnrolled",
DISQUALIFIED: "Disqualified",
WAS_ENROLLED: "WasEnrolled",
ERROR: "Error",
};
const ENROLLMENT_STATUS_REASONS = {
QUALIFIED: "Qualified",
OPT_IN: "OptIn",
OPT_OUT: "OptOut",
NOT_SELECTED: "NotSelected",
NOT_TARGETED: "NotTargeted",
ENROLLMENTS_PAUSED: "EnrollmentsPaused",
FEATURE_CONFLICT: "FeatureConflict",
ERROR: "Error",
};
function featuresCompat(branch) {
if (!branch || (!branch.feature && !branch.features)) {
return [];
}
let { features } = branch;
// In <=v1.5.0 of the Nimbus API, experiments had single feature
if (!features) {
features = [branch.feature];
}
return features;
}
function getFeatureFromBranch(branch, featureId) {
return featuresCompat(branch).find(
featureConfig => featureConfig.featureId === featureId
);
}
/**
* A module for processes Experiment recipes, choosing and storing enrollment state,
* and sending experiment-related Telemetry.
*/
export class _ExperimentManager {
constructor({ id = "experimentmanager", store } = {}) {
this.id = id;
this.store = store || new lazy.ExperimentStore();
this.sessions = new Map();
this.optInRecipes = [];
// By default, no extra context.
this.extraContext = {};
Services.prefs.addObserver(UPLOAD_ENABLED_PREF, this);
Services.prefs.addObserver(STUDIES_OPT_OUT_PREF, this);
// A Map from pref names to pref observers and metadata. See
// `_updatePrefObservers` for the full structure.
this._prefs = new Map();
// A Map from enrollment slugs to a Set of prefs that enrollment is setting
// or would set (e.g., if the enrollment is a rollout and there wasn't an
// active experiment already setting it).
this._prefsBySlug = new Map();
this._prefFlips = new PrefFlipsFeature({ manager: this });
}
get studiesEnabled() {
return (
Services.prefs.getBoolPref(UPLOAD_ENABLED_PREF, false) &&
Services.prefs.getBoolPref(STUDIES_OPT_OUT_PREF, false) &&
Services.policies.isAllowed("Shield")
);
}
/**
* Creates a targeting context with following filters:
*
* * `activeExperiments`: an array of slugs of all the active experiments
* * `isFirstStartup`: a boolean indicating whether or not the current enrollment
* is performed during the first startup
*
* @returns {Object} A context object
* @memberof _ExperimentManager
*/
createTargetingContext() {
let context = {
...this.extraContext,
isFirstStartup: lazy.FirstStartup.state === lazy.FirstStartup.IN_PROGRESS,
get currentDate() {
return new Date();
},
};
Object.defineProperty(context, "activeExperiments", {
enumerable: true,
get: async () => {
await this.store.ready();
return this.store.getAllActiveExperiments().map(exp => exp.slug);
},
});
Object.defineProperty(context, "activeRollouts", {
enumerable: true,
get: async () => {
await this.store.ready();
return this.store.getAllActiveRollouts().map(rollout => rollout.slug);
},
});
Object.defineProperty(context, "previousExperiments", {
enumerable: true,
get: async () => {
await this.store.ready();
return this.store
.getAll()
.filter(enrollment => !enrollment.active && !enrollment.isRollout)
.map(exp => exp.slug);
},
});
Object.defineProperty(context, "previousRollouts", {
enumerable: true,
get: async () => {
await this.store.ready();
return this.store
.getAll()
.filter(enrollment => !enrollment.active && enrollment.isRollout)
.map(rollout => rollout.slug);
},
});
Object.defineProperty(context, "enrollments", {
enumerable: true,
get: async () => {
await this.store.ready();
return this.store.getAll().map(enrollment => enrollment.slug);
},
});
Object.defineProperty(context, "enrollmentsMap", {
enumerable: true,
get: async () => {
await this.store.ready();
return this.store.getAll().reduce((acc, enrollment) => {
acc[enrollment.slug] = enrollment.branch.slug;
return acc;
}, {});
},
});
return context;
}
/**
* Runs on startup, including before first run.
*
* @param {object} extraContext extra targeting context provided by the
* ambient environment.
*/
async onStartup(extraContext = {}) {
await this.store.init();
this.extraContext = extraContext;
const restoredExperiments = this.store.getAllActiveExperiments();
const restoredRollouts = this.store.getAllActiveRollouts();
for (const experiment of restoredExperiments) {
this.setExperimentActive(experiment);
if (this._restoreEnrollmentPrefs(experiment)) {
this._updatePrefObservers(experiment);
}
}
for (const rollout of restoredRollouts) {
this.setExperimentActive(rollout);
if (this._restoreEnrollmentPrefs(rollout)) {
this._updatePrefObservers(rollout);
}
}
this._prefFlips.init();
this.observe();
lazy.NimbusFeatures.nimbusTelemetry.onUpdate(() => {
const cfg =
lazy.NimbusFeatures.nimbusTelemetry.getVariable(
"gleanMetricConfiguration"
) ?? {};
Services.fog.applyServerKnobsConfig(JSON.stringify(cfg));
});
}
/**
* Runs every time a Recipe is updated or seen for the first time.
* @param {RecipeArgs} recipe
* @param {string} source
* @param {boolean} isTargetingMatch
*/
async onRecipe(recipe, source, isTargetingMatch) {
const { slug, isEnrollmentPaused, isFirefoxLabsOptIn } = recipe;
if (!source) {
throw new Error("When calling onRecipe, you must specify a source.");
}
if (isFirefoxLabsOptIn) {
this.optInRecipes.push(recipe);
} else if (isTargetingMatch) {
if (!this.sessions.has(source)) {
this.sessions.set(source, new Set());
}
this.sessions.get(source).add(slug);
if (this.store.has(slug)) {
await this.updateEnrollment(recipe, source);
} else if (isEnrollmentPaused) {
lazy.log.debug(`Enrollment is paused for "${slug}"`);
} else if (!(await this.isInBucketAllocation(recipe.bucketConfig))) {
lazy.log.debug(
"Client was not enrolled because of the bucket sampling"
);
} else {
await this.enroll(recipe, source);
}
}
}
_checkUnseenEnrollments(
enrollments,
sourceToCheck,
recipeMismatches,
invalidRecipes,
invalidBranches,
invalidFeatures,
missingLocale,
missingL10nIds
) {
for (const enrollment of enrollments) {
const { slug, source, branch } = enrollment;
if (sourceToCheck !== source) {
continue;
}
const statusTelemetry = {
slug,
branch: branch.slug,
};
if (!this.sessions.get(source)?.has(slug)) {
lazy.log.debug(`Stopping study for recipe ${slug}`);
try {
let reason;
if (recipeMismatches.includes(slug)) {
reason = "targeting-mismatch";
statusTelemetry.status = ENROLLMENT_STATUS.DISQUALIFIED;
statusTelemetry.reason = ENROLLMENT_STATUS_REASONS.NOT_TARGETED;
} else if (invalidRecipes.includes(slug)) {
reason = "invalid-recipe";
} else if (invalidBranches.has(slug) || invalidFeatures.has(slug)) {
reason = "invalid-branch";
} else if (missingLocale.includes(slug)) {
reason = "l10n-missing-locale";
} else if (missingL10nIds.has(slug)) {
reason = "l10n-missing-entry";
} else {
reason = "recipe-not-seen";
statusTelemetry.status = ENROLLMENT_STATUS.WAS_ENROLLED;
statusTelemetry.branch = branch.slug;
}
if (!statusTelemetry.status) {
statusTelemetry.status = ENROLLMENT_STATUS.DISQUALIFIED;
statusTelemetry.reason = ENROLLMENT_STATUS_REASONS.ERROR;
statusTelemetry.error_string = reason;
}
this.unenroll(slug, reason);
} catch (err) {
console.error(err);
}
} else {
statusTelemetry.status = ENROLLMENT_STATUS.ENROLLED;
statusTelemetry.reason = ENROLLMENT_STATUS_REASONS.QUALIFIED;
}
this.sendEnrollmentStatusTelemetry(statusTelemetry);
}
}
/**
* Removes stored enrollments that were not seen after syncing with Remote Settings
* Runs when the all recipes been processed during an update, including at first run.
* @param {string} sourceToCheck
* @param {object} options Extra context used in telemetry reporting
* @param {string[]} options.recipeMismatches
* The list of experiments that do not match targeting.
* @param {string[]} options.invalidRecipes
* The list of recipes that do not match
* @param {Map<string, string[]>} options.invalidBranches
* A mapping of experiment slugs to a list of branches that failed
* feature validation.
* @param {Map<string, string[]>} options.invalidFeatures
* The mapping of experiment slugs to a list of invalid feature IDs.
* @param {string[]} options.missingLocale
* The list of experiment slugs missing an entry in the localization
* table for the current locale.
* @param {Map<string, string[]>} options.missingL10nIds
* The mapping of experiment slugs to the IDs of localization entries
* missing from the current locale.
* @param {string | null} options.locale
* The current locale.
* @param {boolean} options.validationEnabled
* Whether or not schema validation was enabled.
*/
onFinalize(
sourceToCheck,
{
recipeMismatches = [],
invalidRecipes = [],
invalidBranches = new Map(),
invalidFeatures = new Map(),
missingLocale = [],
missingL10nIds = new Map(),
locale = null,
validationEnabled = true,
} = {}
) {
if (!sourceToCheck) {
throw new Error("When calling onFinalize, you must specify a source.");
}
const activeExperiments = this.store.getAllActiveExperiments();
const activeRollouts = this.store.getAllActiveRollouts();
this._checkUnseenEnrollments(
activeExperiments,
sourceToCheck,
recipeMismatches,
invalidRecipes,
invalidBranches,
invalidFeatures,
missingLocale,
missingL10nIds
);
this._checkUnseenEnrollments(
activeRollouts,
sourceToCheck,
recipeMismatches,
invalidRecipes,
invalidBranches,
invalidFeatures,
missingLocale,
missingL10nIds
);
// If schema validation is disabled, then we will never send these
// validation failed telemetry events
if (validationEnabled) {
for (const slug of invalidRecipes) {
this.sendValidationFailedTelemetry(slug, "invalid-recipe");
}
for (const [slug, branches] of invalidBranches.entries()) {
for (const branch of branches) {
this.sendValidationFailedTelemetry(slug, "invalid-branch", {
branch,
});
}
}
for (const [slug, featureIds] of invalidFeatures.entries()) {
for (const featureId of featureIds) {
this.sendValidationFailedTelemetry(slug, "invalid-feature", {
feature: featureId,
});
}
}
}
if (locale) {
for (const slug of missingLocale.values()) {
this.sendValidationFailedTelemetry(slug, "l10n-missing-locale", {
locale,
});
}
for (const [slug, ids] of missingL10nIds.entries()) {
this.sendValidationFailedTelemetry(slug, "l10n-missing-entry", {
l10n_ids: ids.join(","),
locale,
});
}
}
this.sessions.delete(sourceToCheck);
this._originalDefaultValues = null;
}
/**
* Determine userId based on bucketConfig.randomizationUnit;
* either "normandy_id" or "group_id".
*
* @param {object} bucketConfig
*
*/
async getUserId(bucketConfig) {
let id;
if (bucketConfig.randomizationUnit === "normandy_id") {
id = lazy.ClientEnvironment.userId;
} else if (bucketConfig.randomizationUnit === "group_id") {
id = await lazy.ClientID.getProfileGroupID();
} else {
// Others not currently supported.
lazy.log.debug(
`Invalid randomizationUnit: ${bucketConfig.randomizationUnit}`
);
}
return id;
}
/**
* Get all of the opt-in recipes that match targeting and bucketing.
*
* @returns opt in recipes
*/
async getAllOptInRecipes() {
const enrollmentsCtx = new lazy.EnrollmentsContext(this, null, {
validationEnabled: false,
});
// RemoteSettingsExperimentLoader could be in a middle of updating recipes
// so let's wait for the update to finish and this promise to resolve.
await lazy.RemoteSettingsExperimentLoader.updatingRecipes();
// At this point in the execution of this function,
// RemoteSettingsExperimentLoader should've finished updating recipes at least once.
const optInRecipesWithTargetingMatch = [];
for (const recipe of this.optInRecipes) {
// check if the opt in recipe matches targeting and bucketing.
if (
(await enrollmentsCtx.checkTargeting(recipe)) &&
(await this.isInBucketAllocation(recipe.bucketConfig))
) {
optInRecipesWithTargetingMatch.push(recipe);
}
}
return optInRecipesWithTargetingMatch;
}
/**
* Get a single opt in recipe given its slug.
*
* @returns a single opt in recipe or undefined if not found.
*/
async getSingleOptInRecipe(slug) {
if (!slug) {
throw new Error("Slug required for .getSingleOptInRecipe");
}
// RemoteSettingsExperimentLoader could be in a middle of updating recipes
// so let's wait for the update to finish and this promise to resolve.
await lazy.RemoteSettingsExperimentLoader.updatingRecipes();
return this.optInRecipes.find(recipe => recipe.slug === slug);
}
/**
* Determine if this client falls into the bucketing specified in bucketConfig
*
* @param {object} bucketConfig
* @param {string} bucketConfig.randomizationUnit
* The randomization unit to use for bucketing. This must be
* either "normandy_id" or "group_id".
* @param {number} bucketConfig.start
* The start of the bucketing range (inclusive).
* @param {number} bucketConfig.count
* The number of buckets in the range.
* @param {number} bucketConfig.total
* The total number of buckets.
* @param {string} bucketConfig.namespace
* A namespace used to seed the RNG used in the sampling
* algorithm. Given an otherwise identical bucketConfig with
* different namespaces, the client will fall into different a
* different bucket.
* @returns {Promise<boolean>}
* Whether or not the client falls into the bucketing range.
*/
async isInBucketAllocation(bucketConfig) {
if (!bucketConfig) {
lazy.log.debug("Cannot enroll if recipe bucketConfig is not set.");
return false;
}
const id = await this.getUserId(bucketConfig);
if (!id) {
return false;
}
return lazy.Sampling.bucketSample(
[id, bucketConfig.namespace],
bucketConfig.start,
bucketConfig.count,
bucketConfig.total
);
}
/**
* Start a new experiment by enrolling the users
*
* @param {RecipeArgs} recipe
* @param {string} source
* @param {object} options
* @param {boolean} options.reenroll - Allow re-enrollment. Only allowed for rollouts.
* @returns {Promise<Enrollment>} The experiment object stored in the data store
* @rejects {Error}
* @memberof _ExperimentManager
*/
async enroll(
recipe,
source,
{ reenroll = false, optInRecipeBranchSlug } = {}
) {
let { slug, branches, bucketConfig, isFirefoxLabsOptIn } = recipe;
const enrollment = this.store.get(slug);
if (
enrollment &&
(enrollment.active || !enrollment.isRollout || !reenroll)
) {
this.sendFailureTelemetry("enrollFailed", slug, "name-conflict");
throw new Error(`An experiment with the slug "${slug}" already exists.`);
}
let storeLookupByFeature = recipe.isRollout
? this.store.getRolloutForFeature.bind(this.store)
: this.store.hasExperimentForFeature.bind(this.store);
const userId = await this.getUserId(bucketConfig);
let branch;
if (isFirefoxLabsOptIn) {
if (typeof optInRecipeBranchSlug === "undefined") {
throw new Error(
`Branch slug not provided for Firefox Labs opt in recipe: "${slug}"`
);
} else {
branch = branches.find(branch => branch.slug === optInRecipeBranchSlug);
if (!branch) {
throw new Error(
`Invalid branch slug provided for Firefox Labs opt in recipe: "${slug}"`
);
}
}
} else {
// recipe is not an opt in recipe hence use a ratio sampled branch
branch = await this.chooseBranch(slug, branches, userId);
}
const features = featuresCompat(branch);
for (let feature of features) {
if (storeLookupByFeature(feature?.featureId)) {
lazy.log.debug(
`Skipping enrollment for "${slug}" because there is an existing ${
recipe.isRollout ? "rollout" : "experiment"
} for this feature.`
);
this.sendFailureTelemetry("enrollFailed", slug, "feature-conflict");
return null;
}
}
return this._enroll(recipe, branch, source);
}
_enroll(
{
slug,
experimentType = TELEMETRY_DEFAULT_EXPERIMENT_TYPE,
userFacingName,
userFacingDescription,
featureIds,
isRollout,
localizations,
isFirefoxLabsOptIn,
firefoxLabsTitle,
firefoxLabsDescription,
},
branch,
source,
options = {}
) {
const { prefs, prefsToSet } = this._getPrefsForBranch(branch, isRollout);
const prefNames = new Set(prefs.map(entry => entry.name));
// Unenroll in any conflicting prefFlips enrollments. Even though the
// rollout does not have an effect, if it also *would* control any of the
// same prefs, it would cause a conflict when it became active.
const prefFlipEnrollments = [
this.store.getRolloutForFeature(PrefFlipsFeature.FEATURE_ID),
this.store.getExperimentForFeature(PrefFlipsFeature.FEATURE_ID),
].filter(enrollment => enrollment);
for (const enrollment of prefFlipEnrollments) {
const featureValue = getFeatureFromBranch(
enrollment.branch,
PrefFlipsFeature.FEATURE_ID
).value;
for (const prefName of Object.keys(featureValue.prefs)) {
if (prefNames.has(prefName)) {
this._unenroll(enrollment, {
reason: "prefFlips-conflict",
conflictingSlug: slug,
});
break;
}
}
}
/** @type {Enrollment} */
const experiment = {
slug,
branch,
active: true,
experimentType,
source,
userFacingName,
userFacingDescription,
lastSeen: new Date().toJSON(),
featureIds,
prefs,
};
if (localizations) {
experiment.localizations = localizations;
}
if (typeof isFirefoxLabsOptIn !== "undefined") {
Object.assign(experiment, {
isFirefoxLabsOptIn,
firefoxLabsTitle,
firefoxLabsDescription,
});
}
if (typeof isRollout !== "undefined") {
experiment.isRollout = isRollout;
}
// Tag this as a forced enrollment. This prevents all unenrolling unless
// manually triggered from about:studies
if (options.force) {
experiment.force = true;
}
if (isRollout) {
experiment.experimentType = "rollout";
this.store.addEnrollment(experiment);
this.setExperimentActive(experiment);
} else {
this.store.addEnrollment(experiment);
this.setExperimentActive(experiment);
}
this.sendEnrollmentTelemetry(experiment);
this._setEnrollmentPrefs(prefsToSet);
this._updatePrefObservers(experiment);
lazy.log.debug(
`New ${isRollout ? "rollout" : "experiment"} started: ${slug}, ${
branch.slug
}`
);
return experiment;
}
forceEnroll(recipe, branch, source = "force-enrollment") {
/**
* If we happen to be enrolled in an experiment for the same feature
* we need to unenroll from that experiment.
* If the experiment has the same slug after unenrollment adding it to the
* store will overwrite the initial experiment.
*/
const features = featuresCompat(branch);
for (let feature of features) {
const isRollout = recipe.isRollout ?? false;
let enrollment = isRollout
? this.store.getRolloutForFeature(feature?.featureId)
: this.store.getExperimentForFeature(feature?.featureId);
if (enrollment) {
lazy.log.debug(
`Existing ${
isRollout ? "rollout" : "experiment"
} found for the same feature ${feature.featureId}, unenrolling.`
);
this.unenroll(enrollment.slug, source);
}
}
recipe.userFacingName = `${recipe.userFacingName} - Forced enrollment`;
const slug = `optin-${recipe.slug}`;
const enrollment = this._enroll(
{
...recipe,
slug,
},
branch,
source,
{ force: true }
);
Services.obs.notifyObservers(null, "nimbus:enrollments-updated", slug);
return enrollment;
}
/**
* Update an enrollment that was already set
*
* @param {RecipeArgs} recipe
* @returns {boolean} whether the enrollment is still active
*/
async updateEnrollment(recipe, source) {
/** @type Enrollment */
const enrollment = this.store.get(recipe.slug);
// Don't update experiments that were already unenrolled.
if (enrollment.active === false && !recipe.isRollout) {
lazy.log.debug(`Enrollment ${recipe.slug} has expired, aborting.`);
return false;
}
if (recipe.isRollout) {
if (!(await this.isInBucketAllocation(recipe.bucketConfig))) {
lazy.log.debug(
`No longer meet bucketing for "${recipe.slug}"; unenrolling...`
);
this.unenroll(recipe.slug, "bucketing");
return false;
} else if (
!enrollment.active &&
enrollment.unenrollReason !== "individual-opt-out"
) {
lazy.log.debug(`Re-enrolling in rollout "${recipe.slug}`);
return !!(await this.enroll(recipe, source, { reenroll: true }));
}
}
// Stay in the same branch, don't re-sample every time.
const branch = recipe.branches.find(
branch => branch.slug === enrollment.branch.slug
);
if (!branch) {
// Our branch has been removed. Unenroll.
this.unenroll(recipe.slug, "branch-removed");
return false;
}
return true;
}
/**
* Stop an enrollment that is currently active
*
* @param {string} slug
* The slug of the enrollment to stop.
* @param {string} reason
* An optional reason for the unenrollment.
*
* This will be reported in telemetry.
*/
unenroll(slug, reason = "unknown") {
const enrollment = this.store.get(slug);
if (!enrollment) {
this.sendFailureTelemetry("unenrollFailed", slug, "does-not-exist");
throw new Error(`Could not find an experiment with the slug "${slug}"`);
}
this._unenroll(enrollment, { reason });
}
/**
* Stop an enrollment that is currently active.
*
* @param {Enrollment} enrollment
* The enrollment to end.
*
* @param {object} options
* @param {string} options.reason
* An optional reason for the unenrollment.
*
* This will be reported in telemetry.
*
* @param {object?} options.changedPref
* If the unenrollment was due to pref change, this will contain the
* information about the pref that changed.
*
* @param {string} options.changedPref.name
* The name of the pref that caused the unenrollment.
*
* @param {string} options.changedPref.branch
* The branch that was changed ("user" or "default").
*/
_unenroll(
enrollment,
{
reason = "unknown",
changedPref = undefined,
duringRestore = false,
conflictingSlug = undefined,
prefName = undefined,
prefType = undefined,
} = {}
) {
const { slug } = enrollment;
if (!enrollment.active) {
this.sendFailureTelemetry("unenrollFailed", slug, "already-unenrolled");
throw new Error(
`Cannot stop experiment "${slug}" because it is already expired`
);
}
lazy.TelemetryEnvironment.setExperimentInactive(slug);
// We also need to set the experiment inactive in the Glean Experiment API
Services.fog.setExperimentInactive(slug);
this.store.updateExperiment(slug, {
active: false,
unenrollReason: reason,
});
lazy.TelemetryEvents.sendEvent(
"unenroll",
TELEMETRY_EVENT_OBJECT,
slug,
Object.assign(
{
reason,
branch: enrollment.branch.slug,
},
typeof changedPref !== "undefined"
? { changedPref: changedPref.name }
: {},
typeof conflictingSlug !== "undefined" ? { conflictingSlug } : {},
reason === REASON_PREFFLIPS_FAILED ? { prefType, prefName } : {}
)
);
// Sent Glean event equivalent
Glean.nimbusEvents.unenrollment.record(
Object.assign(
{
experiment: slug,
branch: enrollment.branch.slug,
reason,
},
typeof changedPref !== "undefined"
? { changed_pref: changedPref.name }
: {},
typeof conflictingSlug !== "undefined"
? { conflicting_slug: conflictingSlug }
: {},
reason === REASON_PREFFLIPS_FAILED
? {
pref_type: prefType,
pref_name: prefName,
}
: {}
)
);
this._unsetEnrollmentPrefs(enrollment, { changedPref, duringRestore });
lazy.log.debug(`Recipe unenrolled: ${slug}`);
}
/**
* Unenroll from all active studies if user opts out.
*/
observe() {
if (!this.studiesEnabled) {
for (const { slug } of this.store.getAllActiveExperiments()) {
this.unenroll(slug, "studies-opt-out");
}
for (const { slug } of this.store.getAllActiveRollouts()) {
this.unenroll(slug, "studies-opt-out");
}
}
Services.obs.notifyObservers(null, STUDIES_ENABLED_CHANGED);
}
/**
* Send Telemetry for undesired event
*
* @param {string} eventName
* @param {string} slug
* @param {string} reason
*/
sendFailureTelemetry(eventName, slug, reason) {
lazy.TelemetryEvents.sendEvent(eventName, TELEMETRY_EVENT_OBJECT, slug, {
reason,
});
if (eventName == "enrollFailed") {
Glean.nimbusEvents.enrollFailed.record({
experiment: slug,
reason,
});
} else if (eventName == "unenrollFailed") {
Glean.nimbusEvents.unenrollFailed.record({
experiment: slug,
reason,
});
}
}
sendValidationFailedTelemetry(slug, reason, extra) {
lazy.TelemetryEvents.sendEvent(
"validationFailed",
TELEMETRY_EVENT_OBJECT,
slug,
{
reason,
...extra,
}
);
Glean.nimbusEvents.validationFailed.record({
experiment: slug,
reason,
...extra,
});
}
/**
*
* @param {Enrollment} experiment
*/
sendEnrollmentTelemetry({ slug, branch, experimentType }) {
lazy.TelemetryEvents.sendEvent("enroll", TELEMETRY_EVENT_OBJECT, slug, {
experimentType,
branch: branch.slug,
});
Glean.nimbusEvents.enrollment.record({
experiment: slug,
branch: branch.slug,
experiment_type: experimentType,
});
}
/**
*
* @param {object} enrollmentStatus
* @param {string} enrollmentStatus.slug
* @param {string} enrollmentStatus.status
* @param {string?} enrollmentStatus.reason
* @param {string?} enrollmentStatus.branch
* @param {string?} enrollmentStatus.error_string
* @param {string?} enrollmentStatus.conflict_slug
*/
sendEnrollmentStatusTelemetry({
slug,
status,
reason,
branch,
error_string,
conflict_slug,
}) {
Glean.nimbusEvents.enrollmentStatus.record({
slug,
status,
reason,
branch,
error_string,
conflict_slug,
});
}
/**
* Sets Telemetry when activating an experiment.
*
* @param {Enrollment} experiment
*/
setExperimentActive(experiment) {
lazy.TelemetryEnvironment.setExperimentActive(
experiment.slug,
experiment.branch.slug,
{
type: `${TELEMETRY_EXPERIMENT_ACTIVE_PREFIX}${experiment.experimentType}`,
}
);
// Report the experiment to the Glean Experiment API
Services.fog.setExperimentActive(experiment.slug, experiment.branch.slug, {
type: `${TELEMETRY_EXPERIMENT_ACTIVE_PREFIX}${experiment.experimentType}`,
});
}
/**
* Generate Normandy UserId respective to a branch
* for a given experiment.
*
* @param {string} slug
* @param {Array<{slug: string; ratio: number}>} branches
* @param {string} namespace
* @param {number} start
* @param {number} count
* @param {number} total
* @returns {Promise<{[branchName: string]: string}>} An object where
* the keys are branch names and the values are user IDs that will enroll
* a user for that particular branch. Also includes a `notInExperiment` value
* that will not enroll the user in the experiment if not 100% enrollment.
*/
async generateTestIds(recipe) {
// Older recipe structure had bucket config values at the top level while
// newer recipes group them into a bucketConfig object
const { slug, branches, namespace, start, count, total } = {
...recipe,
...recipe.bucketConfig,
};
const branchValues = {};
const includeNot = count < total;
if (!slug || !namespace) {
throw new Error(`slug, namespace not in expected format`);
}
if (!(start < total && count <= total)) {
throw new Error("Must include start, count, and total as integers");
}
if (
!Array.isArray(branches) ||
branches.filter(branch => branch.slug && branch.ratio).length !==
branches.length
) {
throw new Error("branches parameter not in expected format");
}
while (Object.keys(branchValues).length < branches.length + includeNot) {
const id = lazy.NormandyUtils.generateUuid();
const enrolls = await lazy.Sampling.bucketSample(
[id, namespace],
start,
count,
total
);
// Does this id enroll the user in the experiment
if (enrolls) {
// Choose a random branch
const { slug: pickedBranch } = await this.chooseBranch(
slug,
branches,
id
);
if (!Object.keys(branchValues).includes(pickedBranch)) {
branchValues[pickedBranch] = id;
lazy.log.debug(`Found a value for "${pickedBranch}"`);
}
} else if (!branchValues.notInExperiment) {
branchValues.notInExperiment = id;
}
}
return branchValues;
}
/**
* Choose a branch randomly.
*
* @param {string} slug
* @param {Branch[]} branches
* @param {string} userId
* @returns {Promise<Branch>}
* @memberof _ExperimentManager
*/
async chooseBranch(slug, branches, userId = lazy.ClientEnvironment.userId) {
const ratios = branches.map(({ ratio = 1 }) => ratio);
// It's important that the input be:
// - Unique per-user (no one is bucketed alike)
// - Unique per-experiment (bucketing differs across multiple experiments)
// - Differs from the input used for sampling the recipe (otherwise only
// branches that contain the same buckets as the recipe sampling will
// receive users)
const input = `${this.id}-${userId}-${slug}-branch`;
const index = await lazy.Sampling.ratioSample(input, ratios);
return branches[index];
}
/**
* Generate the list of prefs a recipe will set.
*
* @params {object} branch The recipe branch that will be enrolled.
* @params {boolean} isRollout Whether or not this recipe is a rollout.
*
* @returns {object} An object with the following keys:
*
* `prefs`:
* The full list of prefs that this recipe would set,
* if there are no conflicts. This will include prefs
* that, for example, will not be set because this
* enrollment is a rollout and there is an active
* experiment that set the same pref.
*
* `prefsToSet`:
* Prefs that should be set once enrollment is
* complete.
*/
_getPrefsForBranch(branch, isRollout = false) {
const prefs = [];
const prefsToSet = [];
const getConflictingEnrollment = this._makeEnrollmentCache(isRollout);
for (const { featureId, value: featureValue } of featuresCompat(branch)) {
const feature = lazy.NimbusFeatures[featureId];
if (!feature) {
continue;
}
// It is possible to enroll in both an experiment and a rollout, so we
// need to check if we have another enrollment for the same feature.
const conflictingEnrollment = getConflictingEnrollment(featureId);
for (let [variable, value] of Object.entries(featureValue)) {
const setPref = feature.getSetPref(variable);
if (setPref) {
const { pref: prefName, branch: prefBranch } = setPref;
let originalValue;
const conflictingPref = conflictingEnrollment?.prefs?.find(
p => p.name === prefName
);
if (conflictingPref) {
// If there is another enrollment that has already set the pref we
// care about, we use its stored originalValue.
originalValue = conflictingPref.originalValue;
} else if (
prefBranch === "user" &&
!Services.prefs.prefHasUserValue(prefName)
) {
// If there is a default value set, then attempting to read the user
// branch would result in returning the default branch value.
originalValue = null;
} else {
// If there is an active prefFlips experiment for this pref on this
// branch, we must use its originalValue.
const prefFlip = this._prefFlips._prefs.get(prefName);
if (prefFlip?.branch === prefBranch) {
originalValue = prefFlip.originalValue;
} else {
originalValue = lazy.PrefUtils.getPref(prefName, {
branch: prefBranch,
});
}
}
prefs.push({
name: prefName,
branch: prefBranch,
featureId,
variable,
originalValue,
});
// An experiment takes precedence if there is already a pref set.
if (!isRollout || !conflictingPref) {
if (
lazy.NimbusFeatures[featureId].manifest.variables[variable]
.type === "json"
) {
value = JSON.stringify(value);
}
prefsToSet.push({
name: prefName,
value,
prefBranch,
});
}
}
}
}
return { prefs, prefsToSet };
}
/**
* Set a list of prefs from enrolling in an experiment or rollout.
*
* The ExperimentManager's pref observers will be disabled while setting each
* pref so as not to accidentally unenroll an existing rollout that an
* experiment would override.
*
* @param {object[]} prefsToSet
* A list of objects containing the prefs to set.
*
* Each object has the following properties:
*
* * `name`: The name of the pref.
* * `value`: The value of the pref.
* * `prefBranch`: The branch to set the pref on (either "user" or "default").
*/
_setEnrollmentPrefs(prefsToSet) {
for (const { name, value, prefBranch } of prefsToSet) {
const entry = this._prefs.get(name);
// If another enrollment exists that has set this pref, temporarily
// disable the pref observer so as not to cause unenrollment.
if (entry) {
entry.enrollmentChanging = true;
}
lazy.PrefUtils.setPref(name, value, { branch: prefBranch });
if (entry) {
entry.enrollmentChanging = false;
}
}
}
/**
* Unset prefs set during this enrollment.
*
* If this enrollment is an experiment and there is an existing rollout that
* would set a pref that was covered by this enrollment, the pref will be
* updated to that rollout's value.
*
* Otherwise, it will be set to the original value from before the enrollment
* began.
*
* @param {Enrollment} enrollment
* The enrollment that has ended.
*
* @param {object} options
*
* @param {object?} options.changedPref
* If provided, a changed pref that caused the unenrollment that
* triggered unsetting these prefs. This is provided as to not
* overwrite a changed pref with an original value.
*
* @param {string} options.changedPref.name
* The name of the changed pref.
*
* @param {string} options.changedPref.branch
* The branch that was changed ("user" or "default").
*
* @param {boolean} options.duringRestore
* The unenrollment was caused during restore.
*/
_unsetEnrollmentPrefs(enrollment, { changedPref, duringRestore } = {}) {
if (!enrollment.prefs?.length) {
return;
}
const getConflictingEnrollment = this._makeEnrollmentCache(
enrollment.isRollout
);
for (const pref of enrollment.prefs) {
this._removePrefObserver(pref.name, enrollment.slug);
if (
changedPref?.name == pref.name &&
changedPref.branch === pref.branch
) {
// Resetting the original value would overwite the pref the user just
// set. Skip it.
continue;
}
let newValue = pref.originalValue;
// If we are unenrolling from an experiment during a restore, we must
// ignore any potential conflicting rollout in the store, because its
// hasn't gone through `_restoreEnrollmentPrefs`, which might also cause
// it to unenroll.
//
// Both enrollments will have the same `originalValue` stored, so it will
// always be restored.
if (!duringRestore || enrollment.isRollout) {
const conflictingEnrollment = getConflictingEnrollment(pref.featureId);
const conflictingPref = conflictingEnrollment?.prefs?.find(
p => p.name === pref.name
);
if (conflictingPref) {
if (enrollment.isRollout) {
// If we are unenrolling from a rollout, we have an experiment that
// has set the pref. Since experiments take priority, we do not unset
// it.
continue;
} else {
// If we are an unenrolling from an experiment, we have a rollout that would
// set the same pref, so we update the pref to that value instead of
// the original value.
newValue = getFeatureFromBranch(
conflictingEnrollment.branch,
pref.featureId
).value[pref.variable];
}
}
}
// If another enrollment exists that has set this pref, temporarily
// disable the pref observer so as not to cause unenrollment when we
// update the pref to its value.
const entry = this._prefs.get(pref.name);
if (entry) {
entry.enrollmentChanging = true;
}
lazy.PrefUtils.setPref(pref.name, newValue, {
branch: pref.branch,
});
if (entry) {
entry.enrollmentChanging = false;
}
}
}
/**
* Restore the prefs set by an enrollment.
*
* @param {object} enrollment The enrollment.
* @param {object} enrollment.branch The branch that was enrolled.
* @param {object[]} enrollment.prefs The prefs that are set by the enrollment.
* @param {object[]} enrollment.isRollout The prefs that are set by the enrollment.
*
* @returns {boolean} Whether the restore was successful. If false, the
* enrollment has ended.
*/
_restoreEnrollmentPrefs(enrollment) {
const { branch, prefs = [], isRollout } = enrollment;
if (!prefs?.length) {
return false;
}
const featuresById = Object.assign(
...featuresCompat(branch).map(f => ({ [f.featureId]: f }))
);
for (const { name, featureId, variable } of prefs) {
// If the feature no longer exists, unenroll.
if (!Object.hasOwn(lazy.NimbusFeatures, featureId)) {
this._unenroll(enrollment, {
reason: "invalid-feature",
duringRestore: true,
});
return false;
}
const variables = lazy.NimbusFeatures[featureId].manifest.variables;
// If the feature is missing a variable that set a pref, unenroll.
if (!Object.hasOwn(variables, variable)) {
this._unenroll(enrollment, {
reason: "pref-variable-missing",
duringRestore: true,
});
return false;
}
const variableDef = variables[variable];
// If the variable is no longer a pref-setting variable, unenroll.
if (!Object.hasOwn(variableDef, "setPref")) {
this._unenroll(enrollment, {
reason: "pref-variable-no-longer",
duringRestore: true,
});
return false;
}
// If the variable is setting a different preference, unenroll.
const prefName =
typeof variableDef.setPref === "object"
? variableDef.setPref.pref
: variableDef.setPref;
if (prefName !== name) {
this._unenroll(enrollment, {
reason: "pref-variable-changed",
duringRestore: true,
});
return false;
}
}
for (const { name, branch: prefBranch, featureId, variable } of prefs) {
// User prefs are already persisted.
if (prefBranch === "user") {
continue;
}
// If we are a rollout, we need to check for an existing experiment that
// has set the same pref. If so, we do not need to set the pref because
// experiments take priority.
if (isRollout) {
const conflictingEnrollment =
this.store.getExperimentForFeature(featureId);
const conflictingPref = conflictingEnrollment?.prefs?.find(
p => p.name === name
);
if (conflictingPref) {
continue;
}
}
let value = featuresById[featureId].value[variable];
if (
lazy.NimbusFeatures[featureId].manifest.variables[variable].type ===
"json"
) {
value = JSON.stringify(value);
}
if (prefBranch !== "user") {
lazy.PrefUtils.setPref(name, value, { branch: prefBranch });
}
}
return true;
}
/**
* Make a cache to look up enrollments of the oppposite kind by feature ID.
*
* @param {boolean} isRollout Whether or not the current enrollment is a
* rollout. If true, the cache will return
* experiments. If false, the cache will return
* rollouts.
*
* @returns {function} The cache, as a callable function.
*/
_makeEnrollmentCache(isRollout) {
const getOtherEnrollment = (
isRollout
? this.store.getExperimentForFeature
: this.store.getRolloutForFeature
).bind(this.store);
const conflictingEnrollments = {};
return featureId => {
if (!Object.hasOwn(conflictingEnrollments, featureId)) {
conflictingEnrollments[featureId] = getOtherEnrollment(featureId);
}
return conflictingEnrollments[featureId];
};
}
/**
* Update the set of observers with prefs set by the given enrollment.
*
* @param {Enrollment} enrollment
* The enrollment that is setting prefs.
*/
_updatePrefObservers({ slug, prefs }) {
if (!prefs?.length) {
return;
}
for (const pref of prefs) {
const { name } = pref;
if (!this._prefs.has(name)) {
const observer = (aSubject, aTopic, aData) => {
// This observer will be called for changes to `name` as well as any
// other pref that begins with `name.`, so we have to filter to
// exactly the pref we care about.
if (aData === name) {
this._onExperimentPrefChanged(pref);
}
};
const entry = {
slugs: new Set([slug]),
enrollmentChanging: false,
observer,
};
Services.prefs.addObserver(name, observer);
this._prefs.set(name, entry);
} else {
this._prefs.get(name).slugs.add(slug);
}
if (!this._prefsBySlug.has(slug)) {
this._prefsBySlug.set(slug, new Set([name]));
} else {
this._prefsBySlug.get(slug).add(name);
}
}
}
/**
* Remove an entry for the pref observer for the given pref and slug.
*
* If there are no more enrollments listening to a pref, the observer will be removed.
*
* This is called when an enrollment is ending.
*
* @param {string} name The name of the pref.
* @param {string} slug The slug of the enrollment that is being unenrolled.
*/
_removePrefObserver(name, slug) {
// Update the pref observer that the current enrollment is no longer
// involved in the pref.
//
// If no enrollments have a variable setting the pref, then we can remove
// the observers.
const entry = this._prefs.get(name);
// If this is happening due to a pref change, the observers will already be removed.
if (entry) {
entry.slugs.delete(slug);
if (entry.slugs.size == 0) {
Services.prefs.removeObserver(name, entry.observer);
this._prefs.delete(name);
}
}
const bySlug = this._prefsBySlug.get(slug);
if (bySlug) {
bySlug.delete(name);
if (bySlug.size == 0) {
this._prefsBySlug.delete(slug);
}
}
}
/**
* Handle a change to a pref set by enrollments by ending those enrollments.
*
* @param {object} pref
* Information about the pref that was changed.
*
* @param {string} pref.name
* The name of the pref that was changed.
*
* @param {string} pref.branch
* The branch enrollments set the pref on.
*
* @param {string} pref.featureId
* The feature ID of the feature containing the variable that set the
* pref.
*
* @param {string} pref.variable
* The variable in the given feature whose value determined the pref's
* value.
*/
_onExperimentPrefChanged(pref) {
const entry = this._prefs.get(pref.name);
// If this was triggered while we are enrolling or unenrolling from an
// experiment, then we don't want to unenroll from the rollout because the
// experiment's value is taking precendence.
//
// Otherwise, all enrollments that set the variable corresponding to this
// pref must be unenrolled.
if (entry.enrollmentChanging) {
return;
}
// Copy the `Set` into an `Array` because we modify the set later in
// `_removePrefObserver` and we need to iterate over it multiple times.
const slugs = Array.from(entry.slugs);
// Remove all pref observers set by enrollments. We are potentially about
// to set these prefs during unenrollment, so we don't want to trigger
// them and cause nested unenrollments.
for (const slug of slugs) {
const toRemove = Array.from(this._prefsBySlug.get(slug) ?? []);
for (const name of toRemove) {
this._removePrefObserver(name, slug);
}
}
// Unenroll from the rollout first to save calls to setPref.
const enrollments = Array.from(slugs).map(slug => this.store.get(slug));
// There is a maximum of two enrollments (one experiment and one rollout).
if (enrollments.length == 2) {
// Order enrollments so that we unenroll from the rollout first.
if (!enrollments[0].isRollout) {
enrollments.reverse();
}
}
const feature = getFeatureFromBranch(
enrollments.at(-1).branch,
pref.featureId
);
const changedPref = {
name: pref.name,
branch: PrefFlipsFeature.determinePrefChangeBranch(
pref.name,
pref.branch,
feature.value[pref.variable]
),
};
for (const enrollment of enrollments) {
this._unenroll(enrollment, { reason: "changed-pref", changedPref });
}
}
}
export const ExperimentManager = new _ExperimentManager();