diff options
Diffstat (limited to 'toolkit/components/normandy/actions')
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" + } +} |