Bug 1433335 - Send telemetry events for each AddonManager action on an extension. draft
authorLuca Greco <lgreco@mozilla.com>
Wed, 27 Jun 2018 20:22:43 +0200
changeset 830178 201f12568db6a51fd2539d1864b4d7b901fbac41
parent 830177 096c72c4e6eca0aa9674e85eb649464694c79f54
child 830179 6fce46fa73af5dc250be113e0281d9d55f9f67bc
push id118822
push userluca.greco@alcacoop.it
push dateMon, 20 Aug 2018 14:44:05 +0000
bugs1433335
milestone63.0a1
Bug 1433335 - Send telemetry events for each AddonManager action on an extension. MozReview-Commit-ID: 8eTF52vaAP2
browser/base/content/browser-addons.js
toolkit/components/extensions/ExtensionXPCShellUtils.jsm
toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js
toolkit/components/telemetry/Events.yaml
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
toolkit/mozapps/extensions/internal/XPIInstall.jsm
toolkit/mozapps/extensions/test/xpcshell/test_addon_manager_telemetry_events.js
toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
--- a/browser/base/content/browser-addons.js
+++ b/browser/base/content/browser-addons.js
@@ -292,17 +292,23 @@ var gXPInstallObserver = {
         callback() {
           secHistogram.add(Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED_CLICK_THROUGH);
           installInfo.install();
         }
       };
       let secondaryAction = {
         label: gNavigatorBundle.getString("xpinstallPromptMessage.dontAllow"),
         accessKey: gNavigatorBundle.getString("xpinstallPromptMessage.dontAllow.accesskey"),
-        callback: () => {},
+        callback: () => {
+          for (let install of installInfo.installs) {
+            if (install.state != AddonManager.STATE_CANCELLED) {
+              install.cancel();
+            }
+          }
+        },
       };
 
       secHistogram.add(Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED);
       let popup = PopupNotifications.show(browser, notificationID,
                                           messageString, anchorID,
                                           action, [secondaryAction], options);
       removeNotificationOnEnd(popup, installInfo.installs);
       break; }
--- a/toolkit/components/extensions/ExtensionXPCShellUtils.jsm
+++ b/toolkit/components/extensions/ExtensionXPCShellUtils.jsm
@@ -418,23 +418,24 @@ class ExtensionWrapper {
 
   onMessage(msg, callback) {
     this.checkDuplicateListeners(msg);
     this.messageHandler.set(msg, callback);
   }
 }
 
 class AOMExtensionWrapper extends ExtensionWrapper {
-  constructor(testScope, xpiFile, installType) {
+  constructor(testScope, xpiFile, installType, installTelemetryInfo) {
     super(testScope);
 
     this.onEvent = this.onEvent.bind(this);
 
     this.file = xpiFile;
     this.installType = installType;
+    this.installTelemetryInfo = installTelemetryInfo;
 
     this.cleanupFiles = [xpiFile];
 
     Management.on("ready", this.onEvent);
     Management.on("shutdown", this.onEvent);
     Management.on("startup", this.onEvent);
 
     AddonTestUtils.on("addon-manager-shutdown", this.onEvent);
@@ -561,17 +562,17 @@ class AOMExtensionWrapper extends Extens
         this.addon = addon;
 
         return this.startupPromise;
       }).catch(e => {
         this.state = "unloaded";
         return Promise.reject(e);
       });
     } else if (this.installType === "permanent") {
-      return AddonManager.getInstallForFile(xpiFile).then(install => {
+      return AddonManager.getInstallForFile(xpiFile, null, this.installTelemetryInfo).then(install => {
         let listener = {
           onInstallFailed: () => {
             this.state = "unloaded";
             this.resolveStartup(Promise.reject(new Error("Install failed")));
           },
           onInstallEnded: (install, newAddon) => {
             this.id = newAddon.id;
             this.addon = newAddon;
@@ -733,26 +734,26 @@ var ExtensionTestUtils = {
                                                          .QueryInterface(Ci.nsITimerCallback);
     manager.observe(null, "addons-startup", null);
   },
 
   loadExtension(data) {
     if (data.useAddonManager) {
       let xpiFile = Extension.generateXPI(data);
 
-      return this.loadExtensionXPI(xpiFile, data.useAddonManager);
+      return this.loadExtensionXPI(xpiFile, data.useAddonManager, data.amInstallTelemetryInfo);
     }
 
     let extension = Extension.generate(data);
 
     return new ExtensionWrapper(this.currentScope, extension);
   },
 
-  loadExtensionXPI(xpiFile, useAddonManager = "temporary") {
-    return new AOMExtensionWrapper(this.currentScope, xpiFile, useAddonManager);
+  loadExtensionXPI(xpiFile, useAddonManager = "temporary", installTelemetryInfo) {
+    return new AOMExtensionWrapper(this.currentScope, xpiFile, useAddonManager, installTelemetryInfo);
   },
 
   get remoteContentScripts() {
     return REMOTE_CONTENT_SCRIPTS;
   },
 
   set remoteContentScripts(val) {
     REMOTE_CONTENT_SCRIPTS = !!val;
--- a/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js
@@ -191,17 +191,19 @@ add_task(async function test_storage_loc
   await extension.awaitMessage("storage-local-data-migrated");
 
   // The histogram values are unmodified.
   assertMigrationHistogramCount("success", 1);
   assertMigrationHistogramCount("failure", 0);
 
   // No new telemetry events recorded for the extension.
   const snapshot = Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, true);
-  ok(!snapshot.parent || snapshot.parent.length === 0,
+  const filterByCategory = ([timestamp, category]) => category === EVENT_CATEGORY;
+
+  ok(!snapshot.parent || snapshot.parent.filter(filterByCategory).length === 0,
      "No telemetry events should be recorded for an already migrated extension");
 
   Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, false);
   Services.prefs.setBoolPref(LEAVE_UUID_PREF, false);
 
   await extension.unload();
 
   equal(Services.prefs.getPrefType(`${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`),
--- a/toolkit/components/telemetry/Events.yaml
+++ b/toolkit/components/telemetry/Events.yaml
@@ -64,16 +64,62 @@ activity_stream:
       - "msamuel@mozilla.com"
     expiry_version: never
     extra_keys:
       addon_version: The Activity Stream addon version.
       session_id: The ID of the Activity Stream session in which the event occurred
       page: about:home or about_newtab - the page where the event occurred
       user_prefs: An integer representaing a user's A-S settings.
 
+addonsManager:
+  install:
+    description: >
+      This events are recorded during the install and update flow for extensions and themes,
+      the value of the event is an install_id shared by the events related to the same install
+      or update flow.
+    objects: ["extension", "theme", "locale", "dictionary", "other", "unknown"]
+    methods: ["install", "update"]
+    extra_keys:
+      addon_id: A string which identify the extension (when available)
+      error: The AddonManager error related to an install or update failure.
+      perms_length: The number of permissions shown to the user in the extension permission doorhanger
+      origins_length: The number of origins shown to the user in the extension permission doorhanger
+      source: The source that originally triggered the add-on installation
+      method: >
+        The method used by the source to install the add-on (included when the source can use more than one,
+        e.g. install events with source "about:addons" may have "install-from-file" or "url" as method).
+      updated_from: Determine if an update has been requested by the user or the application ("app" / "user")
+      step: >
+        The current step in the install or update flow:
+          - started, postponed, cancelled, failed, permissions_prompt, completed
+          - site_warning, site_blocked, install_disabled_warning
+          - download_started, download_completed, download_failed
+    notification_emails: ["addons-dev-internal@mozilla.com"]
+    expiry_version: "68"
+    record_in_processes: ["main"]
+    bug_numbers: [1433335]
+    release_channel_collection: opt-out
+  manage:
+    description: >
+      This events are recorded when an installed add-ons is being disable/enabled/uninstalled,
+      the value of the event is the addon_id (which also allow to correlate multiple events
+      related to each other).
+    objects: ["extension", "theme", "locale", "dictionary", "other"]
+    methods: ["disable", "enable", "uninstall"]
+    extra_keys:
+      source: The source from which the addon has been installed
+      method: >
+        The method used by the source to install the add-on (included when the source can use more than one,
+        e.g. install events with source "about:addons" may have "install-from-file" or "url" as method).
+    notification_emails: ["addons-dev-internal@mozilla.com"]
+    expiry_version: "68"
+    record_in_processes: ["main"]
+    bug_numbers: [1433335]
+    release_channel_collection: opt-out
+
 extensions.data:
   migrateResult:
     objects: ["storageLocal"]
     bug_numbers: [1470213]
     notification_emails: ["addons-dev-internal@mozilla.com"]
     expiry_version: "70"
     record_in_processes: ["main"]
     release_channel_collection: opt-out
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -3523,15 +3523,408 @@ var AddonManager = {
     return AddonManagerInternal.webAPI;
   },
 
   get shutdown() {
     return gShutdownBarrier.client;
   },
 };
 
+/**
+ * Listens to the AddonManager install and addon events and send telemetry events.
+ */
+var AMTelemetry = {
+  initialized: false,
+
+  // Numeric id included in the install events to correlate multiple events related
+  // to the same install or update flow.
+  nextInstallId: 1,
+
+  // Keep track of extra telemetry information related to the AddonInstall objects:
+  //   WeakMap<AddonInstall -> object>
+  trackedInstallMap: new WeakMap(),
+
+  init() {
+    AddonManager.addManagerListener(this);
+  },
+
+  onStartup() {
+    this.setup();
+  },
+
+  async setup() {
+    if (this.initialized) {
+      return;
+    }
+
+    this.initialized = true;
+
+    Services.telemetry.setEventRecordingEnabled("addonsManager", true);
+
+    Services.obs.addObserver(this, "addon-install-origin-blocked");
+    Services.obs.addObserver(this, "addon-install-disabled");
+    Services.obs.addObserver(this, "addon-install-blocked");
+
+    AddonManager.addInstallListener(this);
+    AddonManager.addAddonListener(this);
+  },
+
+  // Observer Service notification callback.
+
+  observe(subject, topic, data) {
+    switch (topic) {
+      case "addon-install-blocked": {
+        const {installs} = subject.wrappedJSObject;
+        this.recordInstallEvent(installs[0], {step: "site_warning"});
+        break;
+      }
+      case "addon-install-origin-blocked": {
+        const {installs} = subject.wrappedJSObject;
+        this.recordInstallEvent(installs[0], {step: "site_blocked"});
+        break;
+      }
+      case "addon-install-disabled": {
+        const {installs} = subject.wrappedJSObject;
+        this.recordInstallEvent(installs[0], {step: "install_disabled_warning"});
+        break;
+      }
+    }
+  },
+
+  // AddonManager install listener callbacks.
+
+  onNewInstall(install) {
+    this.recordInstallEvent(install, {step: "started"});
+  },
+
+  onInstallCancelled(install) {
+    this.recordInstallEvent(install, {step: "cancelled"});
+  },
+
+  onInstallPostponed(install) {
+    this.recordInstallEvent(install, {step: "postponed"});
+  },
+
+  onInstallFailed(install) {
+    this.recordInstallEvent(install, {step: "failed"});
+  },
+
+  onInstallEnded(install) {
+    this.recordInstallEvent(install, {step: "completed"});
+  },
+
+  onDownloadStarted(install) {
+    this.recordInstallEvent(install, {step: "download_started"});
+  },
+
+  onDownloadCancelled(install) {
+    this.recordInstallEvent(install, {step: "cancelled"});
+  },
+
+  onDownloadEnded(install) {
+    this.recordInstallEvent(install, {step: "download_completed"});
+  },
+
+  onDownloadFailed(install) {
+    this.recordInstallEvent(install, {step: "download_failed"});
+  },
+
+  // Addon listeners callbacks.
+
+  onUninstalled(addon) {
+    this.recordManageEvent(addon, "uninstall");
+  },
+
+  onEnabled(addon) {
+    this.recordManageEvent(addon, "enable");
+  },
+
+  onDisabled(addon) {
+    this.recordManageEvent(addon, "disable");
+  },
+
+  // Internal helpers methods.
+
+  /**
+   * Get a trimmed version of the given string if it is longer than 80 chars.
+   *
+   * @param {string} str
+   *        The original string content.
+   *
+   * @returns {string}
+   *          The trimmed version of the string when longer than 80 chars, or the given string
+   *          unmodified otherwise.
+   */
+  getTrimmedString(str) {
+    if (str.length <= 80) {
+      return str;
+    }
+
+    const length = str.length;
+
+    // Trim the string to prevent a flood of warnings messages logged internally by recordEvent,
+    // the trimmed version is going to be composed by the first 40 chars and the last 37 and 3 dots
+    // that joins the two parts, to visually indicate that the string has been trimmed.
+    return `${str.slice(0, 40)}...${str.slice(length - 37, length)}`;
+  },
+
+  /**
+   * Retrieve the addonId for the given AddonInstall instance.
+   *
+   * @param {AddonInstall} install
+   *        The AddonInstall instance to retrieve the addonId from.
+   *
+   * @returns {string | null}
+   *          The addonId for the given AddonInstall instance (if any).
+   */
+  getAddonIdFromInstall(install) {
+    // Returns the id of the extension that is being installed, as soon as the
+    // addon is available in the AddonInstall instance (after being downloaded
+    // and validated successfully).
+    if (install.addon) {
+      return install.addon.id;
+    }
+
+    // While updating an addon, the existing addon can be
+    // used to retrieve the addon id since the first update event.
+    if (install.existingAddon) {
+      return install.existingAddon.id;
+    }
+
+    return null;
+  },
+
+  /**
+   * Retrieve the telemetry event object for the given AddonInstall instance.
+   *
+   * @param {AddonInstall} install
+   *        The AddonInstall instance to retrieve the event object from.
+   *
+   * @returns {string}
+   *          The object for the given AddonInstall instance.
+   */
+  getEventObjectFromInstall(install) {
+    let addonType;
+
+    if (install.type) {
+      addonType = install.type;
+    } else if (install.addon) {
+      addonType = install.addon.type;
+    } else if (install.existingAddon) {
+      addonType = install.existingAddon.type;
+    }
+
+    return this.getEventObjectFromAddonType(addonType);
+  },
+
+  /**
+   * Retrieve the telemetry event source for the given AddonInstall instance.
+   *
+   * @param {AddonInstall} install
+   *        The AddonInstall instance to retrieve the source from.
+   *
+   * @returns {string | null}
+   *          The source for the given AddonInstall instance.
+   */
+  getSourceFromInstall(install) {
+    if (install.installTelemetryInfo) {
+      return install.installTelemetryInfo.source;
+    } else if (install.existingAddon && install.existingAddon.installTelemetryInfo) {
+      // Get the install source from the existing addon (e.g. for an extension update).
+      return install.existingAddon.installTelemetryInfo.source;
+    }
+
+    return null;
+  },
+
+  /**
+   * Retrieve the telemetry event source method for the given AddonInstall instance.
+   *
+   * @param {AddonInstall} install
+   *        The AddonInstall instance to retrieve the source from.
+   *
+   * @returns {string | null}
+   *          The method used by the source for the given AddonInstall instance.
+   */
+  getMethodFromInstall(install) {
+    if (install.installTelemetryInfo) {
+      return install.installTelemetryInfo.method;
+    } else if (install.existingAddon && install.existingAddon.installTelemetryInfo) {
+      // Get the install method from the existing addon (e.g. for an extension update).
+      return install.existingAddon.installTelemetryInfo.method;
+    }
+
+    return null;
+  },
+
+  /**
+   * Get the telemetry event object for the given addon type
+   *
+   * @param {string} addonType
+   *        The addon type to convert into the related telemetry event object.
+   *
+   * @returns {string}
+   *          The object for the given addon type.
+   */
+  getEventObjectFromAddonType(addonType) {
+    switch (addonType) {
+    case undefined:
+      return "unknown";
+    case "extension":
+    case "theme":
+    case "locale":
+    case "dictionary":
+      return addonType;
+    default:
+      // Currently this should only include plugins and gmp-plugins
+      return "other";
+    }
+  },
+
+  /**
+   * Get the additional telemetry information related to a AddonInstall instance,
+   * if the instance is not yet tracked, it also assign its install id (used to correlate
+   * multiple telemetry events related to the same install or update flow) and the
+   * actual method ("install" for new addon installations and "update" for addon updates).
+   *
+   * @param {AddonInstall} install
+   *        The AddonInstall instance.
+   *
+   * @returns {object}
+   *          The object which contains the additional telemetry information for the given
+   *          AddonInstall instance:
+   *            - installId
+   *            - method, which can be "install" or "update"
+   */
+  getTrackedInstallData(install) {
+    let installData = this.trackedInstallMap.get(install);
+
+    if (!installData) {
+      // Keep track of some additional info related to install flow.
+      installData = {
+        // An numeric id used to correlate multiple telemetry events
+        // related to the same install or update flow.
+        installId: `${this.nextInstallId++}`,
+
+        // Detect the telemetry event method for this AddonInstall instance.
+        method: install.existingAddon ? "update" : "install",
+      };
+
+      this.trackedInstallMap.set(install, installData);
+    }
+
+    return installData;
+  },
+
+  /**
+   * Convert all the telemetry event's extra_vars into strings, if needed.
+   *
+   * @param {object} extraVars
+   */
+  formatExtraVars(extraVars) {
+    // All the extra_vars in a telemetry event have to be strings.
+    for (var key of Object.keys(extraVars)) {
+      if (typeof(extraVars[key]) !== "string") {
+        extraVars[key] = `${extraVars[key]}`;
+      }
+    }
+  },
+
+  /**
+   * Record an install or update event for the given AddonInstall instance.
+   *
+   * @param {AddonInstall} install
+   *        The AddonInstall instance to record an install or update event for.
+   * @param {object} extraVars
+   *        The additional extra_vars to include in the recorded event.
+   * @param {string} extraVars.step
+   *        The current step in the install or update flow.
+   * @param {string} extraVars.permission_length
+   *        The number of permissions and origins for the extensions.
+   */
+  recordInstallEvent(install, extraVars) {
+    let addonId = this.getAddonIdFromInstall(install);
+    let object = this.getEventObjectFromInstall(install);
+    let {installId, method} = this.getTrackedInstallData(install);
+
+    let extra = {};
+
+    let source = this.getSourceFromInstall(install);
+    if (typeof source === "string") {
+      extra.source = source;
+    }
+
+    // Also include the install source's method when applicable (e.g. install events with
+    // source "about:addons" may have "install-from-file" or "url" as their source method).
+    let sourceMethod = this.getMethodFromInstall(install);
+    if (typeof sourceMethod === "string") {
+      extra.method = sourceMethod;
+    }
+
+    if (addonId) {
+      extra.addon_id = this.getTrimmedString(addonId);
+    }
+
+    if (install.error) {
+      extra.error = AddonManager.errorToString(install.error);
+    }
+
+    if (method === "update") {
+      // For "update" telemetry events, also include an extra var which determine
+      // if the update has been requested by the user.
+      extra.updated_from = install.isUserRequestedUpdate ? "user" : "app";
+    }
+
+    // All the extra vars in a telemetry event have to be strings.
+    extra = {...extraVars, ...extra};
+    this.formatExtraVars(extra);
+
+    this.recordEvent({method, object, value: installId, extra});
+  },
+
+  /**
+   * Record a manage event for the given addon.
+   *
+   * @param {AddonWrapper} addon
+   *        The AddonWrapper instance.
+   * @param {object} extra
+   *        The additional extra_vars to include in the recorded event.
+   */
+  recordManageEvent(addon, method, extraVars) {
+    let object = this.getEventObjectFromAddonType(addon.type);
+    let value = this.getTrimmedString(addon.id);
+
+    let extra = {};
+
+    if (addon.installTelemetryInfo) {
+      if ("source" in addon.installTelemetryInfo) {
+        extra.source = addon.installTelemetryInfo.source;
+      }
+
+      // Also include the install source's method when applicable (e.g. install events with
+      // source "about:addons" may have "install-from-file" or "url" as their source method).
+      if ("method" in addon.installTelemetryInfo) {
+        extra.method = addon.installTelemetryInfo.method;
+      }
+    }
+
+    extra = {...extraVars, ...extra};
+
+    let hasExtraVars = Object.keys(extra).length > 0;
+    this.formatExtraVars(extra);
+
+    this.recordEvent({method, object, value, extra: hasExtraVars ? extra : null});
+  },
+
+  recordEvent({method, object, value, extra}) {
+    Services.telemetry.recordEvent("addonsManager", method, object, value, extra);
+  }
+};
+
 this.AddonManager.init();
 
+AMTelemetry.init();
+
 // load the timestamps module into AddonManagerInternal
 ChromeUtils.import("resource://gre/modules/TelemetryTimestamps.jsm", AddonManagerInternal);
 Object.freeze(AddonManagerInternal);
 Object.freeze(AddonManagerPrivate);
 Object.freeze(AddonManager);
--- a/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
+++ b/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
@@ -1363,21 +1363,24 @@ var AddonTestUtils = {
   /**
    * A helper method to install a file.
    *
    * @param {nsIFile} file
    *        The file to install
    * @param {boolean} [ignoreIncompatible = false]
    *        Optional parameter to ignore add-ons that are incompatible
    *        with the application
+   * @param {Object} [installTelemetryInfo = undefined]
+   *        Optional parameter to set the install telemetry info for the
+   *        installed addon
    * @returns {Promise}
    *        Resolves when the install has completed.
    */
-  async promiseInstallFile(file, ignoreIncompatible = false) {
-    let install = await AddonManager.getInstallForFile(file);
+  async promiseInstallFile(file, ignoreIncompatible = false, installTelemetryInfo) {
+    let install = await AddonManager.getInstallForFile(file, null, installTelemetryInfo);
     if (!install)
       throw new Error(`No AddonInstall created for ${file.path}`);
 
     if (install.state != AddonManager.STATE_DOWNLOADED)
       throw new Error(`Expected file to be downloaded for install of ${file.path}`);
 
     if (ignoreIncompatible && install.addon.appDisabled)
       return null;
--- a/toolkit/mozapps/extensions/internal/XPIInstall.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm
@@ -1641,16 +1641,18 @@ class AddonInstall {
    */
   checkPrompt() {
     (async () => {
       if (this.promptHandler) {
         let info = {
           existingAddon: this.existingAddon ? this.existingAddon.wrapper : null,
           addon: this.addon.wrapper,
           icon: this.getIcon(),
+          // Used in AMTelemetry to detect the install flow related to this prompt.
+          install: this.wrapper,
         };
 
         try {
           await this.promptHandler(info);
         } catch (err) {
           logger.info(`Install of ${this.addon.id} cancelled by user`);
           this.state = AddonManager.STATE_CANCELLED;
           XPIInstall.installs.delete(this);
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_addon_manager_telemetry_events.js
@@ -0,0 +1,343 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+ChromeUtils.import("resource://gre/modules/TelemetryController.jsm");
+
+// We don't have an easy way to serve update manifests from a secure URL.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+const EVENT_CATEGORY = "addonsManager";
+const EVENT_METHODS_INSTALL = ["install", "update"];
+const EVENT_METHODS_MANAGE = [
+  "disable", "enable", "uninstall",
+];
+const EVENT_METHODS = [...EVENT_METHODS_INSTALL, ...EVENT_METHODS_MANAGE];
+
+const FAKE_INSTALL_TELEMETRY_INFO = {
+  source: "fake-install-source",
+  method: "fake-install-method",
+};
+
+function getTelemetryEvents(includeMethods = EVENT_METHODS) {
+  const snapshot = Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, true);
+
+  ok(snapshot.parent && snapshot.parent.length > 0, "Got parent telemetry events in the snapshot");
+
+  return snapshot.parent.filter(([timestamp, category, method]) => {
+    const includeMethod = includeMethods ?
+          includeMethods.includes(method) : true;
+
+    return category === EVENT_CATEGORY && includeMethod;
+  }).map(event => {
+    return {method: event[2], object: event[3], value: event[4], extra: event[5]};
+  });
+}
+
+add_task(async function setup() {
+  createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+  // Telemetry test setup needed to ensure that the builtin events are defined
+  // and they can be collected and verified.
+  await TelemetryController.testSetup();
+
+  await promiseStartupManager();
+});
+
+// Test the basic install and management flows.
+add_task(async function test_basic_telemetry_events() {
+  const EXTENSION_ID = "basic@test.extension";
+
+  const manifest = {
+    applications: {gecko: {id: EXTENSION_ID}},
+  };
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest,
+    useAddonManager: "permanent",
+    amInstallTelemetryInfo: FAKE_INSTALL_TELEMETRY_INFO,
+  });
+
+  await extension.startup();
+
+  const addon = await promiseAddonByID(EXTENSION_ID);
+
+  info("Disabling the extension");
+  const onceAddonDisabled = promiseAddonEvent("onDisabled");
+  addon.disable();
+  await onceAddonDisabled;
+
+  info("Set pending uninstall on the extension");
+  const onceAddonUninstalling = promiseAddonEvent("onUninstalling");
+  addon.uninstall(true);
+  await onceAddonUninstalling;
+
+  info("Cancel pending uninstall");
+  const oncePendingUninstallCancelled = promiseAddonEvent("onOperationCancelled");
+  addon.cancelUninstall();
+  await oncePendingUninstallCancelled;
+
+  info("Re-enabling the extension");
+  const onceAddonStarted = promiseWebExtensionStartup(EXTENSION_ID);
+  const onceAddonEnabled = promiseAddonEvent("onEnabled");
+  addon.enable();
+  await Promise.all([onceAddonEnabled, onceAddonStarted]);
+
+  await extension.unload();
+
+  const amEvents = getTelemetryEvents();
+
+  const amMethods = amEvents.map(evt => evt.method);
+  const expectedMethods = [
+    // These two install methods are related to the steps "started" and "completed".
+    "install", "install",
+    // Sequence of disable and enable (pending uninstall and undo uninstall are not going to
+    // record any telemetry events).
+    "disable", "enable",
+    // The final "uninstall" when the test extension is unloaded.
+    "uninstall",
+  ];
+  Assert.deepEqual(amMethods, expectedMethods, "Got the addonsManager telemetry events in the expected order");
+
+  const installEvents = amEvents.filter(evt => evt.method === "install");
+  const expectedInstallEvents = [
+    {
+      method: "install", object: "extension", value: "1",
+      extra: {
+        addon_id: "basic@test.extension",
+        step: "started",
+        ...FAKE_INSTALL_TELEMETRY_INFO,
+      },
+    },
+    {
+      method: "install", object: "extension", value: "1",
+      extra: {
+        addon_id: "basic@test.extension",
+        step: "completed",
+        ...FAKE_INSTALL_TELEMETRY_INFO,
+      },
+    },
+  ];
+  Assert.deepEqual(installEvents, expectedInstallEvents, "Got the expected addonsManager.install events");
+
+  const manageEvents = amEvents.filter(evt => EVENT_METHODS_MANAGE.includes(evt.method));
+  const expectedExtra = FAKE_INSTALL_TELEMETRY_INFO;
+  const expectedManageEvents = [
+    {
+      method: "disable", object: "extension", value: "basic@test.extension", extra: expectedExtra,
+    },
+    {
+      method: "enable", object: "extension", value: "basic@test.extension", extra: expectedExtra,
+    },
+    {
+      method: "uninstall", object: "extension", value: "basic@test.extension", extra: expectedExtra,
+    },
+  ];
+  Assert.deepEqual(manageEvents, expectedManageEvents, "Got the expected addonsManager.manage events");
+
+  // Verify that on every install flow, the value of the addonsManager.install Telemetry events
+  // is being incremented.
+
+  extension = ExtensionTestUtils.loadExtension({
+    manifest,
+    useAddonManager: "permanent",
+    amInstallTelemetryInfo: FAKE_INSTALL_TELEMETRY_INFO,
+  });
+
+  await extension.startup();
+  await extension.unload();
+
+  const eventsFromNewInstall = getTelemetryEvents();
+  equal(eventsFromNewInstall.length, 3, "Got the expected number of addonsManager install events");
+
+  const eventValues = eventsFromNewInstall.filter(evt => evt.method === "install").map(evt => evt.value);
+  const expectedValues = ["2", "2"];
+  Assert.deepEqual(eventValues, expectedValues, "Got the expected install id");
+});
+
+add_task(async function test_update_telemetry_events() {
+  const EXTENSION_ID = "basic@test.extension";
+
+  const testserver = AddonTestUtils.createHttpServer({hosts: ["example.com"]});
+  testserver.registerDirectory("/data/", do_get_file("data"));
+
+  const updateUrl = `http://example.com/updates.json`;
+
+  const testAddon = AddonTestUtils.createTempWebExtensionFile({
+    manifest: {
+      version: "1.0",
+      applications: {
+        gecko: {
+          id: EXTENSION_ID,
+          update_url: updateUrl,
+        },
+      },
+    },
+  });
+
+  const testUserRequestedUpdate = AddonTestUtils.createTempWebExtensionFile({
+    manifest: {
+      version: "2.0",
+      applications: {
+        gecko: {
+          id: EXTENSION_ID,
+          update_url: updateUrl,
+        },
+      },
+    },
+  });
+  const testAppRequestedUpdate = AddonTestUtils.createTempWebExtensionFile({
+    manifest: {
+      version: "2.1",
+      applications: {
+        gecko: {
+          id: EXTENSION_ID,
+          update_url: updateUrl,
+        },
+      },
+    },
+  });
+
+  testserver.registerFile(`/addons/${EXTENSION_ID}-2.0.xpi`, testUserRequestedUpdate);
+  testserver.registerFile(`/addons/${EXTENSION_ID}-2.1.xpi`, testAppRequestedUpdate);
+
+  let updates = [
+    {
+      "version": "2.0",
+      "update_link": `http://example.com/addons/${EXTENSION_ID}-2.0.xpi`,
+      "applications": {
+        "gecko": {
+          "strict_min_version": "1",
+        },
+      },
+    },
+  ];
+
+  testserver.registerPathHandler("/updates.json", (request, response) => {
+    response.write(`{
+      "addons": {
+        "${EXTENSION_ID}": {
+          "updates": ${JSON.stringify(updates)}
+        }
+      }
+    }`);
+  });
+
+  await promiseInstallFile(testAddon, false, FAKE_INSTALL_TELEMETRY_INFO);
+
+  let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+
+  // User requested update.
+  await promiseFindAddonUpdates(addon, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+  let installs = await AddonManager.getAllInstalls();
+  await promiseCompleteAllInstalls(installs);
+
+  updates = [
+    {
+      "version": "2.1",
+      "update_link": `http://example.com/addons/${EXTENSION_ID}-2.1.xpi`,
+      "applications": {
+        "gecko": {
+          "strict_min_version": "1",
+        },
+      },
+    },
+  ];
+
+  // App requested update.
+  await promiseFindAddonUpdates(addon, AddonManager.UPDATE_WHEN_PERIODIC_UPDATE);
+  let installs2 = await AddonManager.getAllInstalls();
+  await promiseCompleteAllInstalls(installs2);
+
+  updates = [
+    {
+      "version": "2.1.1",
+      "update_link": `http://example.com/addons/${EXTENSION_ID}-2.1.1-network-failure.xpi`,
+      "applications": {
+        "gecko": {
+          "strict_min_version": "1",
+        },
+      },
+    },
+  ];
+
+  // Update which fails to download.
+  await promiseFindAddonUpdates(addon, AddonManager.UPDATE_WHEN_PERIODIC_UPDATE);
+  let installs3 = await AddonManager.getAllInstalls();
+  await promiseCompleteAllInstalls(installs3);
+
+  let amEvents = getTelemetryEvents();
+
+  const installEvents = amEvents.filter(evt => evt.method === "install").map(evt => {
+    delete evt.value;
+    return evt;
+  });
+
+  const addon_id = "basic@test.extension";
+  const object = "extension";
+
+  Assert.deepEqual(installEvents, [
+    {method: "install", object, extra: {addon_id, step: "started", ...FAKE_INSTALL_TELEMETRY_INFO}},
+    {method: "install", object, extra: {addon_id, step: "completed", ...FAKE_INSTALL_TELEMETRY_INFO}},
+  ], "Got the expected addonsManager.install events");
+
+  const updateEvents = amEvents.filter(evt => evt.method === "update").map(evt => {
+    delete evt.value;
+    return evt;
+  });
+
+  const method = "update";
+  const baseExtra = FAKE_INSTALL_TELEMETRY_INFO;
+
+  const expectedUpdateEvents = [
+    // User-requested update to the 2.1 version.
+    {
+      method, object, extra: {...baseExtra, addon_id, step: "started", updated_from: "user"},
+    },
+    {
+      method, object, extra: {...baseExtra, addon_id, step: "download_started", updated_from: "user"},
+    },
+    {
+      method, object, extra: {...baseExtra, addon_id, step: "download_completed", updated_from: "user"},
+    },
+    {
+      method, object, extra: {...baseExtra, addon_id, step: "completed", updated_from: "user"},
+    },
+    // App-requested update to the 2.1 version.
+    {
+      method, object, extra: {...baseExtra, addon_id, step: "started", updated_from: "app"},
+    },
+    {
+      method, object, extra: {...baseExtra, addon_id, step: "download_started", updated_from: "app"},
+    },
+    {
+      method, object, extra: {...baseExtra, addon_id, step: "download_completed", updated_from: "app"},
+    },
+    {
+      method, object, extra: {...baseExtra, addon_id, step: "completed", updated_from: "app"},
+    },
+    // Broken update to the 2.1.1 version (on ERROR_NETWORK_FAILURE).
+    {
+      method, object, extra: {...baseExtra, addon_id, step: "started", updated_from: "app"},
+    },
+    {
+      method, object, extra: {...baseExtra, addon_id, step: "download_started", updated_from: "app"},
+    },
+    {
+      method, object, extra: {
+        ...baseExtra,
+        addon_id,
+        error: "ERROR_NETWORK_FAILURE",
+        step: "download_failed",
+        updated_from: "app",
+      },
+    },
+  ];
+  Assert.deepEqual(updateEvents, expectedUpdateEvents, "Got the expected addonsManager.update events");
+
+  await addon.uninstall();
+});
+
+add_task(async function teardown() {
+  await TelemetryController.testShutdown();
+});
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
@@ -2,16 +2,17 @@
 skip-if = toolkit == 'android'
 tags = addons
 head = head_addons.js
 firefox-appdir = browser
 dupe-manifest =
 support-files =
   data/**
 
+[test_addon_manager_telemetry_events.js]
 [test_AddonRepository.js]
 [test_AddonRepository_cache.js]
 # Bug 676992: test consistently hangs on Android
 skip-if = os == "android"
 [test_AddonRepository_paging.js]
 [test_LightweightThemeManager.js]
 [test_ProductAddonChecker.js]
 [test_XPIStates.js]