summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/actions
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/normandy/actions')
-rw-r--r--toolkit/components/normandy/actions/AddonRollbackAction.sys.mjs88
-rw-r--r--toolkit/components/normandy/actions/AddonRolloutAction.sys.mjs241
-rw-r--r--toolkit/components/normandy/actions/BaseAction.sys.mjs338
-rw-r--r--toolkit/components/normandy/actions/BaseStudyAction.sys.mjs37
-rw-r--r--toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs789
-rw-r--r--toolkit/components/normandy/actions/ConsoleLogAction.sys.mjs20
-rw-r--r--toolkit/components/normandy/actions/MessagingExperimentAction.sys.mjs34
-rw-r--r--toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs278
-rw-r--r--toolkit/components/normandy/actions/PreferenceRollbackAction.sys.mjs104
-rw-r--r--toolkit/components/normandy/actions/PreferenceRolloutAction.sys.mjs265
-rw-r--r--toolkit/components/normandy/actions/ShowHeartbeatAction.sys.mjs226
-rw-r--r--toolkit/components/normandy/actions/schemas/README.md13
-rw-r--r--toolkit/components/normandy/actions/schemas/export_json.js20
-rw-r--r--toolkit/components/normandy/actions/schemas/index.sys.mjs528
-rw-r--r--toolkit/components/normandy/actions/schemas/package.json11
15 files changed, 2992 insertions, 0 deletions
diff --git a/toolkit/components/normandy/actions/AddonRollbackAction.sys.mjs b/toolkit/components/normandy/actions/AddonRollbackAction.sys.mjs
new file mode 100644
index 0000000000..d4b2d4c423
--- /dev/null
+++ b/toolkit/components/normandy/actions/AddonRollbackAction.sys.mjs
@@ -0,0 +1,88 @@
+/* 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 { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonRollouts: "resource://normandy/lib/AddonRollouts.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
+});
+
+export class AddonRollbackAction extends BaseAction {
+ get schema() {
+ return lazy.ActionSchemas["addon-rollback"];
+ }
+
+ async _run(recipe) {
+ const { rolloutSlug } = recipe.arguments;
+ const rollout = await lazy.AddonRollouts.get(rolloutSlug);
+
+ if (!rollout) {
+ this.log.debug(`Rollback ${rolloutSlug} not applicable, skipping`);
+ return;
+ }
+
+ switch (rollout.state) {
+ case lazy.AddonRollouts.STATE_ACTIVE: {
+ await lazy.AddonRollouts.update({
+ ...rollout,
+ state: lazy.AddonRollouts.STATE_ROLLED_BACK,
+ });
+
+ const addon = await lazy.AddonManager.getAddonByID(rollout.addonId);
+ if (addon) {
+ try {
+ await addon.uninstall();
+ } catch (err) {
+ lazy.TelemetryEvents.sendEvent(
+ "unenrollFailed",
+ "addon_rollback",
+ rolloutSlug,
+ {
+ reason: "uninstall-failed",
+ enrollmentId:
+ rollout.enrollmentId ||
+ lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+ throw err;
+ }
+ } else {
+ this.log.warn(
+ `Could not uninstall addon ${rollout.addonId} for rollback ${rolloutSlug}: it is not installed.`
+ );
+ }
+
+ lazy.TelemetryEvents.sendEvent(
+ "unenroll",
+ "addon_rollback",
+ rolloutSlug,
+ {
+ reason: "rollback",
+ enrollmentId:
+ rollout.enrollmentId ||
+ lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+ lazy.TelemetryEnvironment.setExperimentInactive(rolloutSlug);
+ break;
+ }
+
+ case lazy.AddonRollouts.STATE_ROLLED_BACK: {
+ return; // Do nothing
+ }
+
+ default: {
+ throw new Error(
+ `Unexpected state when rolling back ${rolloutSlug}: ${rollout.state}`
+ );
+ }
+ }
+ }
+}
diff --git a/toolkit/components/normandy/actions/AddonRolloutAction.sys.mjs b/toolkit/components/normandy/actions/AddonRolloutAction.sys.mjs
new file mode 100644
index 0000000000..f669aaaf4f
--- /dev/null
+++ b/toolkit/components/normandy/actions/AddonRolloutAction.sys.mjs
@@ -0,0 +1,241 @@
+/* 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 { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
+ AddonRollouts: "resource://normandy/lib/AddonRollouts.sys.mjs",
+ NormandyAddonManager: "resource://normandy/lib/NormandyAddonManager.sys.mjs",
+ NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs",
+ NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
+});
+
+class AddonRolloutError extends Error {
+ /**
+ * @param {string} slug
+ * @param {object} extra Extra details to include when reporting the error to telemetry.
+ * @param {string} extra.reason The specific reason for the failure.
+ */
+ constructor(slug, extra) {
+ let message;
+ let { reason } = extra;
+ switch (reason) {
+ case "conflict": {
+ message = "an existing rollout already exists for this add-on";
+ break;
+ }
+ case "addon-id-changed": {
+ message = "upgrade add-on ID does not match installed add-on ID";
+ break;
+ }
+ case "upgrade-required": {
+ message = "a newer version of the add-on is already installed";
+ break;
+ }
+ case "download-failure": {
+ message = "the add-on failed to download";
+ break;
+ }
+ case "metadata-mismatch": {
+ message = "the server metadata does not match the downloaded add-on";
+ break;
+ }
+ case "install-failure": {
+ message = "the add-on failed to install";
+ break;
+ }
+ default: {
+ throw new Error(`Unexpected AddonRolloutError reason: ${reason}`);
+ }
+ }
+ super(`Cannot install add-on for rollout (${slug}): ${message}.`);
+ this.slug = slug;
+ this.extra = extra;
+ }
+}
+
+export class AddonRolloutAction extends BaseAction {
+ get schema() {
+ return lazy.ActionSchemas["addon-rollout"];
+ }
+
+ async _run(recipe) {
+ const { extensionApiId, slug } = recipe.arguments;
+
+ const existingRollout = await lazy.AddonRollouts.get(slug);
+ const eventName = existingRollout ? "update" : "enroll";
+ const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails(
+ extensionApiId
+ );
+ let enrollmentId = existingRollout
+ ? existingRollout.enrollmentId
+ : undefined;
+
+ // Check if the existing rollout matches the current rollout
+ if (
+ existingRollout &&
+ existingRollout.addonId === extensionDetails.extension_id
+ ) {
+ const versionCompare = Services.vc.compare(
+ existingRollout.addonVersion,
+ extensionDetails.version
+ );
+
+ if (versionCompare === 0) {
+ return; // Do nothing
+ }
+ }
+
+ const createError = (reason, extra) => {
+ return new AddonRolloutError(slug, {
+ ...extra,
+ reason,
+ });
+ };
+
+ // Check for a conflict (addon already installed by another rollout)
+ const activeRollouts = await lazy.AddonRollouts.getAllActive();
+ const conflictingRollout = activeRollouts.find(
+ rollout =>
+ rollout.slug !== slug &&
+ rollout.addonId === extensionDetails.extension_id
+ );
+ if (conflictingRollout) {
+ const conflictError = createError("conflict", {
+ addonId: conflictingRollout.addonId,
+ conflictingSlug: conflictingRollout.slug,
+ enrollmentId:
+ conflictingRollout.enrollmentId ||
+ lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ this.reportError(conflictError, "enrollFailed");
+ throw conflictError;
+ }
+
+ const onInstallStarted = (install, installDeferred) => {
+ const existingAddon = install.existingAddon;
+
+ if (existingRollout && existingRollout.addonId !== install.addon.id) {
+ installDeferred.reject(
+ createError("addon-id-changed", {
+ enrollmentId:
+ enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ })
+ );
+ return false; // cancel the upgrade, the add-on ID has changed
+ }
+
+ if (
+ existingAddon &&
+ Services.vc.compare(existingAddon.version, install.addon.version) > 0
+ ) {
+ installDeferred.reject(
+ createError("upgrade-required", {
+ enrollmentId:
+ enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ })
+ );
+ return false; // cancel the installation, must be an upgrade
+ }
+
+ return true;
+ };
+
+ const applyNormandyChanges = async install => {
+ const details = {
+ addonId: install.addon.id,
+ addonVersion: install.addon.version,
+ extensionApiId,
+ xpiUrl: extensionDetails.xpi,
+ xpiHash: extensionDetails.hash,
+ xpiHashAlgorithm: extensionDetails.hash_algorithm,
+ };
+
+ if (existingRollout) {
+ await lazy.AddonRollouts.update({
+ ...existingRollout,
+ ...details,
+ });
+ } else {
+ enrollmentId = lazy.NormandyUtils.generateUuid();
+ await lazy.AddonRollouts.add({
+ recipeId: recipe.id,
+ state: lazy.AddonRollouts.STATE_ACTIVE,
+ slug,
+ enrollmentId:
+ enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ ...details,
+ });
+ }
+ };
+
+ const undoNormandyChanges = async () => {
+ if (existingRollout) {
+ await lazy.AddonRollouts.update(existingRollout);
+ } else {
+ await lazy.AddonRollouts.delete(recipe.id);
+ }
+ };
+
+ const [installedId, installedVersion] =
+ await lazy.NormandyAddonManager.downloadAndInstall({
+ createError,
+ extensionDetails,
+ applyNormandyChanges,
+ undoNormandyChanges,
+ onInstallStarted,
+ reportError: error => this.reportError(error, `${eventName}Failed`),
+ });
+
+ if (existingRollout) {
+ this.log.debug(`Updated addon rollout ${slug}`);
+ } else {
+ this.log.debug(`Enrolled in addon rollout ${slug}`);
+ lazy.TelemetryEnvironment.setExperimentActive(
+ slug,
+ lazy.AddonRollouts.STATE_ACTIVE,
+ {
+ type: "normandy-addonrollout",
+ }
+ );
+ }
+
+ // All done, report success to Telemetry
+ lazy.TelemetryEvents.sendEvent(eventName, "addon_rollout", slug, {
+ addonId: installedId,
+ addonVersion: installedVersion,
+ enrollmentId:
+ enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ }
+
+ reportError(error, eventName) {
+ if (error instanceof AddonRolloutError) {
+ // One of our known errors. Report it nicely to telemetry
+ lazy.TelemetryEvents.sendEvent(
+ eventName,
+ "addon_rollout",
+ error.slug,
+ error.extra
+ );
+ } else {
+ /*
+ * Some unknown error. Add some helpful details, and report it to
+ * telemetry. The actual stack trace and error message could possibly
+ * contain PII, so we don't include them here. Instead include some
+ * information that should still be helpful, and is less likely to be
+ * unsafe.
+ */
+ const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
+ lazy.TelemetryEvents.sendEvent(eventName, "addon_rollout", error.slug, {
+ reason: safeErrorMessage.slice(0, 80), // max length is 80 chars
+ });
+ }
+ }
+}
diff --git a/toolkit/components/normandy/actions/BaseAction.sys.mjs b/toolkit/components/normandy/actions/BaseAction.sys.mjs
new file mode 100644
index 0000000000..f71d5c71dd
--- /dev/null
+++ b/toolkit/components/normandy/actions/BaseAction.sys.mjs
@@ -0,0 +1,338 @@
+/* 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 { Uptake } from "resource://normandy/lib/Uptake.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ JsonSchemaValidator:
+ "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs",
+ LogManager: "resource://normandy/lib/LogManager.sys.mjs",
+});
+
+/**
+ * Base class for local actions.
+ *
+ * This should be subclassed. Subclasses must implement _run() for
+ * per-recipe behavior, and may implement _preExecution and _finalize
+ * for actions to be taken once before and after recipes are run.
+ *
+ * Other methods should be overridden with care, to maintain the life
+ * cycle events and error reporting implemented by this class.
+ */
+export class BaseAction {
+ constructor() {
+ this.state = BaseAction.STATE_PREPARING;
+ this.log = lazy.LogManager.getLogger(`action.${this.name}`);
+ this.lastError = null;
+ }
+
+ /**
+ * Be sure to run the _preExecution() hook once during its
+ * lifecycle.
+ *
+ * This is not intended for overriding by subclasses.
+ */
+ _ensurePreExecution() {
+ if (this.state !== BaseAction.STATE_PREPARING) {
+ return;
+ }
+
+ try {
+ this._preExecution();
+ // if _preExecution changed the state, don't overwrite it
+ if (this.state === BaseAction.STATE_PREPARING) {
+ this.state = BaseAction.STATE_READY;
+ }
+ } catch (err) {
+ // Sometimes err.message is editable. If it is, add helpful details.
+ // Otherwise log the helpful details and move on.
+ try {
+ err.message = `Could not initialize action ${this.name}: ${err.message}`;
+ } catch (_e) {
+ this.log.error(
+ `Could not initialize action ${this.name}, error follows.`
+ );
+ }
+ this.fail(err);
+ }
+ }
+
+ get schema() {
+ return {
+ type: "object",
+ properties: {},
+ };
+ }
+
+ /**
+ * Disable the action for a non-error reason, such as the user opting out of
+ * this type of action.
+ */
+ disable() {
+ this.state = BaseAction.STATE_DISABLED;
+ }
+
+ fail(err) {
+ switch (this.state) {
+ case BaseAction.STATE_PREPARING: {
+ Uptake.reportAction(this.name, Uptake.ACTION_PRE_EXECUTION_ERROR);
+ break;
+ }
+ default: {
+ console.error(new Error("BaseAction.fail() called at unexpected time"));
+ }
+ }
+ this.state = BaseAction.STATE_FAILED;
+ this.lastError = err;
+ console.error(err);
+ }
+
+ // Gets the name of the action. Does not necessarily match the
+ // server slug for the action.
+ get name() {
+ return this.constructor.name;
+ }
+
+ /**
+ * Action specific pre-execution behavior should be implemented
+ * here. It will be called once per execution session.
+ */
+ _preExecution() {
+ // Does nothing, may be overridden
+ }
+
+ validateArguments(args, schema = this.schema) {
+ let { valid, parsedValue: validated } = lazy.JsonSchemaValidator.validate(
+ args,
+ schema,
+ {
+ allowExtraProperties: true,
+ }
+ );
+ if (!valid) {
+ throw new Error(
+ `Arguments do not match schema. arguments:\n${JSON.stringify(args)}\n` +
+ `schema:\n${JSON.stringify(schema)}`
+ );
+ }
+ return validated;
+ }
+
+ /**
+ * Execute the per-recipe behavior of this action for a given
+ * recipe. Reports Uptake telemetry for the execution of the recipe.
+ *
+ * @param {Recipe} recipe
+ * @param {BaseAction.suitability} suitability
+ * @throws If this action has already been finalized.
+ */
+ async processRecipe(recipe, suitability) {
+ if (!BaseAction.suitabilitySet.has(suitability)) {
+ throw new Error(`Unknown recipe status ${suitability}`);
+ }
+
+ this._ensurePreExecution();
+
+ if (this.state === BaseAction.STATE_FINALIZED) {
+ throw new Error("Action has already been finalized");
+ }
+
+ if (this.state !== BaseAction.STATE_READY) {
+ Uptake.reportRecipe(recipe, Uptake.RECIPE_ACTION_DISABLED);
+ this.log.warn(
+ `Skipping recipe ${recipe.name} because ${this.name} was disabled during preExecution.`
+ );
+ return;
+ }
+
+ let uptakeResult = BaseAction.suitabilityToUptakeStatus[suitability];
+ if (!uptakeResult) {
+ throw new Error(
+ `Coding error, no uptake status for suitability ${suitability}`
+ );
+ }
+
+ // If capabilties don't match, we can't even be sure that the arguments
+ // should be valid. In that case don't try to validate them.
+ if (suitability !== BaseAction.suitability.CAPABILITIES_MISMATCH) {
+ try {
+ recipe.arguments = this.validateArguments(recipe.arguments);
+ } catch (error) {
+ console.error(error);
+ uptakeResult = Uptake.RECIPE_EXECUTION_ERROR;
+ suitability = BaseAction.suitability.ARGUMENTS_INVALID;
+ }
+ }
+
+ try {
+ await this._processRecipe(recipe, suitability);
+ } catch (err) {
+ console.error(err);
+ uptakeResult = Uptake.RECIPE_EXECUTION_ERROR;
+ }
+ Uptake.reportRecipe(recipe, uptakeResult);
+ }
+
+ /**
+ * Action specific recipe behavior may be implemented here. It will be
+ * executed once for each recipe that applies to this client.
+ * The recipe will be passed as a parameter.
+ *
+ * @param {Recipe} recipe
+ */
+ async _run(recipe) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Action specific recipe behavior should be implemented here. It will be
+ * executed once for every recipe currently published. The suitability of the
+ * recipe will be passed, it will be one of the constants from
+ * `BaseAction.suitability`.
+ *
+ * By default, this calls `_run()` for recipes with `status == FILTER_MATCH`,
+ * and does nothing for all other recipes. It is invalid for an action to
+ * override both `_run` and `_processRecipe`.
+ *
+ * @param {Recipe} recipe
+ * @param {RecipeSuitability} suitability
+ */
+ async _processRecipe(recipe, suitability) {
+ if (!suitability) {
+ throw new Error("Suitability is undefined:", suitability);
+ }
+ if (suitability == BaseAction.suitability.FILTER_MATCH) {
+ await this._run(recipe);
+ }
+ }
+
+ /**
+ * Finish an execution session. After this method is called, no
+ * other methods may be called on this method, and all relevant
+ * recipes will be assumed to have been seen.
+ */
+ async finalize(options) {
+ // It's possible that no recipes used this action, so processRecipe()
+ // was never called. In that case, we should ensure that we call
+ // _preExecute() here.
+ this._ensurePreExecution();
+
+ let status;
+ switch (this.state) {
+ case BaseAction.STATE_FINALIZED: {
+ throw new Error("Action has already been finalized");
+ }
+ case BaseAction.STATE_READY: {
+ try {
+ await this._finalize(options);
+ status = Uptake.ACTION_SUCCESS;
+ } catch (err) {
+ status = Uptake.ACTION_POST_EXECUTION_ERROR;
+ // Sometimes Error.message can be updated in place. This gives better messages when debugging errors.
+ try {
+ err.message = `Could not run postExecution hook for ${this.name}: ${err.message}`;
+ } catch (err) {
+ // Sometimes Error.message cannot be updated. Log a warning, and move on.
+ this.log.debug(`Could not run postExecution hook for ${this.name}`);
+ }
+
+ this.lastError = err;
+ console.error(err);
+ }
+ break;
+ }
+ case BaseAction.STATE_DISABLED: {
+ this.log.debug(
+ `Skipping post-execution hook for ${this.name} because it is disabled.`
+ );
+ status = Uptake.ACTION_SUCCESS;
+ break;
+ }
+ case BaseAction.STATE_FAILED: {
+ this.log.debug(
+ `Skipping post-execution hook for ${this.name} because it failed during pre-execution.`
+ );
+ // Don't report a status. A status should have already been reported by this.fail().
+ break;
+ }
+ default: {
+ throw new Error(`Unexpected state during finalize: ${this.state}`);
+ }
+ }
+
+ this.state = BaseAction.STATE_FINALIZED;
+ if (status) {
+ Uptake.reportAction(this.name, status);
+ }
+ }
+
+ /**
+ * Action specific post-execution behavior should be implemented
+ * here. It will be executed once after all recipes have been
+ * processed.
+ */
+ async _finalize(_options = {}) {
+ // Does nothing, may be overridden
+ }
+}
+
+BaseAction.STATE_PREPARING = "ACTION_PREPARING";
+BaseAction.STATE_READY = "ACTION_READY";
+BaseAction.STATE_DISABLED = "ACTION_DISABLED";
+BaseAction.STATE_FAILED = "ACTION_FAILED";
+BaseAction.STATE_FINALIZED = "ACTION_FINALIZED";
+
+// Make sure to update the docs in ../docs/suitabilities.rst when changing this.
+BaseAction.suitability = {
+ /**
+ * The recipe's signature is not valid. If any action is taken this recipe
+ * should be treated with extreme suspicion.
+ */
+ SIGNATURE_ERROR: "RECIPE_SUITABILITY_SIGNATURE_ERROR",
+
+ /**
+ * The recipe requires capabilities that this recipe runner does not have.
+ * Use caution when interacting with this recipe, as it may not match the
+ * expected schema.
+ */
+ CAPABILITIES_MISMATCH: "RECIPE_SUITABILITY_CAPABILITIES_MISMATCH",
+
+ /**
+ * The recipe is suitable to execute in this client.
+ */
+ FILTER_MATCH: "RECIPE_SUITABILITY_FILTER_MATCH",
+
+ /**
+ * This client does not match the recipe's filter, but it is otherwise a
+ * suitable recipe.
+ */
+ FILTER_MISMATCH: "RECIPE_SUITABILITY_FILTER_MISMATCH",
+
+ /**
+ * There was an error while evaluating the filter. It is unknown if this
+ * client matches this filter. This may be temporary, due to network errors,
+ * or permanent due to syntax errors.
+ */
+ FILTER_ERROR: "RECIPE_SUITABILITY_FILTER_ERROR",
+
+ /**
+ * The arguments of the recipe do not match the expected schema for the named
+ * action.
+ */
+ ARGUMENTS_INVALID: "RECIPE_SUITABILITY_ARGUMENTS_INVALID",
+};
+
+BaseAction.suitabilitySet = new Set(Object.values(BaseAction.suitability));
+
+BaseAction.suitabilityToUptakeStatus = {
+ [BaseAction.suitability.SIGNATURE_ERROR]: Uptake.RECIPE_INVALID_SIGNATURE,
+ [BaseAction.suitability.CAPABILITIES_MISMATCH]:
+ Uptake.RECIPE_INCOMPATIBLE_CAPABILITIES,
+ [BaseAction.suitability.FILTER_MATCH]: Uptake.RECIPE_SUCCESS,
+ [BaseAction.suitability.FILTER_MISMATCH]: Uptake.RECIPE_DIDNT_MATCH_FILTER,
+ [BaseAction.suitability.FILTER_ERROR]: Uptake.RECIPE_FILTER_BROKEN,
+ [BaseAction.suitability.ARGUMENTS_INVALID]: Uptake.RECIPE_ARGUMENTS_INVALID,
+};
diff --git a/toolkit/components/normandy/actions/BaseStudyAction.sys.mjs b/toolkit/components/normandy/actions/BaseStudyAction.sys.mjs
new file mode 100644
index 0000000000..cca66e65fa
--- /dev/null
+++ b/toolkit/components/normandy/actions/BaseStudyAction.sys.mjs
@@ -0,0 +1,37 @@
+/* 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 { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs";
+
+const OPT_OUT_STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled";
+
+/**
+ * Base class for local study actions.
+ *
+ * This should be subclassed. Subclasses must implement _run() for
+ * per-recipe behavior, and may implement _finalize for actions to be
+ * taken once after recipes are run.
+ *
+ * For actions that need to be taken once before recipes are run
+ * _preExecution may be overriden but the overridden method must
+ * call the parent method to ensure the appropriate checks occur.
+ *
+ * Other methods should be overridden with care, to maintain the life
+ * cycle events and error reporting implemented by this class.
+ */
+export class BaseStudyAction extends BaseAction {
+ _preExecution() {
+ if (!Services.policies.isAllowed("Shield")) {
+ this.log.debug("Disabling Shield because it's blocked by policy.");
+ this.disable();
+ }
+
+ if (!Services.prefs.getBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, true)) {
+ this.log.debug(
+ "User has opted-out of opt-out experiments, disabling action."
+ );
+ this.disable();
+ }
+ }
+}
diff --git a/toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs b/toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs
new file mode 100644
index 0000000000..b341635668
--- /dev/null
+++ b/toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs
@@ -0,0 +1,789 @@
+/* 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/. */
+
+/*
+ * This action handles the life cycle of add-on based studies. Currently that
+ * means installing the add-on the first time the recipe applies to this
+ * client, updating the add-on to new versions if the recipe changes, and
+ * uninstalling them when the recipe no longer applies.
+ */
+
+import { BaseStudyAction } from "resource://normandy/actions/BaseStudyAction.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs",
+ BaseAction: "resource://normandy/actions/BaseAction.sys.mjs",
+ ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
+ NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs",
+ NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs",
+ PromiseUtils: "resource://gre/modules/PromiseUtils.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",
+});
+
+class AddonStudyEnrollError extends Error {
+ /**
+ * @param {string} studyName
+ * @param {object} extra Extra details to include when reporting the error to telemetry.
+ * @param {string} extra.reason The specific reason for the failure.
+ */
+ constructor(studyName, extra) {
+ let message;
+ let { reason } = extra;
+ switch (reason) {
+ case "conflicting-addon-id": {
+ message = "an add-on with this ID is already installed";
+ break;
+ }
+ case "download-failure": {
+ message = "the add-on failed to download";
+ break;
+ }
+ case "metadata-mismatch": {
+ message = "the server metadata does not match the downloaded add-on";
+ break;
+ }
+ case "install-failure": {
+ message = "the add-on failed to install";
+ break;
+ }
+ default: {
+ throw new Error(`Unexpected AddonStudyEnrollError reason: ${reason}`);
+ }
+ }
+ super(`Cannot install study add-on for ${studyName}: ${message}.`);
+ this.studyName = studyName;
+ this.extra = extra;
+ }
+}
+
+class AddonStudyUpdateError extends Error {
+ /**
+ * @param {string} studyName
+ * @param {object} extra Extra details to include when reporting the error to telemetry.
+ * @param {string} extra.reason The specific reason for the failure.
+ */
+ constructor(studyName, extra) {
+ let message;
+ let { reason } = extra;
+ switch (reason) {
+ case "addon-id-mismatch": {
+ message = "new add-on ID does not match old add-on ID";
+ break;
+ }
+ case "addon-does-not-exist": {
+ message = "an add-on with this ID does not exist";
+ break;
+ }
+ case "no-downgrade": {
+ message = "the add-on was an older version than is installed";
+ break;
+ }
+ case "metadata-mismatch": {
+ message = "the server metadata does not match the downloaded add-on";
+ break;
+ }
+ case "download-failure": {
+ message = "the add-on failed to download";
+ break;
+ }
+ case "install-failure": {
+ message = "the add-on failed to install";
+ break;
+ }
+ default: {
+ throw new Error(`Unexpected AddonStudyUpdateError reason: ${reason}`);
+ }
+ }
+ super(`Cannot update study add-on for ${studyName}: ${message}.`);
+ this.studyName = studyName;
+ this.extra = extra;
+ }
+}
+
+export class BranchedAddonStudyAction extends BaseStudyAction {
+ get schema() {
+ return lazy.ActionSchemas["branched-addon-study"];
+ }
+
+ constructor() {
+ super();
+ this.seenRecipeIds = new Set();
+ }
+
+ async _run(recipe) {
+ throw new Error("_run should not be called anymore");
+ }
+
+ /**
+ * This hook is executed once for every recipe currently enabled on the
+ * server. It is responsible for:
+ *
+ * - Enrolling studies the first time they have a FILTER_MATCH suitability.
+ * - Updating studies that have changed and still have a FILTER_MATCH suitability.
+ * - Marking studies as having been seen in this session.
+ * - Unenrolling studies when they have permanent errors.
+ * - Unenrolling studies when temporary errors persist for too long.
+ *
+ * If the action fails to perform any of these tasks, it should throw to
+ * properly report its status.
+ */
+ async _processRecipe(recipe, suitability) {
+ this.seenRecipeIds.add(recipe.id);
+ const study = await lazy.AddonStudies.get(recipe.id);
+
+ switch (suitability) {
+ case lazy.BaseAction.suitability.FILTER_MATCH: {
+ if (!study) {
+ await this.enroll(recipe);
+ } else if (study.active) {
+ await this.update(recipe, study);
+ }
+ break;
+ }
+
+ case lazy.BaseAction.suitability.SIGNATURE_ERROR: {
+ await this._considerTemporaryError({
+ study,
+ reason: "signature-error",
+ });
+ break;
+ }
+
+ case lazy.BaseAction.suitability.FILTER_ERROR: {
+ await this._considerTemporaryError({
+ study,
+ reason: "filter-error",
+ });
+ break;
+ }
+
+ case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: {
+ if (study?.active) {
+ await this.unenroll(recipe.id, "capability-mismatch");
+ }
+ break;
+ }
+
+ case lazy.BaseAction.suitability.FILTER_MISMATCH: {
+ if (study?.active) {
+ await this.unenroll(recipe.id, "filter-mismatch");
+ }
+ break;
+ }
+
+ case lazy.BaseAction.suitability.ARGUMENTS_INVALID: {
+ if (study?.active) {
+ await this.unenroll(recipe.id, "arguments-invalid");
+ }
+ break;
+ }
+
+ default: {
+ throw new Error(`Unknown recipe suitability "${suitability}".`);
+ }
+ }
+ }
+
+ /**
+ * This hook is executed once after all recipes that apply to this client
+ * have been processed. It is responsible for unenrolling the client from any
+ * studies that no longer apply, based on this.seenRecipeIds.
+ */
+ async _finalize({ noRecipes } = {}) {
+ const activeStudies = await lazy.AddonStudies.getAllActive({
+ branched: lazy.AddonStudies.FILTER_BRANCHED_ONLY,
+ });
+
+ if (noRecipes) {
+ if (this.seenRecipeIds.size) {
+ throw new BranchedAddonStudyAction.BadNoRecipesArg();
+ }
+ for (const study of activeStudies) {
+ await this._considerTemporaryError({ study, reason: "no-recipes" });
+ }
+ } else {
+ for (const study of activeStudies) {
+ if (!this.seenRecipeIds.has(study.recipeId)) {
+ this.log.debug(
+ `Stopping branched add-on study for recipe ${study.recipeId}`
+ );
+ try {
+ await this.unenroll(study.recipeId, "recipe-not-seen");
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Download and install the addon for a given recipe
+ *
+ * @param recipe Object describing the study to enroll in.
+ * @param extensionDetails Object describing the addon to be installed.
+ * @param onInstallStarted A function that returns a callback for the install listener.
+ * @param onComplete A callback function that is run on completion of the download.
+ * @param onFailedInstall A callback function that is run if the installation fails.
+ * @param errorClass The class of error to be thrown when exceptions occur.
+ * @param reportError A function that reports errors to Telemetry.
+ * @param [errorExtra] Optional, an object that will be merged into the
+ * `extra` field of the error generated, if any.
+ */
+ async downloadAndInstall({
+ recipe,
+ extensionDetails,
+ branchSlug,
+ onInstallStarted,
+ onComplete,
+ onFailedInstall,
+ errorClass,
+ reportError,
+ errorExtra = {},
+ }) {
+ const { slug } = recipe.arguments;
+ const { hash, hash_algorithm } = extensionDetails;
+
+ const downloadDeferred = lazy.PromiseUtils.defer();
+ const installDeferred = lazy.PromiseUtils.defer();
+
+ const install = await lazy.AddonManager.getInstallForURL(
+ extensionDetails.xpi,
+ {
+ hash: `${hash_algorithm}:${hash}`,
+ telemetryInfo: { source: "internal" },
+ }
+ );
+
+ const listener = {
+ onDownloadFailed() {
+ downloadDeferred.reject(
+ new errorClass(slug, {
+ reason: "download-failure",
+ branch: branchSlug,
+ detail: lazy.AddonManager.errorToString(install.error),
+ ...errorExtra,
+ })
+ );
+ },
+
+ onDownloadEnded() {
+ downloadDeferred.resolve();
+ return false; // temporarily pause installation for Normandy bookkeeping
+ },
+
+ onInstallFailed() {
+ installDeferred.reject(
+ new errorClass(slug, {
+ reason: "install-failure",
+ branch: branchSlug,
+ detail: lazy.AddonManager.errorToString(install.error),
+ })
+ );
+ },
+
+ onInstallEnded() {
+ installDeferred.resolve();
+ },
+ };
+
+ listener.onInstallStarted = onInstallStarted(installDeferred);
+
+ install.addListener(listener);
+
+ // Download the add-on
+ try {
+ install.install();
+ await downloadDeferred.promise;
+ } catch (err) {
+ reportError(err);
+ install.removeListener(listener);
+ throw err;
+ }
+
+ await onComplete(install, listener);
+
+ // Finish paused installation
+ try {
+ install.install();
+ await installDeferred.promise;
+ } catch (err) {
+ reportError(err);
+ install.removeListener(listener);
+ await onFailedInstall();
+ throw err;
+ }
+
+ install.removeListener(listener);
+
+ return [install.addon.id, install.addon.version];
+ }
+
+ async chooseBranch({ slug, branches }) {
+ const ratios = branches.map(branch => branch.ratio);
+ const userId = lazy.ClientEnvironment.userId;
+
+ // 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 = `${userId}-${slug}-addon-branch`;
+
+ const index = await lazy.Sampling.ratioSample(input, ratios);
+ return branches[index];
+ }
+
+ /**
+ * Enroll in the study represented by the given recipe.
+ * @param recipe Object describing the study to enroll in.
+ * @param extensionDetails Object describing the addon to be installed.
+ */
+ async enroll(recipe) {
+ // This function first downloads the add-on to get its metadata. Then it
+ // uses that metadata to record a study in `AddonStudies`. Then, it finishes
+ // installing the add-on, and finally sends telemetry. If any of these steps
+ // fails, the previous ones are undone, as needed.
+ //
+ // This ordering is important because the only intermediate states we can be
+ // in are:
+ // 1. The add-on is only downloaded, in which case AddonManager will clean it up.
+ // 2. The study has been recorded, in which case we will unenroll on next
+ // start up. The start up code will assume that the add-on was uninstalled
+ // while the browser was shutdown.
+ // 3. After installation is complete, but before telemetry, in which case we
+ // lose an enroll event. This is acceptable.
+ //
+ // This way a shutdown, crash or unexpected error can't leave Normandy in a
+ // long term inconsistent state. The main thing avoided is having a study
+ // add-on installed but no record of it, which would leave it permanently
+ // installed.
+
+ if (recipe.arguments.isEnrollmentPaused) {
+ // Recipe does not need anything done
+ return;
+ }
+
+ const { slug, userFacingName, userFacingDescription } = recipe.arguments;
+ const branch = await this.chooseBranch({
+ slug: recipe.arguments.slug,
+ branches: recipe.arguments.branches,
+ });
+ this.log.debug(`Enrolling in branch ${branch.slug}`);
+
+ const enrollmentId = lazy.NormandyUtils.generateUuid();
+
+ if (branch.extensionApiId === null) {
+ const study = {
+ recipeId: recipe.id,
+ slug,
+ userFacingName,
+ userFacingDescription,
+ branch: branch.slug,
+ addonId: null,
+ addonVersion: null,
+ addonUrl: null,
+ extensionApiId: null,
+ extensionHash: null,
+ extensionHashAlgorithm: null,
+ active: true,
+ studyStartDate: new Date(),
+ studyEndDate: null,
+ enrollmentId,
+ temporaryErrorDeadline: null,
+ };
+
+ try {
+ await lazy.AddonStudies.add(study);
+ } catch (err) {
+ this.reportEnrollError(err);
+ throw err;
+ }
+
+ // All done, report success to Telemetry
+ lazy.TelemetryEvents.sendEvent("enroll", "addon_study", slug, {
+ addonId: lazy.AddonStudies.NO_ADDON_MARKER,
+ addonVersion: lazy.AddonStudies.NO_ADDON_MARKER,
+ branch: branch.slug,
+ enrollmentId:
+ enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ } else {
+ const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails(
+ branch.extensionApiId
+ );
+
+ const onInstallStarted = installDeferred => cbInstall => {
+ const versionMatches =
+ cbInstall.addon.version === extensionDetails.version;
+ const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
+
+ if (cbInstall.existingAddon) {
+ installDeferred.reject(
+ new AddonStudyEnrollError(slug, {
+ reason: "conflicting-addon-id",
+ branch: branch.slug,
+ })
+ );
+ return false; // cancel the installation, no upgrades allowed
+ } else if (!versionMatches || !idMatches) {
+ installDeferred.reject(
+ new AddonStudyEnrollError(slug, {
+ branch: branch.slug,
+ reason: "metadata-mismatch",
+ })
+ );
+ return false; // cancel the installation, server metadata does not match downloaded add-on
+ }
+ return true;
+ };
+
+ let study;
+ const onComplete = async (install, listener) => {
+ study = {
+ recipeId: recipe.id,
+ slug,
+ userFacingName,
+ userFacingDescription,
+ branch: branch.slug,
+ addonId: install.addon.id,
+ addonVersion: install.addon.version,
+ addonUrl: extensionDetails.xpi,
+ extensionApiId: branch.extensionApiId,
+ extensionHash: extensionDetails.hash,
+ extensionHashAlgorithm: extensionDetails.hash_algorithm,
+ active: true,
+ studyStartDate: new Date(),
+ studyEndDate: null,
+ enrollmentId,
+ temporaryErrorDeadline: null,
+ };
+
+ try {
+ await lazy.AddonStudies.add(study);
+ } catch (err) {
+ this.reportEnrollError(err);
+ install.removeListener(listener);
+ install.cancel();
+ throw err;
+ }
+ };
+
+ const onFailedInstall = async () => {
+ await lazy.AddonStudies.delete(recipe.id);
+ };
+
+ const [installedId, installedVersion] = await this.downloadAndInstall({
+ recipe,
+ branchSlug: branch.slug,
+ extensionDetails,
+ onInstallStarted,
+ onComplete,
+ onFailedInstall,
+ errorClass: AddonStudyEnrollError,
+ reportError: this.reportEnrollError,
+ });
+
+ // All done, report success to Telemetry
+ lazy.TelemetryEvents.sendEvent("enroll", "addon_study", slug, {
+ addonId: installedId,
+ addonVersion: installedVersion,
+ branch: branch.slug,
+ enrollmentId:
+ enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ }
+
+ lazy.TelemetryEnvironment.setExperimentActive(slug, branch.slug, {
+ type: "normandy-addonstudy",
+ enrollmentId:
+ enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ }
+
+ /**
+ * Update the study represented by the given recipe.
+ * @param recipe Object describing the study to be updated.
+ * @param extensionDetails Object describing the addon to be installed.
+ */
+ async update(recipe, study) {
+ const { slug } = recipe.arguments;
+
+ // Stay in the same branch, don't re-sample every time.
+ const branch = recipe.arguments.branches.find(
+ branch => branch.slug === study.branch
+ );
+
+ if (!branch) {
+ // Our branch has been removed. Unenroll.
+ await this.unenroll(recipe.id, "branch-removed");
+ return;
+ }
+
+ // Since we saw a non-error suitability, clear the temporary error deadline.
+ study.temporaryErrorDeadline = null;
+ await lazy.AddonStudies.update(study);
+
+ const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails(
+ branch.extensionApiId
+ );
+
+ let error;
+
+ if (study.addonId && study.addonId !== extensionDetails.extension_id) {
+ error = new AddonStudyUpdateError(slug, {
+ branch: branch.slug,
+ reason: "addon-id-mismatch",
+ enrollmentId:
+ study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ }
+
+ const versionCompare = Services.vc.compare(
+ study.addonVersion,
+ extensionDetails.version
+ );
+ if (versionCompare > 0) {
+ error = new AddonStudyUpdateError(slug, {
+ branch: branch.slug,
+ reason: "no-downgrade",
+ enrollmentId:
+ study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ } else if (versionCompare === 0) {
+ return; // Unchanged, do nothing
+ }
+
+ if (error) {
+ this.reportUpdateError(error);
+ throw error;
+ }
+
+ const onInstallStarted = installDeferred => cbInstall => {
+ const versionMatches =
+ cbInstall.addon.version === extensionDetails.version;
+ const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
+
+ if (!cbInstall.existingAddon) {
+ installDeferred.reject(
+ new AddonStudyUpdateError(slug, {
+ branch: branch.slug,
+ reason: "addon-does-not-exist",
+ enrollmentId:
+ study.enrollmentId ||
+ lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ })
+ );
+ return false; // cancel the installation, must upgrade an existing add-on
+ } else if (!versionMatches || !idMatches) {
+ installDeferred.reject(
+ new AddonStudyUpdateError(slug, {
+ branch: branch.slug,
+ reason: "metadata-mismatch",
+ enrollmentId:
+ study.enrollmentId ||
+ lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ })
+ );
+ return false; // cancel the installation, server metadata do not match downloaded add-on
+ }
+
+ return true;
+ };
+
+ const onComplete = async (install, listener) => {
+ try {
+ await lazy.AddonStudies.update({
+ ...study,
+ addonVersion: install.addon.version,
+ addonUrl: extensionDetails.xpi,
+ extensionHash: extensionDetails.hash,
+ extensionHashAlgorithm: extensionDetails.hash_algorithm,
+ extensionApiId: branch.extensionApiId,
+ });
+ } catch (err) {
+ this.reportUpdateError(err);
+ install.removeListener(listener);
+ install.cancel();
+ throw err;
+ }
+ };
+
+ const onFailedInstall = () => {
+ lazy.AddonStudies.update(study);
+ };
+
+ const [installedId, installedVersion] = await this.downloadAndInstall({
+ recipe,
+ extensionDetails,
+ branchSlug: branch.slug,
+ onInstallStarted,
+ onComplete,
+ onFailedInstall,
+ errorClass: AddonStudyUpdateError,
+ reportError: this.reportUpdateError,
+ errorExtra: {
+ enrollmentId:
+ study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ },
+ });
+
+ // All done, report success to Telemetry
+ lazy.TelemetryEvents.sendEvent("update", "addon_study", slug, {
+ addonId: installedId,
+ addonVersion: installedVersion,
+ branch: branch.slug,
+ enrollmentId:
+ study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ }
+
+ reportEnrollError(error) {
+ if (error instanceof AddonStudyEnrollError) {
+ // One of our known errors. Report it nicely to telemetry
+ lazy.TelemetryEvents.sendEvent(
+ "enrollFailed",
+ "addon_study",
+ error.studyName,
+ error.extra
+ );
+ } else {
+ /*
+ * Some unknown error. Add some helpful details, and report it to
+ * telemetry. The actual stack trace and error message could possibly
+ * contain PII, so we don't include them here. Instead include some
+ * information that should still be helpful, and is less likely to be
+ * unsafe.
+ */
+ const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
+ lazy.TelemetryEvents.sendEvent(
+ "enrollFailed",
+ "addon_study",
+ error.studyName,
+ {
+ reason: safeErrorMessage.slice(0, 80), // max length is 80 chars
+ }
+ );
+ }
+ }
+
+ reportUpdateError(error) {
+ if (error instanceof AddonStudyUpdateError) {
+ // One of our known errors. Report it nicely to telemetry
+ lazy.TelemetryEvents.sendEvent(
+ "updateFailed",
+ "addon_study",
+ error.studyName,
+ error.extra
+ );
+ } else {
+ /*
+ * Some unknown error. Add some helpful details, and report it to
+ * telemetry. The actual stack trace and error message could possibly
+ * contain PII, so we don't include them here. Instead include some
+ * information that should still be helpful, and is less likely to be
+ * unsafe.
+ */
+ const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
+ lazy.TelemetryEvents.sendEvent(
+ "updateFailed",
+ "addon_study",
+ error.studyName,
+ {
+ reason: safeErrorMessage.slice(0, 80), // max length is 80 chars
+ }
+ );
+ }
+ }
+
+ /**
+ * Unenrolls the client from the study with a given recipe ID.
+ * @param recipeId The recipe ID of an enrolled study
+ * @param reason The reason for this unenrollment, to be used in Telemetry
+ * @throws If the specified study does not exist, or if it is already inactive.
+ */
+ async unenroll(recipeId, reason = "unknown") {
+ const study = await lazy.AddonStudies.get(recipeId);
+ if (!study) {
+ throw new Error(`No study found for recipe ${recipeId}.`);
+ }
+ if (!study.active) {
+ throw new Error(
+ `Cannot stop study for recipe ${recipeId}; it is already inactive.`
+ );
+ }
+
+ await lazy.AddonStudies.markAsEnded(study, reason);
+
+ // Study branches may indicate that no add-on should be installed, as a
+ // form of control branch. In that case, `study.addonId` will be null (as
+ // will the other add-on related fields). Only try to uninstall the add-on
+ // if we expect one should be installed.
+ if (study.addonId) {
+ const addon = await lazy.AddonManager.getAddonByID(study.addonId);
+ if (addon) {
+ await addon.uninstall();
+ } else {
+ this.log.warn(
+ `Could not uninstall addon ${study.addonId} for recipe ${study.recipeId}: it is not installed.`
+ );
+ }
+ }
+ }
+
+ /**
+ * Given that a temporary error has occured for a study, check if it
+ * should be temporarily ignored, or if the deadline has passed. If the
+ * deadline is passed, the study will be ended. If this is the first
+ * temporary error, a deadline will be generated. Otherwise, nothing will
+ * happen.
+ *
+ * If a temporary deadline exists but cannot be parsed, a new one will be
+ * made.
+ *
+ * The deadline is 7 days from the first time that recipe failed, as
+ * reckoned by the client's clock.
+ *
+ * @param {Object} args
+ * @param {Study} args.study The enrolled study to potentially unenroll.
+ * @param {String} args.reason If the study should end, the reason it is ending.
+ */
+ async _considerTemporaryError({ study, reason }) {
+ if (!study?.active) {
+ return;
+ }
+
+ let now = Date.now(); // milliseconds-since-epoch
+ let day = 24 * 60 * 60 * 1000;
+ let newDeadline = new Date(now + 7 * day);
+
+ if (study.temporaryErrorDeadline) {
+ // if deadline is an invalid date, set it to one week from now.
+ if (isNaN(study.temporaryErrorDeadline)) {
+ study.temporaryErrorDeadline = newDeadline;
+ await lazy.AddonStudies.update(study);
+ return;
+ }
+
+ if (now > study.temporaryErrorDeadline) {
+ await this.unenroll(study.recipeId, reason);
+ }
+ } else {
+ // there is no deadline, so set one
+ study.temporaryErrorDeadline = newDeadline;
+ await lazy.AddonStudies.update(study);
+ }
+ }
+}
+
+BranchedAddonStudyAction.BadNoRecipesArg = class extends Error {
+ message = "noRecipes is true, but some recipes observed";
+};
diff --git a/toolkit/components/normandy/actions/ConsoleLogAction.sys.mjs b/toolkit/components/normandy/actions/ConsoleLogAction.sys.mjs
new file mode 100644
index 0000000000..a0b9f859fa
--- /dev/null
+++ b/toolkit/components/normandy/actions/ConsoleLogAction.sys.mjs
@@ -0,0 +1,20 @@
+/* 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 { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
+});
+
+export class ConsoleLogAction extends BaseAction {
+ get schema() {
+ return lazy.ActionSchemas["console-log"];
+ }
+
+ async _run(recipe) {
+ this.log.info(recipe.arguments.message);
+ }
+}
diff --git a/toolkit/components/normandy/actions/MessagingExperimentAction.sys.mjs b/toolkit/components/normandy/actions/MessagingExperimentAction.sys.mjs
new file mode 100644
index 0000000000..393d2cc3f2
--- /dev/null
+++ b/toolkit/components/normandy/actions/MessagingExperimentAction.sys.mjs
@@ -0,0 +1,34 @@
+/* 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 { BaseStudyAction } from "resource://normandy/actions/BaseStudyAction.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
+ ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs",
+});
+
+const RECIPE_SOURCE = "normandy";
+
+export class MessagingExperimentAction extends BaseStudyAction {
+ constructor() {
+ super();
+ this.manager = lazy.ExperimentManager;
+ }
+ get schema() {
+ return lazy.ActionSchemas["messaging-experiment"];
+ }
+
+ async _run(recipe) {
+ if (recipe.arguments) {
+ await this.manager.onRecipe(recipe.arguments, RECIPE_SOURCE);
+ }
+ }
+
+ async _finalize() {
+ this.manager.onFinalize(RECIPE_SOURCE);
+ }
+}
diff --git a/toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs b/toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs
new file mode 100644
index 0000000000..310d1b08fd
--- /dev/null
+++ b/toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs
@@ -0,0 +1,278 @@
+/* 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 { BaseStudyAction } from "resource://normandy/actions/BaseStudyAction.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
+ BaseAction: "resource://normandy/actions/BaseAction.sys.mjs",
+ ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
+ PreferenceExperiments:
+ "resource://normandy/lib/PreferenceExperiments.sys.mjs",
+ Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs",
+});
+
+/**
+ * Enrolls a user in a preference experiment, in which we assign the
+ * user to an experiment branch and modify a preference temporarily to
+ * measure how it affects Firefox via Telemetry.
+ */
+export class PreferenceExperimentAction extends BaseStudyAction {
+ get schema() {
+ return lazy.ActionSchemas["multi-preference-experiment"];
+ }
+
+ constructor() {
+ super();
+ this.seenExperimentSlugs = new Set();
+ }
+
+ async _processRecipe(recipe, suitability) {
+ const {
+ branches,
+ isHighPopulation,
+ isEnrollmentPaused,
+ slug,
+ userFacingName,
+ userFacingDescription,
+ } = recipe.arguments || {};
+
+ let experiment;
+ // Slug might not exist, because if suitability is ARGUMENTS_INVALID, the
+ // arguments is not guaranteed to match the schema.
+ if (slug) {
+ this.seenExperimentSlugs.add(slug);
+
+ try {
+ experiment = await lazy.PreferenceExperiments.get(slug);
+ } catch (err) {
+ // This is probably that the experiment doesn't exist. If that's not the
+ // case, re-throw the error.
+ if (!(err instanceof lazy.PreferenceExperiments.NotFoundError)) {
+ throw err;
+ }
+ }
+ }
+
+ switch (suitability) {
+ case lazy.BaseAction.suitability.SIGNATURE_ERROR: {
+ this._considerTemporaryError({ experiment, reason: "signature-error" });
+ break;
+ }
+
+ case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: {
+ if (experiment && !experiment.expired) {
+ await lazy.PreferenceExperiments.stop(slug, {
+ resetValue: true,
+ reason: "capability-mismatch",
+ caller:
+ "PreferenceExperimentAction._processRecipe::capabilities_mismatch",
+ });
+ }
+ break;
+ }
+
+ case lazy.BaseAction.suitability.FILTER_MATCH: {
+ // If we're not in the experiment, try to enroll
+ if (!experiment) {
+ // Check all preferences that could be used by this experiment.
+ // If there's already an active experiment that has set that preference, abort.
+ const activeExperiments =
+ await lazy.PreferenceExperiments.getAllActive();
+ for (const branch of branches) {
+ const conflictingPrefs = Object.keys(branch.preferences).filter(
+ preferenceName => {
+ return activeExperiments.some(exp =>
+ exp.preferences.hasOwnProperty(preferenceName)
+ );
+ }
+ );
+ if (conflictingPrefs.length) {
+ throw new Error(
+ `Experiment ${slug} ignored; another active experiment is already using the
+ ${conflictingPrefs[0]} preference.`
+ );
+ }
+ }
+
+ // Determine if enrollment is currently paused for this experiment.
+ if (isEnrollmentPaused) {
+ this.log.debug(`Enrollment is paused for experiment "${slug}"`);
+ return;
+ }
+
+ // Otherwise, enroll!
+ const branch = await this.chooseBranch(slug, branches);
+ const experimentType = isHighPopulation ? "exp-highpop" : "exp";
+ await lazy.PreferenceExperiments.start({
+ slug,
+ actionName: this.name,
+ branch: branch.slug,
+ preferences: branch.preferences,
+ experimentType,
+ userFacingName,
+ userFacingDescription,
+ });
+ } else if (experiment.expired) {
+ this.log.debug(`Experiment ${slug} has expired, aborting.`);
+ } else {
+ experiment.temporaryErrorDeadline = null;
+ await lazy.PreferenceExperiments.update(experiment);
+ await lazy.PreferenceExperiments.markLastSeen(slug);
+ }
+ break;
+ }
+
+ case lazy.BaseAction.suitability.FILTER_MISMATCH: {
+ if (experiment && !experiment.expired) {
+ await lazy.PreferenceExperiments.stop(slug, {
+ resetValue: true,
+ reason: "filter-mismatch",
+ caller:
+ "PreferenceExperimentAction._processRecipe::filter_mismatch",
+ });
+ }
+ break;
+ }
+
+ case lazy.BaseAction.suitability.FILTER_ERROR: {
+ this._considerTemporaryError({ experiment, reason: "filter-error" });
+ break;
+ }
+
+ case lazy.BaseAction.suitability.ARGUMENTS_INVALID: {
+ if (experiment && !experiment.expired) {
+ await lazy.PreferenceExperiments.stop(slug, {
+ resetValue: true,
+ reason: "arguments-invalid",
+ caller:
+ "PreferenceExperimentAction._processRecipe::arguments_invalid",
+ });
+ }
+ break;
+ }
+
+ default: {
+ throw new Error(`Unknown recipe suitability "${suitability}".`);
+ }
+ }
+ }
+
+ async _run(recipe) {
+ throw new Error("_run shouldn't be called anymore");
+ }
+
+ async chooseBranch(slug, branches) {
+ const ratios = branches.map(branch => branch.ratio);
+ const userId = lazy.ClientEnvironment.userId;
+
+ // 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 = `${userId}-${slug}-branch`;
+
+ const index = await lazy.Sampling.ratioSample(input, ratios);
+ return branches[index];
+ }
+
+ /**
+ * End any experiments which we didn't see during this session.
+ * This is the "normal" way experiments end, as they are disabled on
+ * the server and so we stop seeing them. This can also happen if
+ * the user doesn't match the filter any more.
+ */
+ async _finalize({ noRecipes } = {}) {
+ const activeExperiments = await lazy.PreferenceExperiments.getAllActive();
+
+ if (noRecipes && this.seenExperimentSlugs.size) {
+ throw new PreferenceExperimentAction.BadNoRecipesArg();
+ }
+
+ return Promise.all(
+ activeExperiments.map(experiment => {
+ if (this.name != experiment.actionName) {
+ // Another action is responsible for cleaning this one
+ // up. Leave it alone.
+ return null;
+ }
+
+ if (noRecipes) {
+ return this._considerTemporaryError({
+ experiment,
+ reason: "no-recipes",
+ });
+ }
+
+ if (this.seenExperimentSlugs.has(experiment.slug)) {
+ return null;
+ }
+
+ return lazy.PreferenceExperiments.stop(experiment.slug, {
+ resetValue: true,
+ reason: "recipe-not-seen",
+ caller: "PreferenceExperimentAction._finalize",
+ }).catch(e => {
+ this.log.warn(`Stopping experiment ${experiment.slug} failed: ${e}`);
+ });
+ })
+ );
+ }
+
+ /**
+ * Given that a temporary error has occurred for an experiment, check if it
+ * should be temporarily ignored, or if the deadline has passed. If the
+ * deadline is passed, the experiment will be ended. If this is the first
+ * temporary error, a deadline will be generated. Otherwise, nothing will
+ * happen.
+ *
+ * If a temporary deadline exists but cannot be parsed, a new one will be
+ * made.
+ *
+ * The deadline is 7 days from the first time that recipe failed, as
+ * reckoned by the client's clock.
+ *
+ * @param {Object} args
+ * @param {Experiment} args.experiment The enrolled experiment to potentially unenroll.
+ * @param {String} args.reason If the recipe should end, the reason it is ending.
+ */
+ async _considerTemporaryError({ experiment, reason }) {
+ if (!experiment || experiment.expired) {
+ return;
+ }
+
+ let now = Date.now(); // milliseconds-since-epoch
+ let day = 24 * 60 * 60 * 1000;
+ let newDeadline = new Date(now + 7 * day).toJSON();
+
+ if (experiment.temporaryErrorDeadline) {
+ let deadline = new Date(experiment.temporaryErrorDeadline);
+ // if deadline is an invalid date, set it to one week from now.
+ if (isNaN(deadline)) {
+ experiment.temporaryErrorDeadline = newDeadline;
+ await lazy.PreferenceExperiments.update(experiment);
+ return;
+ }
+
+ if (now > deadline) {
+ await lazy.PreferenceExperiments.stop(experiment.slug, {
+ resetValue: true,
+ reason,
+ caller: "PreferenceExperimentAction._considerTemporaryFailure",
+ });
+ }
+ } else {
+ // there is no deadline, so set one
+ experiment.temporaryErrorDeadline = newDeadline;
+ await lazy.PreferenceExperiments.update(experiment);
+ }
+ }
+}
+
+PreferenceExperimentAction.BadNoRecipesArg = class extends Error {
+ message = "noRecipes is true, but some recipes observed";
+};
diff --git a/toolkit/components/normandy/actions/PreferenceRollbackAction.sys.mjs b/toolkit/components/normandy/actions/PreferenceRollbackAction.sys.mjs
new file mode 100644
index 0000000000..cc86ad8f70
--- /dev/null
+++ b/toolkit/components/normandy/actions/PreferenceRollbackAction.sys.mjs
@@ -0,0 +1,104 @@
+/* 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 { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
+ PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs",
+ PreferenceRollouts: "resource://normandy/lib/PreferenceRollouts.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
+});
+
+export class PreferenceRollbackAction extends BaseAction {
+ get schema() {
+ return lazy.ActionSchemas["preference-rollback"];
+ }
+
+ async _run(recipe) {
+ const { rolloutSlug } = recipe.arguments;
+ const rollout = await lazy.PreferenceRollouts.get(rolloutSlug);
+
+ if (lazy.PreferenceRollouts.GRADUATION_SET.has(rolloutSlug)) {
+ // graduated rollouts can't be rolled back
+ lazy.TelemetryEvents.sendEvent(
+ "unenrollFailed",
+ "preference_rollback",
+ rolloutSlug,
+ {
+ reason: "in-graduation-set",
+ enrollmentId:
+ rollout?.enrollmentId ??
+ lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+ throw new Error(
+ `Cannot rollback rollout in graduation set "${rolloutSlug}".`
+ );
+ }
+
+ if (!rollout) {
+ this.log.debug(`Rollback ${rolloutSlug} not applicable, skipping`);
+ return;
+ }
+
+ switch (rollout.state) {
+ case lazy.PreferenceRollouts.STATE_ACTIVE: {
+ this.log.info(`Rolling back ${rolloutSlug}`);
+ rollout.state = lazy.PreferenceRollouts.STATE_ROLLED_BACK;
+ for (const { preferenceName, previousValue } of rollout.preferences) {
+ lazy.PrefUtils.setPref(preferenceName, previousValue, {
+ branch: "default",
+ });
+ }
+ await lazy.PreferenceRollouts.update(rollout);
+ lazy.TelemetryEvents.sendEvent(
+ "unenroll",
+ "preference_rollback",
+ rolloutSlug,
+ {
+ reason: "rollback",
+ enrollmentId:
+ rollout.enrollmentId ||
+ lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+ lazy.TelemetryEnvironment.setExperimentInactive(rolloutSlug);
+ break;
+ }
+ case lazy.PreferenceRollouts.STATE_ROLLED_BACK: {
+ // The rollout has already been rolled back, so nothing to do here.
+ break;
+ }
+ case lazy.PreferenceRollouts.STATE_GRADUATED: {
+ // graduated rollouts can't be rolled back
+ lazy.TelemetryEvents.sendEvent(
+ "unenrollFailed",
+ "preference_rollback",
+ rolloutSlug,
+ {
+ reason: "graduated",
+ enrollmentId:
+ rollout.enrollmentId ||
+ lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+ throw new Error(
+ `Cannot rollback already graduated rollout ${rolloutSlug}`
+ );
+ }
+ default: {
+ throw new Error(
+ `Unexpected state when rolling back ${rolloutSlug}: ${rollout.state}`
+ );
+ }
+ }
+ }
+
+ async _finalize() {
+ await lazy.PreferenceRollouts.saveStartupPrefs();
+ }
+}
diff --git a/toolkit/components/normandy/actions/PreferenceRolloutAction.sys.mjs b/toolkit/components/normandy/actions/PreferenceRolloutAction.sys.mjs
new file mode 100644
index 0000000000..b2f917bd95
--- /dev/null
+++ b/toolkit/components/normandy/actions/PreferenceRolloutAction.sys.mjs
@@ -0,0 +1,265 @@
+/* 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 { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
+ NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs",
+ PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs",
+ PreferenceRollouts: "resource://normandy/lib/PreferenceRollouts.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
+});
+
+const PREFERENCE_TYPE_MAP = {
+ boolean: Services.prefs.PREF_BOOL,
+ string: Services.prefs.PREF_STRING,
+ number: Services.prefs.PREF_INT,
+};
+
+export class PreferenceRolloutAction extends BaseAction {
+ get schema() {
+ return lazy.ActionSchemas["preference-rollout"];
+ }
+
+ async _run(recipe) {
+ const args = recipe.arguments;
+
+ // Check if the rollout is on the list of rollouts to stop applying.
+ if (lazy.PreferenceRollouts.GRADUATION_SET.has(args.slug)) {
+ this.log.debug(
+ `Skipping rollout "${args.slug}" because it is in the graduation set.`
+ );
+ return;
+ }
+
+ // Determine which preferences are already being managed, to avoid
+ // conflicts between recipes. This will throw if there is a problem.
+ await this._verifyRolloutPrefs(args);
+
+ const newRollout = {
+ slug: args.slug,
+ state: "active",
+ preferences: args.preferences.map(({ preferenceName, value }) => ({
+ preferenceName,
+ value,
+ previousValue: lazy.PrefUtils.getPref(preferenceName, {
+ branch: "default",
+ }),
+ })),
+ };
+
+ const existingRollout = await lazy.PreferenceRollouts.get(args.slug);
+ if (existingRollout) {
+ const anyChanged = await this._updatePrefsForExistingRollout(
+ existingRollout,
+ newRollout
+ );
+
+ // If anything was different about the new rollout, write it to the db and send an event about it
+ if (anyChanged) {
+ await lazy.PreferenceRollouts.update(newRollout);
+ lazy.TelemetryEvents.sendEvent(
+ "update",
+ "preference_rollout",
+ args.slug,
+ {
+ previousState: existingRollout.state,
+ enrollmentId:
+ existingRollout.enrollmentId ||
+ lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+
+ switch (existingRollout.state) {
+ case lazy.PreferenceRollouts.STATE_ACTIVE: {
+ this.log.debug(`Updated preference rollout ${args.slug}`);
+ break;
+ }
+ case lazy.PreferenceRollouts.STATE_GRADUATED: {
+ this.log.debug(`Ungraduated preference rollout ${args.slug}`);
+ lazy.TelemetryEnvironment.setExperimentActive(
+ args.slug,
+ newRollout.state,
+ { type: "normandy-prefrollout" }
+ );
+ break;
+ }
+ default: {
+ console.error(
+ new Error(
+ `Updated pref rollout in unexpected state: ${existingRollout.state}`
+ )
+ );
+ }
+ }
+ } else {
+ this.log.debug(`No updates to preference rollout ${args.slug}`);
+ }
+ } else {
+ // new enrollment
+ // Check if this rollout would be a no-op, which is not allowed.
+ if (
+ newRollout.preferences.every(
+ ({ value, previousValue }) => value === previousValue
+ )
+ ) {
+ lazy.TelemetryEvents.sendEvent(
+ "enrollFailed",
+ "preference_rollout",
+ args.slug,
+ { reason: "would-be-no-op" }
+ );
+ // Throw so that this recipe execution is marked as a failure
+ throw new Error(
+ `New rollout ${args.slug} does not change any preferences.`
+ );
+ }
+
+ let enrollmentId = lazy.NormandyUtils.generateUuid();
+ newRollout.enrollmentId = enrollmentId;
+
+ await lazy.PreferenceRollouts.add(newRollout);
+
+ for (const { preferenceName, value } of args.preferences) {
+ lazy.PrefUtils.setPref(preferenceName, value, { branch: "default" });
+ }
+
+ this.log.debug(`Enrolled in preference rollout ${args.slug}`);
+ lazy.TelemetryEnvironment.setExperimentActive(
+ args.slug,
+ newRollout.state,
+ {
+ type: "normandy-prefrollout",
+ enrollmentId:
+ enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+ lazy.TelemetryEvents.sendEvent(
+ "enroll",
+ "preference_rollout",
+ args.slug,
+ {
+ enrollmentId:
+ enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+ }
+ }
+
+ /**
+ * Check that all the preferences in a rollout are ok to set. This means 1) no
+ * other rollout is managing them, and 2) they match the types of the builtin
+ * values.
+ * @param {PreferenceRollout} rollout The arguments from a rollout recipe.
+ * @throws If the preferences are not valid, with details in the error message.
+ */
+ async _verifyRolloutPrefs({ slug, preferences }) {
+ const existingManagedPrefs = new Set();
+ for (const rollout of await lazy.PreferenceRollouts.getAllActive()) {
+ if (rollout.slug === slug) {
+ continue;
+ }
+ for (const prefSpec of rollout.preferences) {
+ existingManagedPrefs.add(prefSpec.preferenceName);
+ }
+ }
+
+ for (const prefSpec of preferences) {
+ if (existingManagedPrefs.has(prefSpec.preferenceName)) {
+ lazy.TelemetryEvents.sendEvent(
+ "enrollFailed",
+ "preference_rollout",
+ slug,
+ {
+ reason: "conflict",
+ preference: prefSpec.preferenceName,
+ }
+ );
+ // Throw so that this recipe execution is marked as a failure
+ throw new Error(
+ `Cannot start rollout ${slug}. Preference ${prefSpec.preferenceName} is already managed.`
+ );
+ }
+ const existingPrefType = Services.prefs.getPrefType(
+ prefSpec.preferenceName
+ );
+ const rolloutPrefType = PREFERENCE_TYPE_MAP[typeof prefSpec.value];
+
+ if (
+ existingPrefType !== Services.prefs.PREF_INVALID &&
+ existingPrefType !== rolloutPrefType
+ ) {
+ lazy.TelemetryEvents.sendEvent(
+ "enrollFailed",
+ "preference_rollout",
+ slug,
+ {
+ reason: "invalid type",
+ preference: prefSpec.preferenceName,
+ }
+ );
+ // Throw so that this recipe execution is marked as a failure
+ throw new Error(
+ `Cannot start rollout "${slug}" on "${prefSpec.preferenceName}". ` +
+ `Existing preference is of type ${existingPrefType}, but rollout ` +
+ `specifies type ${rolloutPrefType}`
+ );
+ }
+ }
+ }
+
+ async _updatePrefsForExistingRollout(existingRollout, newRollout) {
+ let anyChanged = false;
+ const oldPrefSpecs = new Map(
+ existingRollout.preferences.map(p => [p.preferenceName, p])
+ );
+ const newPrefSpecs = new Map(
+ newRollout.preferences.map(p => [p.preferenceName, p])
+ );
+
+ // Check for any preferences that no longer exist, and un-set them.
+ for (const { preferenceName, previousValue } of oldPrefSpecs.values()) {
+ if (!newPrefSpecs.has(preferenceName)) {
+ this.log.debug(
+ `updating ${existingRollout.slug}: ${preferenceName} no longer exists`
+ );
+ anyChanged = true;
+ lazy.PrefUtils.setPref(preferenceName, previousValue, {
+ branch: "default",
+ });
+ }
+ }
+
+ // Check for any preferences that are new and need added, or changed and need updated.
+ for (const prefSpec of Object.values(newRollout.preferences)) {
+ let oldValue = null;
+ if (oldPrefSpecs.has(prefSpec.preferenceName)) {
+ let oldPrefSpec = oldPrefSpecs.get(prefSpec.preferenceName);
+ oldValue = oldPrefSpec.value;
+
+ // Trust the old rollout for the values of `previousValue`, but don't
+ // consider this a change, since it already matches the DB, and doesn't
+ // have any other stateful effect.
+ prefSpec.previousValue = oldPrefSpec.previousValue;
+ }
+ if (oldValue !== newPrefSpecs.get(prefSpec.preferenceName).value) {
+ anyChanged = true;
+ this.log.debug(
+ `updating ${existingRollout.slug}: ${prefSpec.preferenceName} value changed from ${oldValue} to ${prefSpec.value}`
+ );
+ lazy.PrefUtils.setPref(prefSpec.preferenceName, prefSpec.value, {
+ branch: "default",
+ });
+ }
+ }
+ return anyChanged;
+ }
+
+ async _finalize() {
+ await lazy.PreferenceRollouts.saveStartupPrefs();
+ }
+}
diff --git a/toolkit/components/normandy/actions/ShowHeartbeatAction.sys.mjs b/toolkit/components/normandy/actions/ShowHeartbeatAction.sys.mjs
new file mode 100644
index 0000000000..d571e46167
--- /dev/null
+++ b/toolkit/components/normandy/actions/ShowHeartbeatAction.sys.mjs
@@ -0,0 +1,226 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
+ ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
+ Heartbeat: "resource://normandy/lib/Heartbeat.sys.mjs",
+ NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs",
+ ShellService: "resource:///modules/ShellService.sys.mjs",
+ Storage: "resource://normandy/lib/Storage.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "gAllRecipeStorage", function () {
+ return new lazy.Storage("normandy-heartbeat");
+});
+
+const DAY_IN_MS = 24 * 60 * 60 * 1000;
+const HEARTBEAT_THROTTLE = 1 * DAY_IN_MS;
+
+export class ShowHeartbeatAction extends BaseAction {
+ static Heartbeat = lazy.Heartbeat;
+
+ static overrideHeartbeatForTests(newHeartbeat) {
+ if (newHeartbeat) {
+ this.Heartbeat = newHeartbeat;
+ } else {
+ this.Heartbeat = lazy.Heartbeat;
+ }
+ }
+
+ get schema() {
+ return lazy.ActionSchemas["show-heartbeat"];
+ }
+
+ async _run(recipe) {
+ const {
+ message,
+ engagementButtonLabel,
+ thanksMessage,
+ learnMoreMessage,
+ learnMoreUrl,
+ } = recipe.arguments;
+
+ const recipeStorage = new lazy.Storage(recipe.id);
+
+ if (!(await this.shouldShow(recipeStorage, recipe))) {
+ return;
+ }
+
+ this.log.debug(
+ `Heartbeat for recipe ${recipe.id} showing prompt "${message}"`
+ );
+ const targetWindow = lazy.BrowserWindowTracker.getTopWindow();
+
+ if (!targetWindow) {
+ throw new Error("No window to show heartbeat in");
+ }
+
+ const heartbeat = new ShowHeartbeatAction.Heartbeat(targetWindow, {
+ surveyId: this.generateSurveyId(recipe),
+ message,
+ engagementButtonLabel,
+ thanksMessage,
+ learnMoreMessage,
+ learnMoreUrl,
+ postAnswerUrl: await this.generatePostAnswerURL(recipe),
+ flowId: lazy.NormandyUtils.generateUuid(),
+ // Recipes coming from Nimbus won't have a revision_id.
+ ...(Object.hasOwn(recipe, "revision_id")
+ ? { surveyVersion: recipe.revision_id }
+ : {}),
+ });
+
+ heartbeat.eventEmitter.once(
+ "Voted",
+ this.updateLastInteraction.bind(this, recipeStorage)
+ );
+ heartbeat.eventEmitter.once(
+ "Engaged",
+ this.updateLastInteraction.bind(this, recipeStorage)
+ );
+
+ let now = Date.now();
+ await Promise.all([
+ lazy.gAllRecipeStorage.setItem("lastShown", now),
+ recipeStorage.setItem("lastShown", now),
+ ]);
+ }
+
+ async shouldShow(recipeStorage, recipe) {
+ const { repeatOption, repeatEvery } = recipe.arguments;
+ // Don't show any heartbeats to a user more than once per throttle period
+ let lastShown = await lazy.gAllRecipeStorage.getItem("lastShown");
+ if (lastShown) {
+ const duration = new Date() - lastShown;
+ if (duration < HEARTBEAT_THROTTLE) {
+ // show the number of hours since the last heartbeat, with at most 1 decimal point.
+ const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10;
+ this.log.debug(
+ `A heartbeat was shown too recently (${hoursAgo} hours), skipping recipe ${recipe.id}.`
+ );
+ return false;
+ }
+ }
+
+ switch (repeatOption) {
+ case "once": {
+ // Don't show if we've ever shown before
+ if (await recipeStorage.getItem("lastShown")) {
+ this.log.debug(
+ `Heartbeat for "once" recipe ${recipe.id} has been shown before, skipping.`
+ );
+ return false;
+ }
+ break;
+ }
+
+ case "nag": {
+ // Show a heartbeat again only if the user has not interacted with it before
+ if (await recipeStorage.getItem("lastInteraction")) {
+ this.log.debug(
+ `Heartbeat for "nag" recipe ${recipe.id} has already been interacted with, skipping.`
+ );
+ return false;
+ }
+ break;
+ }
+
+ case "xdays": {
+ // Show this heartbeat again if it has been at least `repeatEvery` days since the last time it was shown.
+ let lastShown = await lazy.gAllRecipeStorage.getItem("lastShown");
+ if (lastShown) {
+ lastShown = new Date(lastShown);
+ const duration = new Date() - lastShown;
+ if (duration < repeatEvery * DAY_IN_MS) {
+ // show the number of hours since the last time this hearbeat was shown, with at most 1 decimal point.
+ const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10;
+ this.log.debug(
+ `Heartbeat for "xdays" recipe ${recipe.id} ran in the last ${repeatEvery} days, skipping. (${hoursAgo} hours ago)`
+ );
+ return false;
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns a surveyId value. If recipe calls to include the Normandy client
+ * ID, then the client ID is attached to the surveyId in the format
+ * `${surveyId}::${userId}`.
+ *
+ * @return {String} Survey ID, possibly with user UUID
+ */
+ generateSurveyId(recipe) {
+ const { includeTelemetryUUID, surveyId } = recipe.arguments;
+ if (includeTelemetryUUID) {
+ return `${surveyId}::${lazy.ClientEnvironment.userId}`;
+ }
+ return surveyId;
+ }
+
+ /**
+ * Generate the appropriate post-answer URL for a recipe.
+ * @param recipe
+ * @return {String} URL with post-answer query params
+ */
+ async generatePostAnswerURL(recipe) {
+ const { postAnswerUrl, message, includeTelemetryUUID } = recipe.arguments;
+
+ // Don`t bother with empty URLs.
+ if (!postAnswerUrl) {
+ return postAnswerUrl;
+ }
+
+ const userId = lazy.ClientEnvironment.userId;
+ const searchEngine = (await Services.search.getDefault()).identifier;
+ const args = {
+ fxVersion: Services.appinfo.version,
+ isDefaultBrowser: lazy.ShellService.isDefaultBrowser() ? 1 : 0,
+ searchEngine,
+ source: "heartbeat",
+ // `surveyversion` used to be the version of the heartbeat action when it
+ // was hosted on a server. Keeping it around for compatibility.
+ surveyversion: Services.appinfo.version,
+ syncSetup: Services.prefs.prefHasUserValue("services.sync.username")
+ ? 1
+ : 0,
+ updateChannel: lazy.UpdateUtils.getUpdateChannel(false),
+ utm_campaign: encodeURIComponent(message.replace(/\s+/g, "")),
+ utm_medium: recipe.action,
+ utm_source: "firefox",
+ };
+ if (includeTelemetryUUID) {
+ args.userId = userId;
+ }
+
+ let url = new URL(postAnswerUrl);
+ // create a URL object to append arguments to
+ for (const [key, val] of Object.entries(args)) {
+ if (!url.searchParams.has(key)) {
+ url.searchParams.set(key, val);
+ }
+ }
+
+ // return the address with encoded queries
+ return url.toString();
+ }
+
+ updateLastInteraction(recipeStorage) {
+ recipeStorage.setItem("lastInteraction", Date.now());
+ }
+}
diff --git a/toolkit/components/normandy/actions/schemas/README.md b/toolkit/components/normandy/actions/schemas/README.md
new file mode 100644
index 0000000000..06a7dd4b15
--- /dev/null
+++ b/toolkit/components/normandy/actions/schemas/README.md
@@ -0,0 +1,13 @@
+# Normandy Action Argument Schemas
+
+This is a collection of schemas describing the arguments expected by Normandy
+actions. Its primary purpose is to be used in the Normandy server and Delivery
+Console to validate data and provide better user interactions.
+
+# Contributing
+
+Modifications to this package are made as part of mozilla-central following
+standard procedures: push a patch to Phabricator as part of a bug.
+
+To release, bump the package version and land that, as above -- it can be part
+of another patch -- and then `cd` to this directory and `npm publish`.
diff --git a/toolkit/components/normandy/actions/schemas/export_json.js b/toolkit/components/normandy/actions/schemas/export_json.js
new file mode 100644
index 0000000000..940db247aa
--- /dev/null
+++ b/toolkit/components/normandy/actions/schemas/export_json.js
@@ -0,0 +1,20 @@
+#!/usr/bin/env node
+/* 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/. */
+
+/* eslint-env node */
+
+/**
+ * This script exports the schemas from this package in JSON format, for use by
+ * other tools. It is run as a part of the publishing process to NPM.
+ */
+
+const fs = require("fs");
+const schemas = require("./index.js");
+
+fs.writeFile("./schemas.json", JSON.stringify(schemas), err => {
+ if (err) {
+ console.error("error", err);
+ }
+});
diff --git a/toolkit/components/normandy/actions/schemas/index.sys.mjs b/toolkit/components/normandy/actions/schemas/index.sys.mjs
new file mode 100644
index 0000000000..7ef006e905
--- /dev/null
+++ b/toolkit/components/normandy/actions/schemas/index.sys.mjs
@@ -0,0 +1,528 @@
+/* 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/. */
+
+export const ActionSchemas = {
+ "console-log": {
+ $schema: "http://json-schema.org/draft-04/schema#",
+ title: "Log a message to the console",
+ type: "object",
+ required: ["message"],
+ properties: {
+ message: {
+ description: "Message to log to the console",
+ type: "string",
+ default: "",
+ },
+ },
+ },
+
+ "messaging-experiment": {
+ $schema: "http://json-schema.org/draft-04/schema#",
+ title: "Messaging Experiment",
+ type: "object",
+ required: ["slug", "branches", "isEnrollmentPaused"],
+ properties: {
+ slug: {
+ description: "Unique identifier for this experiment",
+ type: "string",
+ pattern: "^[A-Za-z0-9\\-_]+$",
+ },
+ isEnrollmentPaused: {
+ description: "If true, new users will not be enrolled in the study.",
+ type: "boolean",
+ default: true,
+ },
+ branches: {
+ description: "List of experimental branches",
+ type: "array",
+ minItems: 1,
+ items: {
+ type: "object",
+ required: ["slug", "value", "ratio", "groups"],
+ properties: {
+ slug: {
+ description:
+ "Unique identifier for this branch of the experiment.",
+ type: "string",
+ pattern: "^[A-Za-z0-9\\-_]+$",
+ },
+ value: {
+ description: "Message content.",
+ type: "object",
+ properties: {},
+ },
+ ratio: {
+ description:
+ "Ratio of users who should be grouped into this branch.",
+ type: "integer",
+ minimum: 1,
+ },
+ groups: {
+ description:
+ "A list of experiment groups that can be used to exclude or select related experiments. May be empty.",
+ type: "array",
+ items: {
+ type: "string",
+ description: "Identifier of the group",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+
+ "preference-rollout": {
+ $schema: "http://json-schema.org/draft-04/schema#",
+ title: "Change preferences permanently",
+ type: "object",
+ required: ["slug", "preferences"],
+ properties: {
+ slug: {
+ description:
+ "Unique identifer for the rollout, used in telemetry and rollbacks",
+ type: "string",
+ pattern: "^[a-z0-9\\-_]+$",
+ },
+ preferences: {
+ description: "The preferences to change, and their values",
+ type: "array",
+ minItems: 1,
+ items: {
+ type: "object",
+ required: ["preferenceName", "value"],
+ properties: {
+ preferenceName: {
+ description: "Full dotted-path of the preference being changed",
+ type: "string",
+ },
+ value: {
+ description: "Value to set the preference to",
+ type: ["string", "integer", "boolean"],
+ },
+ },
+ },
+ },
+ },
+ },
+
+ "preference-rollback": {
+ $schema: "http://json-schema.org/draft-04/schema#",
+ title: "Undo a preference rollout",
+ type: "object",
+ required: ["rolloutSlug"],
+ properties: {
+ rolloutSlug: {
+ description: "Unique identifer for the rollout to undo",
+ type: "string",
+ pattern: "^[a-z0-9\\-_]+$",
+ },
+ },
+ },
+
+ "addon-study": {
+ $schema: "http://json-schema.org/draft-04/schema#",
+ title: "Enroll a user in an opt-out SHIELD study",
+ type: "object",
+ required: [
+ "name",
+ "description",
+ "addonUrl",
+ "extensionApiId",
+ "isEnrollmentPaused",
+ ],
+ properties: {
+ name: {
+ description: "User-facing name of the study",
+ type: "string",
+ minLength: 1,
+ },
+ description: {
+ description: "User-facing description of the study",
+ type: "string",
+ minLength: 1,
+ },
+ addonUrl: {
+ description: "URL of the add-on XPI file",
+ type: "string",
+ format: "uri",
+ minLength: 1,
+ },
+ extensionApiId: {
+ description:
+ "The record ID of the extension used for Normandy API calls.",
+ type: "integer",
+ },
+ isEnrollmentPaused: {
+ description: "If true, new users will not be enrolled in the study.",
+ type: "boolean",
+ default: true,
+ },
+ },
+ },
+
+ "addon-rollout": {
+ $schema: "http://json-schema.org/draft-04/schema#",
+ title: "Install add-on permanently",
+ type: "object",
+ required: ["extensionApiId", "slug"],
+ properties: {
+ extensionApiId: {
+ description:
+ "The record ID of the extension used for Normandy API calls.",
+ type: "integer",
+ },
+ slug: {
+ description:
+ "Unique identifer for the rollout, used in telemetry and rollbacks.",
+ type: "string",
+ pattern: "^[a-z0-9\\-_]+$",
+ },
+ },
+ },
+
+ "addon-rollback": {
+ $schema: "http://json-schema.org/draft-04/schema#",
+ title: "Undo an add-on rollout",
+ type: "object",
+ required: ["rolloutSlug"],
+ properties: {
+ rolloutSlug: {
+ description: "Unique identifer for the rollout to undo.",
+ type: "string",
+ pattern: "^[a-z0-9\\-_]+$",
+ },
+ },
+ },
+
+ "branched-addon-study": {
+ $schema: "http://json-schema.org/draft-04/schema#",
+ title: "Enroll a user in an add-on experiment, with managed branches",
+ type: "object",
+ required: [
+ "slug",
+ "userFacingName",
+ "userFacingDescription",
+ "branches",
+ "isEnrollmentPaused",
+ ],
+ properties: {
+ slug: {
+ description: "Machine-readable identifier",
+ type: "string",
+ minLength: 1,
+ },
+ userFacingName: {
+ description: "User-facing name of the study",
+ type: "string",
+ minLength: 1,
+ },
+ userFacingDescription: {
+ description: "User-facing description of the study",
+ type: "string",
+ minLength: 1,
+ },
+ isEnrollmentPaused: {
+ description: "If true, new users will not be enrolled in the study.",
+ type: "boolean",
+ default: true,
+ },
+ branches: {
+ description: "List of experimental branches",
+ type: "array",
+ minItems: 1,
+ items: {
+ type: "object",
+ required: ["slug", "ratio", "extensionApiId"],
+ properties: {
+ slug: {
+ description:
+ "Unique identifier for this branch of the experiment.",
+ type: "string",
+ pattern: "^[A-Za-z0-9\\-_]+$",
+ },
+ ratio: {
+ description:
+ "Ratio of users who should be grouped into this branch.",
+ type: "integer",
+ minimum: 1,
+ },
+ extensionApiId: {
+ description:
+ "The record ID of the add-on uploaded to the Normandy server. May be null, in which case no add-on will be installed.",
+ type: ["number", "null"],
+ default: null,
+ },
+ },
+ },
+ },
+ },
+ },
+
+ "show-heartbeat": {
+ $schema: "http://json-schema.org/draft-04/schema#",
+ title: "Show a Heartbeat survey.",
+ description: "This action shows a single survey.",
+
+ type: "object",
+ required: [
+ "surveyId",
+ "message",
+ "thanksMessage",
+ "postAnswerUrl",
+ "learnMoreMessage",
+ "learnMoreUrl",
+ ],
+ properties: {
+ repeatOption: {
+ type: "string",
+ enum: ["once", "xdays", "nag"],
+ description: "Determines how often a prompt is shown executes.",
+ default: "once",
+ },
+ repeatEvery: {
+ description:
+ "For repeatOption=xdays, how often (in days) the prompt is displayed.",
+ default: null,
+ type: ["number", "null"],
+ },
+ includeTelemetryUUID: {
+ type: "boolean",
+ description: "Include unique user ID in post-answer-url and Telemetry",
+ default: false,
+ },
+ surveyId: {
+ type: "string",
+ description: "Slug uniquely identifying this survey in telemetry",
+ },
+ message: {
+ description: "Message to show to the user",
+ type: "string",
+ },
+ engagementButtonLabel: {
+ description:
+ "Text for the engagement button. If specified, this button will be shown instead of rating stars.",
+ default: null,
+ type: ["string", "null"],
+ },
+ thanksMessage: {
+ description:
+ "Thanks message to show to the user after they've rated Firefox",
+ type: "string",
+ },
+ postAnswerUrl: {
+ description:
+ "URL to redirect the user to after rating Firefox or clicking the engagement button",
+ default: null,
+ type: ["string", "null"],
+ },
+ learnMoreMessage: {
+ description: "Message to show to the user to learn more",
+ default: null,
+ type: ["string", "null"],
+ },
+ learnMoreUrl: {
+ description: "URL to show to the user when they click Learn More",
+ default: null,
+ type: ["string", "null"],
+ },
+ },
+ },
+
+ "multi-preference-experiment": {
+ $schema: "http://json-schema.org/draft-04/schema#",
+ title: "Run a feature experiment activated by a set of preferences.",
+ type: "object",
+ required: [
+ "slug",
+ "userFacingName",
+ "userFacingDescription",
+ "branches",
+ "isEnrollmentPaused",
+ ],
+ properties: {
+ slug: {
+ description: "Unique identifier for this experiment",
+ type: "string",
+ pattern: "^[A-Za-z0-9\\-_]+$",
+ },
+ userFacingName: {
+ description: "User-facing name of the experiment",
+ type: "string",
+ minLength: 1,
+ },
+ userFacingDescription: {
+ description: "User-facing description of the experiment",
+ type: "string",
+ minLength: 1,
+ },
+ experimentDocumentUrl: {
+ description: "URL of a document describing the experiment",
+ type: "string",
+ format: "uri",
+ default: "",
+ },
+ isHighPopulation: {
+ description:
+ "Marks the preference experiment as a high population experiment, that should be excluded from certain types of telemetry",
+ type: "boolean",
+ default: "false",
+ },
+ isEnrollmentPaused: {
+ description: "If true, new users will not be enrolled in the study.",
+ type: "boolean",
+ default: true,
+ },
+ branches: {
+ description: "List of experimental branches",
+ type: "array",
+ minItems: 1,
+ items: {
+ type: "object",
+ required: ["slug", "ratio", "preferences"],
+ properties: {
+ slug: {
+ description:
+ "Unique identifier for this branch of the experiment",
+ type: "string",
+ pattern: "^[A-Za-z0-9\\-_]+$",
+ },
+ ratio: {
+ description:
+ "Ratio of users who should be grouped into this branch",
+ type: "integer",
+ minimum: 1,
+ },
+ preferences: {
+ description:
+ "The set of preferences to be set if this branch is chosen",
+ type: "object",
+ patternProperties: {
+ ".*": {
+ type: "object",
+ properties: {
+ preferenceType: {
+ description:
+ "Data type of the preference that controls this experiment",
+ type: "string",
+ enum: ["string", "integer", "boolean"],
+ },
+ preferenceBranchType: {
+ description:
+ "Controls whether the default or user value of the preference is modified",
+ type: "string",
+ enum: ["user", "default"],
+ default: "default",
+ },
+ preferenceValue: {
+ description:
+ "Value for this preference when this branch is chosen",
+ type: ["string", "number", "boolean"],
+ },
+ },
+ required: [
+ "preferenceType",
+ "preferenceBranchType",
+ "preferenceValue",
+ ],
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+
+ "single-preference-experiment": {
+ $schema: "http://json-schema.org/draft-04/schema#",
+ title: "Run a feature experiment activated by a preference.",
+ type: "object",
+ required: [
+ "slug",
+ "preferenceName",
+ "preferenceType",
+ "branches",
+ "isEnrollmentPaused",
+ ],
+ properties: {
+ slug: {
+ description: "Unique identifier for this experiment",
+ type: "string",
+ pattern: "^[A-Za-z0-9\\-_]+$",
+ },
+ experimentDocumentUrl: {
+ description: "URL of a document describing the experiment",
+ type: "string",
+ format: "uri",
+ default: "",
+ },
+ preferenceName: {
+ description:
+ "Full dotted-path of the preference that controls this experiment",
+ type: "string",
+ },
+ preferenceType: {
+ description:
+ "Data type of the preference that controls this experiment",
+ type: "string",
+ enum: ["string", "integer", "boolean"],
+ },
+ preferenceBranchType: {
+ description:
+ "Controls whether the default or user value of the preference is modified",
+ type: "string",
+ enum: ["user", "default"],
+ default: "default",
+ },
+ isHighPopulation: {
+ description:
+ "Marks the preference experiment as a high population experiment, that should be excluded from certain types of telemetry",
+ type: "boolean",
+ default: "false",
+ },
+ isEnrollmentPaused: {
+ description: "If true, new users will not be enrolled in the study.",
+ type: "boolean",
+ default: true,
+ },
+ branches: {
+ description: "List of experimental branches",
+ type: "array",
+ minItems: 1,
+ items: {
+ type: "object",
+ required: ["slug", "value", "ratio"],
+ properties: {
+ slug: {
+ description:
+ "Unique identifier for this branch of the experiment",
+ type: "string",
+ pattern: "^[A-Za-z0-9\\-_]+$",
+ },
+ value: {
+ description: "Value to set the preference to for this branch",
+ type: ["string", "number", "boolean"],
+ },
+ ratio: {
+ description:
+ "Ratio of users who should be grouped into this branch",
+ type: "integer",
+ minimum: 1,
+ },
+ },
+ },
+ },
+ },
+ },
+};
+
+// Legacy name used on Normandy server
+ActionSchemas["opt-out-study"] = ActionSchemas["addon-study"];
+
+// If running in Node.js, export the schemas.
+if (typeof module !== "undefined") {
+ /* globals module */
+ module.exports = ActionSchemas;
+}
diff --git a/toolkit/components/normandy/actions/schemas/package.json b/toolkit/components/normandy/actions/schemas/package.json
new file mode 100644
index 0000000000..f09031f88d
--- /dev/null
+++ b/toolkit/components/normandy/actions/schemas/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@mozilla/normandy-action-argument-schemas",
+ "version": "0.11.0",
+ "description": "Schemas for Normandy action arguments",
+ "main": "index.js",
+ "author": "Michael Cooper <mcooper@mozilla.com>",
+ "license": "MPL-2.0",
+ "scripts": {
+ "prepack": "node export_json.js"
+ }
+}