Bug 1707901 - Add opt in to nimbus experiments via URL r=k88hudson

Differential Revision: https://phabricator.services.mozilla.com/D113641
This commit is contained in:
Andrei Oprea 2021-05-05 08:58:56 +00:00
parent cb53a7c598
commit 92b0ab56f1
12 changed files with 398 additions and 13 deletions

View File

@ -1489,6 +1489,7 @@ pref("browser.messaging-system.personalized-cfr.score-threshold", 5000);
pref("messaging-system.log", "warn");
pref("messaging-system.rsexperimentloader.enabled", true);
pref("messaging-system.rsexperimentloader.collection_id", "nimbus-desktop-experiments");
pref("nimbus.debug", false);
// Enable the DOM fullscreen API.
pref("full-screen-api.enabled", true);

View File

@ -673,7 +673,7 @@ let JSWINDOWACTORS = {
ShieldPageEvent: { wantUntrusted: true },
},
},
matches: ["about:studies"],
matches: ["about:studies*"],
},
ASRouter: {

View File

@ -176,22 +176,13 @@ class _ExperimentManager {
* @rejects {Error}
* @memberof _ExperimentManager
*/
async enroll(
{
slug,
branches,
experimentType = TELEMETRY_DEFAULT_EXPERIMENT_TYPE,
userFacingName,
userFacingDescription,
},
source
) {
async enroll(recipe, source) {
let { slug, branches } = recipe;
if (this.store.has(slug)) {
this.sendFailureTelemetry("enrollFailed", slug, "name-conflict");
throw new Error(`An experiment with the slug "${slug}" already exists.`);
}
const enrollmentId = NormandyUtils.generateUuid();
const branch = await this.chooseBranch(slug, branches);
if (
@ -208,12 +199,26 @@ class _ExperimentManager {
return null;
}
return this._enroll(recipe, branch, source);
}
_enroll(
{
slug,
experimentType = TELEMETRY_DEFAULT_EXPERIMENT_TYPE,
userFacingName,
userFacingDescription,
},
branch,
source,
options = {}
) {
/** @type {Enrollment} */
const experiment = {
slug,
branch,
active: true,
enrollmentId,
enrollmentId: NormandyUtils.generateUuid(),
experimentType,
source,
userFacingName,
@ -221,6 +226,12 @@ class _ExperimentManager {
lastSeen: new Date().toJSON(),
};
// Tag this as a forced enrollment. This prevents all unenrolling unless
// manually triggered from about:studies
if (options.force) {
experiment.force = true;
}
this.store.addExperiment(experiment);
this.setExperimentActive(experiment);
this.sendEnrollmentTelemetry(experiment);
@ -230,6 +241,29 @@ class _ExperimentManager {
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.
*/
let experiment = this.store.getExperimentForFeature(
branch.feature?.featureId
);
if (experiment) {
log.debug(
`Existing experiment found for the same feature ${branch?.feature.featureId}, unenrolling.`
);
this.unenroll(experiment.slug, source);
}
recipe.userFacingName = `${recipe.userFacingName} - Forced enrollment`;
return this._enroll(recipe, branch, source, { force: true });
}
/**
* Update an enrollment that was already set
*
@ -245,6 +279,11 @@ class _ExperimentManager {
return;
}
// Don't check forced enrollments
if (experiment.force) {
return;
}
// Stay in the same branch, don't re-sample every time.
const branch = recipe.branches.find(
branch => branch.slug === experiment.branch.slug

View File

@ -47,6 +47,7 @@ const TIMER_NAME = "rs-experiment-loader-timer";
const TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${TIMER_NAME}`;
// Use the same update interval as normandy
const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds";
const NIMBUS_DEBUG_PREF = "nimbus.debug";
XPCOMUtils.defineLazyPreferenceGetter(
this,
@ -54,6 +55,12 @@ XPCOMUtils.defineLazyPreferenceGetter(
COLLECTION_ID_PREF,
COLLECTION_ID_FALLBACK
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"NIMBUS_DEBUG",
NIMBUS_DEBUG_PREF,
false
);
/**
* Responsible for pre-fetching remotely defined configurations from
@ -314,6 +321,41 @@ class _RemoteSettingsExperimentLoader {
this._updating = false;
}
async optInToExperiment({ slug, branch: branchSlug, collection }) {
if (!NIMBUS_DEBUG) {
log.debug(
`Force enrollment only works when '${NIMBUS_DEBUG_PREF}' is enabled.`
);
return null;
}
let recipes;
try {
recipes = await RemoteSettings(collection || COLLECTION_ID).get();
} catch (e) {
log.debug("Error getting recipes from remote settings.");
Cu.reportError(e);
}
let recipe = recipes.find(r => r.slug === slug);
if (!recipe) {
log.debug(
`Could not find experiment slug ${slug} in collection ${collection ||
COLLECTION_ID}.`
);
return null;
}
let branch = recipe.branches.find(b => b.slug === branchSlug);
if (!branch) {
log.debug(`Could not find branch slug ${branch} in ${slug}.`);
return null;
}
return ExperimentManager.forceEnroll(recipe, branch);
}
/**
* Handles feature status based on feature pref and STUDIES_OPT_OUT_PREF.
* Changing any of them to false will turn off any recipe fetching and

View File

@ -3,6 +3,7 @@ support-files =
head.js
[browser_remotesettingsexperimentloader_remote_defaults.js]
[browser_remotesettingsexperimentloader_force_enrollment.js]
[browser_experimentstore_load.js]
[browser_remotesettings_experiment_enroll.js]
[browser_experiment_evaluate_jexl.js]

View File

@ -0,0 +1,139 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const { RemoteSettings } = ChromeUtils.import(
"resource://services-settings/remote-settings.js"
);
const {
RemoteDefaultsLoader,
RemoteSettingsExperimentLoader,
} = ChromeUtils.import(
"resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm"
);
const { BrowserTestUtils } = ChromeUtils.import(
"resource://testing-common/BrowserTestUtils.jsm"
);
const { ExperimentFakes } = ChromeUtils.import(
"resource://testing-common/NimbusTestUtils.jsm"
);
const { ExperimentManager } = ChromeUtils.import(
"resource://nimbus/lib/ExperimentManager.jsm"
);
async function setup(recipes) {
const client = RemoteSettings("nimbus-desktop-experiments");
await client.db.importChanges({}, 42, recipes, {
clear: true,
});
await BrowserTestUtils.waitForCondition(
async () => (await client.get()).length,
"RS is ready"
);
registerCleanupFunction(async () => {
await client.db.clear();
});
return client;
}
add_task(async function setup() {
await SpecialPowers.pushPrefEnv({
set: [
["messaging-system.log", "all"],
["nimbus.debug", true],
],
});
registerCleanupFunction(async () => {
await SpecialPowers.popPrefEnv();
});
});
add_task(async function test_fetch_recipe_and_branch_no_debug() {
const sandbox = sinon.createSandbox();
Services.prefs.setBoolPref("nimbus.debug", false);
let stub = sandbox.stub(ExperimentManager, "forceEnroll").returns(true);
let recipes = [ExperimentFakes.recipe("slug123")];
await setup(recipes);
let result = await RemoteSettingsExperimentLoader.optInToExperiment({
slug: "slug123",
branch: "control",
});
Assert.ok(!result, "Pref is not turned on");
Assert.ok(stub.notCalled, "forceEnroll is not called");
Services.prefs.setBoolPref("nimbus.debug", true);
result = await RemoteSettingsExperimentLoader.optInToExperiment({
slug: "slug123",
branch: "control",
});
Assert.ok(result, "Pref was turned on");
Assert.ok(stub.called, "forceEnroll is called");
sandbox.restore();
});
add_task(async function test_fetch_recipe_and_branch_badslug() {
const sandbox = sinon.createSandbox();
let stub = sandbox.stub(ExperimentManager, "forceEnroll").returns(true);
let recipes = [ExperimentFakes.recipe("slug123")];
await setup(recipes);
let result = await RemoteSettingsExperimentLoader.optInToExperiment({
slug: "slug12",
branch: "control",
});
Assert.ok(!result, "Recipe slug doesn't exist");
Assert.ok(stub.notCalled, "forceEnroll is not called");
sandbox.restore();
});
add_task(async function test_fetch_recipe_and_branch_badbranch() {
const sandbox = sinon.createSandbox();
let stub = sandbox.stub(ExperimentManager, "forceEnroll").returns(true);
let recipes = [ExperimentFakes.recipe("slug123")];
await setup(recipes);
let result = await RemoteSettingsExperimentLoader.optInToExperiment({
slug: "slug123",
branch: "branch",
});
Assert.ok(!result, "Recipe slug doesn't exist");
Assert.ok(stub.notCalled, "forceEnroll is not called");
sandbox.restore();
});
add_task(async function test_fetch_recipe_and_branch() {
const sandbox = sinon.createSandbox();
let stub = sandbox.stub(ExperimentManager, "forceEnroll").returns(true);
let recipes = [ExperimentFakes.recipe("slug123")];
await setup(recipes);
let result = await RemoteSettingsExperimentLoader.optInToExperiment({
slug: "slug123",
branch: "control",
});
Assert.ok(result, "Recipe found");
Assert.ok(stub.called, "Called forceEnroll");
Assert.deepEqual(stub.firstCall.args[0], recipes[0], "Called with recipe");
Assert.deepEqual(
stub.firstCall.args[1],
recipes[0].branches[0],
"Called with branch"
);
sandbox.restore();
});

View File

@ -12,6 +12,9 @@ const { Sampling } = ChromeUtils.import(
const { ClientEnvironment } = ChromeUtils.import(
"resource://normandy/lib/ClientEnvironment.jsm"
);
const { TestUtils } = ChromeUtils.import(
"resource://testing-common/TestUtils.jsm"
);
const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
// Experiment store caches in prefs Enrollments for fast sync access
@ -247,3 +250,102 @@ add_task(async function enroll_in_reference_aw_experiment() {
// in prefs.
Assert.ok(prefValue.length < 3498, "Make sure we don't bloat the prefs");
});
add_task(async function test_forceEnroll_cleanup() {
const manager = ExperimentFakes.manager();
const sandbox = sinon.createSandbox();
let unenrollStub = sandbox.spy(manager, "unenroll");
let existingRecipe = ExperimentFakes.recipe("foo", {
branches: [
{
slug: "treatment",
ratio: 1,
feature: { featureId: "force-enrollment", enabled: true },
},
],
});
let forcedRecipe = ExperimentFakes.recipe("bar", {
branches: [
{
slug: "treatment",
ratio: 1,
feature: { featureId: "force-enrollment", enabled: true },
},
],
});
await manager.onStartup();
await manager.enroll(existingRecipe);
let setExperimentActiveSpy = sandbox.spy(manager, "setExperimentActive");
manager.forceEnroll(forcedRecipe, forcedRecipe.branches[0]);
Assert.ok(unenrollStub.called, "Unenrolled from existing experiment");
Assert.equal(
unenrollStub.firstCall.args[0],
existingRecipe.slug,
"Called with existing recipe slug"
);
Assert.ok(setExperimentActiveSpy.calledOnce, "Activated forced experiment");
Assert.equal(
setExperimentActiveSpy.firstCall.args[0].slug,
forcedRecipe.slug,
"Called with forced experiment slug"
);
Assert.equal(
manager.store.getExperimentForFeature("force-enrollment").slug,
forcedRecipe.slug,
"Enrolled in forced experiment"
);
sandbox.restore();
});
add_task(async function test_updateEnrollment_skip_force() {
const manager = ExperimentFakes.manager();
let recipe = ExperimentFakes.recipe("foo");
const sandbox = sinon.createSandbox();
const updateEnrollmentSpy = sandbox.spy(manager, "updateEnrollment");
const unenrollSpy = sandbox.spy(manager, "unenroll");
await manager.onStartup();
await manager.enroll(recipe);
Assert.ok(manager.store.has("foo"), "Finished enrollment");
// Something about the experiment change and we won't fit in the same
// branch assignment
await manager.onRecipe(
{ ...recipe, branches: [] },
"test_ExperimentManager_enroll"
);
Assert.ok(
updateEnrollmentSpy.calledOnce,
"Update enrollement is called because we have the same slug"
);
Assert.ok(unenrollSpy.calledOnce, "Because no matching branch is found");
Assert.ok(unenrollSpy.firstCall.args[0], "foo");
updateEnrollmentSpy.resetHistory();
unenrollSpy.resetHistory();
recipe = ExperimentFakes.recipe("bar");
await manager.forceEnroll(recipe, recipe.branches[0]);
Assert.ok(manager.store.has("bar"), "Finished enrollment");
// Something about the experiment change and we won't fit in the same
// branch assignment but this time it's on a forced enrollment
await manager.onRecipe(
{ ...recipe, branches: [] },
"test_ExperimentManager_enroll"
);
Assert.ok(
updateEnrollmentSpy.calledOnce,
"Update enrollement is called because we have the same slug"
);
Assert.ok(unenrollSpy.notCalled, "Because this is a forced enrollment");
});

View File

@ -33,6 +33,11 @@ ChromeUtils.defineModuleGetter(
"ExperimentManager",
"resource://nimbus/lib/ExperimentManager.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"RemoteSettingsExperimentLoader",
"resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm"
);
var EXPORTED_SYMBOLS = ["AboutPages"];
@ -120,6 +125,10 @@ XPCOMUtils.defineLazyGetter(AboutPages, "aboutStudies", () => {
return ExperimentManager.store.getAll();
},
optInToExperiment(data) {
RemoteSettingsExperimentLoader.optInToExperiment(data);
},
/** Add a browsing context to the weak set;
* this weak set keeps track of all contexts
* that are housing an about:studies page.

View File

@ -126,6 +126,9 @@ class ShieldFrameChild extends JSWindowActorChild {
strings
);
break;
case "ExperimentOptIn":
this.sendQuery("Shield:ExperimentOptIn", event.detail.data);
break;
}
}

View File

@ -47,6 +47,8 @@ class ShieldFrameParent extends JSWindowActorParent {
break;
case "Shield:GetStudiesEnabled":
return aboutStudies.getStudiesEnabled();
case "Shield:ExperimentOptIn":
return aboutStudies.optInToExperiment(msg.data);
}
return null;

View File

@ -23,6 +23,15 @@ function sendPageEvent(action, data) {
document.dispatchEvent(event);
}
function readOptinParams() {
let searchParams = new URLSearchParams(new URL(location).search);
return {
slug: searchParams.get("optin_slug"),
branch: searchParams.get("optin_branch"),
collection: searchParams.get("optin_collection"),
};
}
/**
* Handle basic layout and routing within about:studies.
*/
@ -50,6 +59,10 @@ class AboutStudies extends React.Component {
document.addEventListener(`ReceiveRemoteValue:${remoteName}`, this);
sendPageEvent(`GetRemoteValue:${remoteName}`);
}
let optinParams = readOptinParams();
if (optinParams.branch && optinParams.slug) {
sendPageEvent(`ExperimentOptIn`, optinParams);
}
}
componentWillUnmount() {

View File

@ -12,6 +12,9 @@ const { ExperimentFakes } = ChromeUtils.import(
const { ExperimentManager } = ChromeUtils.import(
"resource://nimbus/lib/ExperimentManager.jsm"
);
const { RemoteSettingsExperimentLoader } = ChromeUtils.import(
"resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm"
);
const { PromiseUtils } = ChromeUtils.import(
"resource://gre/modules/PromiseUtils.jsm"
);
@ -747,3 +750,34 @@ add_task(async function test_getStudiesEnabled() {
"about:studies is enabled if the pref is enabled"
);
});
add_task(async function test_forceEnroll() {
let sandbox = sinon.createSandbox();
let stub = sandbox.stub(RemoteSettingsExperimentLoader, "optInToExperiment");
await BrowserTestUtils.withNewTab(
{
gBrowser,
url:
"about:studies?optin_collection=collection123&optin_branch=branch123&optin_slug=slug123",
},
async browser => {
await SpecialPowers.spawn(browser, [], async () => {
await ContentTaskUtils.waitForCondition(
() => content.document.querySelector(".info-box-content"),
"Wait for content to load"
);
return true;
});
}
);
Assert.ok(stub.called, "Called optInToExperiment");
Assert.deepEqual(
stub.firstCall.args[0],
{ slug: "slug123", branch: "branch123", collection: "collection123" },
"Called with correct arguments"
);
sandbox.restore();
});