1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 * This action handles the life cycle of add-on based studies. Currently that
7 * means installing the add-on the first time the recipe applies to this
8 * client, updating the add-on to new versions if the recipe changes, and
9 * uninstalling them when the recipe no longer applies.
12 import { BaseStudyAction } from "resource://normandy/actions/BaseStudyAction.sys.mjs";
16 ChromeUtils.defineESModuleGetters(lazy, {
17 ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
18 AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
19 AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs",
20 BaseAction: "resource://normandy/actions/BaseAction.sys.mjs",
21 ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
22 NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs",
23 Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs",
24 TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
25 TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
28 class AddonStudyEnrollError extends Error {
30 * @param {string} studyName
31 * @param {object} extra Extra details to include when reporting the error to telemetry.
32 * @param {string} extra.reason The specific reason for the failure.
34 constructor(studyName, extra) {
36 let { reason } = extra;
38 case "conflicting-addon-id": {
39 message = "an add-on with this ID is already installed";
42 case "download-failure": {
43 message = "the add-on failed to download";
46 case "metadata-mismatch": {
47 message = "the server metadata does not match the downloaded add-on";
50 case "install-failure": {
51 message = "the add-on failed to install";
55 throw new Error(`Unexpected AddonStudyEnrollError reason: ${reason}`);
58 super(`Cannot install study add-on for ${studyName}: ${message}.`);
59 this.studyName = studyName;
64 class AddonStudyUpdateError extends Error {
66 * @param {string} studyName
67 * @param {object} extra Extra details to include when reporting the error to telemetry.
68 * @param {string} extra.reason The specific reason for the failure.
70 constructor(studyName, extra) {
72 let { reason } = extra;
74 case "addon-id-mismatch": {
75 message = "new add-on ID does not match old add-on ID";
78 case "addon-does-not-exist": {
79 message = "an add-on with this ID does not exist";
82 case "no-downgrade": {
83 message = "the add-on was an older version than is installed";
86 case "metadata-mismatch": {
87 message = "the server metadata does not match the downloaded add-on";
90 case "download-failure": {
91 message = "the add-on failed to download";
94 case "install-failure": {
95 message = "the add-on failed to install";
99 throw new Error(`Unexpected AddonStudyUpdateError reason: ${reason}`);
102 super(`Cannot update study add-on for ${studyName}: ${message}.`);
103 this.studyName = studyName;
108 export class BranchedAddonStudyAction extends BaseStudyAction {
110 return lazy.ActionSchemas["branched-addon-study"];
115 this.seenRecipeIds = new Set();
119 throw new Error("_run should not be called anymore");
123 * This hook is executed once for every recipe currently enabled on the
124 * server. It is responsible for:
126 * - Enrolling studies the first time they have a FILTER_MATCH suitability.
127 * - Updating studies that have changed and still have a FILTER_MATCH suitability.
128 * - Marking studies as having been seen in this session.
129 * - Unenrolling studies when they have permanent errors.
130 * - Unenrolling studies when temporary errors persist for too long.
132 * If the action fails to perform any of these tasks, it should throw to
133 * properly report its status.
135 async _processRecipe(recipe, suitability) {
136 this.seenRecipeIds.add(recipe.id);
137 const study = await lazy.AddonStudies.get(recipe.id);
139 switch (suitability) {
140 case lazy.BaseAction.suitability.FILTER_MATCH: {
142 await this.enroll(recipe);
143 } else if (study.active) {
144 await this.update(recipe, study);
149 case lazy.BaseAction.suitability.SIGNATURE_ERROR: {
150 await this._considerTemporaryError({
152 reason: "signature-error",
157 case lazy.BaseAction.suitability.FILTER_ERROR: {
158 await this._considerTemporaryError({
160 reason: "filter-error",
165 case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: {
167 await this.unenroll(recipe.id, "capability-mismatch");
172 case lazy.BaseAction.suitability.FILTER_MISMATCH: {
174 await this.unenroll(recipe.id, "filter-mismatch");
179 case lazy.BaseAction.suitability.ARGUMENTS_INVALID: {
181 await this.unenroll(recipe.id, "arguments-invalid");
187 throw new Error(`Unknown recipe suitability "${suitability}".`);
193 * This hook is executed once after all recipes that apply to this client
194 * have been processed. It is responsible for unenrolling the client from any
195 * studies that no longer apply, based on this.seenRecipeIds.
197 async _finalize({ noRecipes } = {}) {
198 const activeStudies = await lazy.AddonStudies.getAllActive({
199 branched: lazy.AddonStudies.FILTER_BRANCHED_ONLY,
203 if (this.seenRecipeIds.size) {
204 throw new BranchedAddonStudyAction.BadNoRecipesArg();
206 for (const study of activeStudies) {
207 await this._considerTemporaryError({ study, reason: "no-recipes" });
210 for (const study of activeStudies) {
211 if (!this.seenRecipeIds.has(study.recipeId)) {
213 `Stopping branched add-on study for recipe ${study.recipeId}`
216 await this.unenroll(study.recipeId, "recipe-not-seen");
226 * Download and install the addon for a given recipe
228 * @param recipe Object describing the study to enroll in.
229 * @param extensionDetails Object describing the addon to be installed.
230 * @param onInstallStarted A function that returns a callback for the install listener.
231 * @param onComplete A callback function that is run on completion of the download.
232 * @param onFailedInstall A callback function that is run if the installation fails.
233 * @param errorClass The class of error to be thrown when exceptions occur.
234 * @param reportError A function that reports errors to Telemetry.
235 * @param [errorExtra] Optional, an object that will be merged into the
236 * `extra` field of the error generated, if any.
238 async downloadAndInstall({
249 const { slug } = recipe.arguments;
250 const { hash, hash_algorithm } = extensionDetails;
252 const downloadDeferred = Promise.withResolvers();
253 const installDeferred = Promise.withResolvers();
255 const install = await lazy.AddonManager.getInstallForURL(
256 extensionDetails.xpi,
258 hash: `${hash_algorithm}:${hash}`,
259 telemetryInfo: { source: "internal" },
265 downloadDeferred.reject(
266 new errorClass(slug, {
267 reason: "download-failure",
269 detail: lazy.AddonManager.errorToString(install.error),
276 downloadDeferred.resolve();
277 return false; // temporarily pause installation for Normandy bookkeeping
281 installDeferred.reject(
282 new errorClass(slug, {
283 reason: "install-failure",
285 detail: lazy.AddonManager.errorToString(install.error),
291 installDeferred.resolve();
295 listener.onInstallStarted = onInstallStarted(installDeferred);
297 install.addListener(listener);
299 // Download the add-on
302 await downloadDeferred.promise;
305 install.removeListener(listener);
309 await onComplete(install, listener);
311 // Finish paused installation
314 await installDeferred.promise;
317 install.removeListener(listener);
318 await onFailedInstall();
322 install.removeListener(listener);
324 return [install.addon.id, install.addon.version];
327 async chooseBranch({ slug, branches }) {
328 const ratios = branches.map(branch => branch.ratio);
329 const userId = lazy.ClientEnvironment.userId;
331 // It's important that the input be:
332 // - Unique per-user (no one is bucketed alike)
333 // - Unique per-experiment (bucketing differs across multiple experiments)
334 // - Differs from the input used for sampling the recipe (otherwise only
335 // branches that contain the same buckets as the recipe sampling will
337 const input = `${userId}-${slug}-addon-branch`;
339 const index = await lazy.Sampling.ratioSample(input, ratios);
340 return branches[index];
344 * Enroll in the study represented by the given recipe.
345 * @param recipe Object describing the study to enroll in.
346 * @param extensionDetails Object describing the addon to be installed.
348 async enroll(recipe) {
349 // This function first downloads the add-on to get its metadata. Then it
350 // uses that metadata to record a study in `AddonStudies`. Then, it finishes
351 // installing the add-on, and finally sends telemetry. If any of these steps
352 // fails, the previous ones are undone, as needed.
354 // This ordering is important because the only intermediate states we can be
356 // 1. The add-on is only downloaded, in which case AddonManager will clean it up.
357 // 2. The study has been recorded, in which case we will unenroll on next
358 // start up. The start up code will assume that the add-on was uninstalled
359 // while the browser was shutdown.
360 // 3. After installation is complete, but before telemetry, in which case we
361 // lose an enroll event. This is acceptable.
363 // This way a shutdown, crash or unexpected error can't leave Normandy in a
364 // long term inconsistent state. The main thing avoided is having a study
365 // add-on installed but no record of it, which would leave it permanently
368 if (recipe.arguments.isEnrollmentPaused) {
369 // Recipe does not need anything done
373 const { slug, userFacingName, userFacingDescription } = recipe.arguments;
374 const branch = await this.chooseBranch({
375 slug: recipe.arguments.slug,
376 branches: recipe.arguments.branches,
378 this.log.debug(`Enrolling in branch ${branch.slug}`);
380 if (branch.extensionApiId === null) {
385 userFacingDescription,
390 extensionApiId: null,
392 extensionHashAlgorithm: null,
394 studyStartDate: new Date(),
396 temporaryErrorDeadline: null,
400 await lazy.AddonStudies.add(study);
402 this.reportEnrollError(err);
406 // All done, report success to Telemetry
407 lazy.TelemetryEvents.sendEvent("enroll", "addon_study", slug, {
408 addonId: lazy.AddonStudies.NO_ADDON_MARKER,
409 addonVersion: lazy.AddonStudies.NO_ADDON_MARKER,
413 const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails(
414 branch.extensionApiId
417 const onInstallStarted = installDeferred => cbInstall => {
418 const versionMatches =
419 cbInstall.addon.version === extensionDetails.version;
420 const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
422 if (cbInstall.existingAddon) {
423 installDeferred.reject(
424 new AddonStudyEnrollError(slug, {
425 reason: "conflicting-addon-id",
429 return false; // cancel the installation, no upgrades allowed
430 } else if (!versionMatches || !idMatches) {
431 installDeferred.reject(
432 new AddonStudyEnrollError(slug, {
434 reason: "metadata-mismatch",
437 return false; // cancel the installation, server metadata does not match downloaded add-on
443 const onComplete = async (install, listener) => {
448 userFacingDescription,
450 addonId: install.addon.id,
451 addonVersion: install.addon.version,
452 addonUrl: extensionDetails.xpi,
453 extensionApiId: branch.extensionApiId,
454 extensionHash: extensionDetails.hash,
455 extensionHashAlgorithm: extensionDetails.hash_algorithm,
457 studyStartDate: new Date(),
459 temporaryErrorDeadline: null,
463 await lazy.AddonStudies.add(study);
465 this.reportEnrollError(err);
466 install.removeListener(listener);
472 const onFailedInstall = async () => {
473 await lazy.AddonStudies.delete(recipe.id);
476 const [installedId, installedVersion] = await this.downloadAndInstall({
478 branchSlug: branch.slug,
483 errorClass: AddonStudyEnrollError,
484 reportError: this.reportEnrollError,
487 // All done, report success to Telemetry
488 lazy.TelemetryEvents.sendEvent("enroll", "addon_study", slug, {
489 addonId: installedId,
490 addonVersion: installedVersion,
495 lazy.TelemetryEnvironment.setExperimentActive(slug, branch.slug, {
496 type: "normandy-addonstudy",
501 * Update the study represented by the given recipe.
502 * @param recipe Object describing the study to be updated.
503 * @param extensionDetails Object describing the addon to be installed.
505 async update(recipe, study) {
506 const { slug } = recipe.arguments;
508 // Stay in the same branch, don't re-sample every time.
509 const branch = recipe.arguments.branches.find(
510 branch => branch.slug === study.branch
514 // Our branch has been removed. Unenroll.
515 await this.unenroll(recipe.id, "branch-removed");
519 // Since we saw a non-error suitability, clear the temporary error deadline.
520 study.temporaryErrorDeadline = null;
521 await lazy.AddonStudies.update(study);
523 const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails(
524 branch.extensionApiId
529 if (study.addonId && study.addonId !== extensionDetails.extension_id) {
530 error = new AddonStudyUpdateError(slug, {
532 reason: "addon-id-mismatch",
536 const versionCompare = Services.vc.compare(
538 extensionDetails.version
540 if (versionCompare > 0) {
541 error = new AddonStudyUpdateError(slug, {
543 reason: "no-downgrade",
545 } else if (versionCompare === 0) {
546 return; // Unchanged, do nothing
550 this.reportUpdateError(error);
554 const onInstallStarted = installDeferred => cbInstall => {
555 const versionMatches =
556 cbInstall.addon.version === extensionDetails.version;
557 const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
559 if (!cbInstall.existingAddon) {
560 installDeferred.reject(
561 new AddonStudyUpdateError(slug, {
563 reason: "addon-does-not-exist",
566 return false; // cancel the installation, must upgrade an existing add-on
567 } else if (!versionMatches || !idMatches) {
568 installDeferred.reject(
569 new AddonStudyUpdateError(slug, {
571 reason: "metadata-mismatch",
574 return false; // cancel the installation, server metadata do not match downloaded add-on
580 const onComplete = async (install, listener) => {
582 await lazy.AddonStudies.update({
584 addonVersion: install.addon.version,
585 addonUrl: extensionDetails.xpi,
586 extensionHash: extensionDetails.hash,
587 extensionHashAlgorithm: extensionDetails.hash_algorithm,
588 extensionApiId: branch.extensionApiId,
591 this.reportUpdateError(err);
592 install.removeListener(listener);
598 const onFailedInstall = () => {
599 lazy.AddonStudies.update(study);
602 const [installedId, installedVersion] = await this.downloadAndInstall({
605 branchSlug: branch.slug,
609 errorClass: AddonStudyUpdateError,
610 reportError: this.reportUpdateError,
614 // All done, report success to Telemetry
615 lazy.TelemetryEvents.sendEvent("update", "addon_study", slug, {
616 addonId: installedId,
617 addonVersion: installedVersion,
622 reportEnrollError(error) {
623 if (error instanceof AddonStudyEnrollError) {
624 // One of our known errors. Report it nicely to telemetry
625 lazy.TelemetryEvents.sendEvent(
633 * Some unknown error. Add some helpful details, and report it to
634 * telemetry. The actual stack trace and error message could possibly
635 * contain PII, so we don't include them here. Instead include some
636 * information that should still be helpful, and is less likely to be
639 const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
640 lazy.TelemetryEvents.sendEvent(
645 reason: safeErrorMessage.slice(0, 80), // max length is 80 chars
651 reportUpdateError(error) {
652 if (error instanceof AddonStudyUpdateError) {
653 // One of our known errors. Report it nicely to telemetry
654 lazy.TelemetryEvents.sendEvent(
662 * Some unknown error. Add some helpful details, and report it to
663 * telemetry. The actual stack trace and error message could possibly
664 * contain PII, so we don't include them here. Instead include some
665 * information that should still be helpful, and is less likely to be
668 const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
669 lazy.TelemetryEvents.sendEvent(
674 reason: safeErrorMessage.slice(0, 80), // max length is 80 chars
681 * Unenrolls the client from the study with a given recipe ID.
682 * @param recipeId The recipe ID of an enrolled study
683 * @param reason The reason for this unenrollment, to be used in Telemetry
684 * @throws If the specified study does not exist, or if it is already inactive.
686 async unenroll(recipeId, reason = "unknown") {
687 const study = await lazy.AddonStudies.get(recipeId);
689 throw new Error(`No study found for recipe ${recipeId}.`);
693 `Cannot stop study for recipe ${recipeId}; it is already inactive.`
697 await lazy.AddonStudies.markAsEnded(study, reason);
699 // Study branches may indicate that no add-on should be installed, as a
700 // form of control branch. In that case, `study.addonId` will be null (as
701 // will the other add-on related fields). Only try to uninstall the add-on
702 // if we expect one should be installed.
704 const addon = await lazy.AddonManager.getAddonByID(study.addonId);
706 await addon.uninstall();
709 `Could not uninstall addon ${study.addonId} for recipe ${study.recipeId}: it is not installed.`
716 * Given that a temporary error has occured for a study, check if it
717 * should be temporarily ignored, or if the deadline has passed. If the
718 * deadline is passed, the study will be ended. If this is the first
719 * temporary error, a deadline will be generated. Otherwise, nothing will
722 * If a temporary deadline exists but cannot be parsed, a new one will be
725 * The deadline is 7 days from the first time that recipe failed, as
726 * reckoned by the client's clock.
728 * @param {Object} args
729 * @param {Study} args.study The enrolled study to potentially unenroll.
730 * @param {String} args.reason If the study should end, the reason it is ending.
732 async _considerTemporaryError({ study, reason }) {
733 if (!study?.active) {
737 let now = Date.now(); // milliseconds-since-epoch
738 let day = 24 * 60 * 60 * 1000;
739 let newDeadline = new Date(now + 7 * day);
741 if (study.temporaryErrorDeadline) {
742 // if deadline is an invalid date, set it to one week from now.
743 if (isNaN(study.temporaryErrorDeadline)) {
744 study.temporaryErrorDeadline = newDeadline;
745 await lazy.AddonStudies.update(study);
749 if (now > study.temporaryErrorDeadline) {
750 await this.unenroll(study.recipeId, reason);
753 // there is no deadline, so set one
754 study.temporaryErrorDeadline = newDeadline;
755 await lazy.AddonStudies.update(study);
760 BranchedAddonStudyAction.BadNoRecipesArg = class extends Error {
761 message = "noRecipes is true, but some recipes observed";