Bug 1433334 - Store the install source of the extensions for telemetry. draft
authorLuca Greco <lgreco@mozilla.com>
Tue, 29 May 2018 16:47:47 +0200
changeset 830169 1f72c2f1dc9af03f6ac1d396b87d3e0322a2761d
parent 820804 4f12d77b4f9b6adaf06615c1c8cdc14de836dc1a
child 830170 f15ffb73681ff4fd3124772cb045f8575fcb92c1
push id118821
push userluca.greco@alcacoop.it
push dateMon, 20 Aug 2018 14:39:19 +0000
bugs1433334
milestone63.0a1
Bug 1433334 - Store the install source of the extensions for telemetry. MozReview-Commit-ID: AWGzx5UIkGF
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/LightweightThemeManager.jsm
toolkit/mozapps/extensions/addonManager.js
toolkit/mozapps/extensions/amContentHandler.js
toolkit/mozapps/extensions/amInstallTrigger.js
toolkit/mozapps/extensions/amWebAPI.js
toolkit/mozapps/extensions/content/extensions.js
toolkit/mozapps/extensions/internal/GMPProvider.jsm
toolkit/mozapps/extensions/internal/PluginProvider.jsm
toolkit/mozapps/extensions/internal/XPIDatabase.jsm
toolkit/mozapps/extensions/internal/XPIInstall.jsm
toolkit/mozapps/extensions/test/xpcshell/test_getInstallSourceFromPrincipal.js
toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -1581,20 +1581,23 @@ var AddonManagerInternal = {
    * @param  aName
    *         An optional placeholder name while the add-on is being downloaded
    * @param  aIcons
    *         Optional placeholder icons while the add-on is being downloaded
    * @param  aVersion
    *         An optional placeholder version while the add-on is being downloaded
    * @param  aBrowser
    *         An optional <browser> element for download permissions prompts.
+   * @param  aTelemetryInfo
+   *         An optional object which provides details about the installation source
+   *         included in the addon manager telemetry events.
    * @throws if the aUrl, aCallback or aMimetype arguments are not specified
    */
   getInstallForURL(aUrl, aMimetype, aHash, aName,
-                   aIcons, aVersion, aBrowser) {
+                   aIcons, aVersion, aBrowser, aTelemetryInfo) {
     if (!gStarted)
       throw Components.Exception("AddonManager is not initialized",
                                  Cr.NS_ERROR_NOT_INITIALIZED);
 
     if (!aUrl || typeof aUrl != "string")
       throw Components.Exception("aURL must be a non-empty string",
                                  Cr.NS_ERROR_INVALID_ARG);
 
@@ -1627,49 +1630,52 @@ var AddonManagerInternal = {
     if (aBrowser && !Element.isInstance(aBrowser))
       throw Components.Exception("aBrowser must be an Element or null",
                                  Cr.NS_ERROR_INVALID_ARG);
 
     for (let provider of this.providers) {
       if (callProvider(provider, "supportsMimetype", false, aMimetype)) {
         return promiseCallProvider(
           provider, "getInstallForURL", aUrl, aHash, aName, aIcons,
-          aVersion, aBrowser);
+          aVersion, aBrowser, aTelemetryInfo);
       }
     }
 
     return Promise.resolve(null);
   },
 
   /**
    * Asynchronously gets an AddonInstall for an nsIFile.
    *
    * @param  aFile
    *         The nsIFile where the add-on is located
    * @param  aMimetype
    *         An optional mimetype hint for the add-on
+   * @param  aTelemetryInfo
+   *         An optional object which provides details about the installation source
+   *         included in the addon manager telemetry events.
    * @throws if the aFile or aCallback arguments are not specified
    */
-  getInstallForFile(aFile, aMimetype) {
+  getInstallForFile(aFile, aMimetype, aTelemetryInfo) {
     if (!gStarted)
       throw Components.Exception("AddonManager is not initialized",
                                  Cr.NS_ERROR_NOT_INITIALIZED);
 
     if (!(aFile instanceof Ci.nsIFile))
       throw Components.Exception("aFile must be a nsIFile",
                                  Cr.NS_ERROR_INVALID_ARG);
 
     if (aMimetype && typeof aMimetype != "string")
       throw Components.Exception("aMimetype must be a string or null",
                                  Cr.NS_ERROR_INVALID_ARG);
 
     return (async () => {
       for (let provider of this.providers) {
         let install = await promiseCallProvider(
-          provider, "getInstallForFile", aFile);
+          provider, "getInstallForFile", aFile, aTelemetryInfo);
 
         if (install)
           return install;
       }
 
       return null;
     })();
   },
@@ -2710,17 +2716,23 @@ var AddonManagerInternal = {
       }
 
       try {
         checkInstallUrl(options.url);
       } catch (err) {
         return Promise.reject({message: err.message});
       }
 
-      return AddonManagerInternal.getInstallForURL(options.url, "application/x-xpinstall", options.hash)
+      let installTelemetryInfo = {
+        source: AddonManager.getInstallSourceFromHost(options.installSourceHost),
+        method: "amWebAPI",
+      };
+
+      return AddonManagerInternal.getInstallForURL(options.url, "application/x-xpinstall", options.hash,
+                                                   null, null, null, null, installTelemetryInfo)
                                  .then(install => {
         AddonManagerInternal.setupPromptHandler(target, null, install, false, "AMO");
 
         let id = this.nextInstall++;
         let {listener, installPromise} = this.makeListener(id, target.messageManager);
         install.addListener(listener);
 
         this.installs.set(id, {install, target, listener, installPromise});
@@ -3006,16 +3018,22 @@ var AddonManagerPrivate = {
 };
 
 /**
  * This is the public API that UI and developers should be calling. All methods
  * just forward to AddonManagerInternal.
  * @class
  */
 var AddonManager = {
+  _installHostSource: new Map([
+    ["addons.mozilla.org", "amo"],
+    ["discovery.addons.mozilla.org", "disco"],
+    ["testpilot.firefox.com", "testpilot"],
+  ]),
+
   // Constants for the AddonInstall.state property
   // These will show up as AddonManager.STATE_* (eg, STATE_AVAILABLE)
   _states: new Map([
     // The install is available for download.
     ["STATE_AVAILABLE",  0],
     // The install is being downloaded.
     ["STATE_DOWNLOADING",  1],
     // The install is checking for compatibility information.
@@ -3265,24 +3283,58 @@ var AddonManager = {
   stateToString(state) {
     return this._stateToString.get(state);
   },
 
   errorToString(err) {
     return err ? this._errorToString.get(err) : null;
   },
 
+  getInstallSourceFromHost(host) {
+    if (this._installHostSource.has(host)) {
+      return this._installHostSource.get(host);
+    }
+
+    if (WEBAPI_TEST_INSTALL_HOSTS.includes(host)) {
+      return "test-hosts";
+    }
+
+    return "unknown";
+  },
+
+  getInstallSourceFromPrincipal(principal) {
+    if (!principal) {
+      return "no-principal";
+    }
+
+    if (principal.isSystemPrincipal) {
+      return "system-principal";
+    }
+
+    if (principal.isNullPrincipal) {
+      return "null-principal";
+    }
+
+    if (principal.isAddonOrExpandedAddonPrincipal) {
+      return "webextension";
+    }
+
+    let host = principal && principal.URI && principal.URI.host;
+
+    return this.getInstallSourceFromHost(host);
+  },
+
   getInstallForURL(aUrl, aMimetype, aHash, aName, aIcons,
-                   aVersion, aBrowser) {
-    return AddonManagerInternal.getInstallForURL(aUrl, aMimetype, aHash,
-                                                 aName, aIcons, aVersion, aBrowser);
+                   aVersion, aBrowser, aTelemetryInfo) {
+    return AddonManagerInternal.getInstallForURL(
+      aUrl, aMimetype, aHash, aName, aIcons, aVersion, aBrowser, aTelemetryInfo);
   },
 
-  getInstallForFile(aFile, aMimetype) {
-      return AddonManagerInternal.getInstallForFile(aFile, aMimetype);
+  getInstallForFile(aFile, aMimetype, aTelemetryInfo) {
+    return AddonManagerInternal.getInstallForFile(aFile, aMimetype, aTelemetryInfo);
   },
 
   /**
    * Gets an array of add-on IDs that changed during the most recent startup.
    *
    * @param  aType
    *         The type of startup change to get
    * @return An array of add-on IDs
--- a/toolkit/mozapps/extensions/LightweightThemeManager.jsm
+++ b/toolkit/mozapps/extensions/LightweightThemeManager.jsm
@@ -115,16 +115,20 @@ var LightweightThemeManager = {
     }
     return _fallbackThemeData;
   },
 
   // Themes that can be added for an application.  They can't be removed, and
   // will always show up at the top of the list.
   _builtInThemes: new Map(),
 
+  isBuiltIn(theme) {
+    return this._builtInThemes.has(theme.id);
+  },
+
   get usedThemes() {
     let themes = [];
     try {
       themes = JSON.parse(_prefs.getStringPref("usedThemes"));
     } catch (e) { }
 
     themes.push(...this._builtInThemes.values());
     return themes;
@@ -677,16 +681,24 @@ AddonWrapper.prototype = {
   get scope() {
     return AddonManager.SCOPE_PROFILE;
   },
 
   get foreignInstall() {
     return false;
   },
 
+  get installTelemetryInfo() {
+    if (LightweightThemeManager.isBuiltIn(themeFor(this))) {
+      return {source: "builtin-theme"};
+    }
+
+    return {source: "lightweight-theme"};
+  },
+
   uninstall() {
     LightweightThemeManager.forgetUsedTheme(themeFor(this).id);
   },
 
   cancelUninstall() {
     throw new Error("Theme is not marked to be uninstalled");
   },
 
--- a/toolkit/mozapps/extensions/addonManager.js
+++ b/toolkit/mozapps/extensions/addonManager.js
@@ -68,62 +68,80 @@ amManager.prototype = {
 
       case "message-manager-close":
       case "message-manager-disconnect":
         this.childClosed(aSubject);
         break;
     }
   },
 
-  installAddonFromWebpage(aMimetype, aBrowser, aInstallingPrincipal,
-                          aUri, aHash, aName, aIcon, aCallback) {
+  installAddonFromWebpage(aPayload, aBrowser, aCallback) {
     let retval = true;
-    if (!AddonManager.isInstallAllowed(aMimetype, aInstallingPrincipal)) {
+
+    const {
+      mimetype,
+      triggeringPrincipal,
+      hash,
+      icon,
+      name,
+      uri
+    } = aPayload;
+
+    if (!AddonManager.isInstallAllowed(mimetype, triggeringPrincipal)) {
       aCallback = null;
       retval = false;
     }
 
-    AddonManager.getInstallForURL(aUri, aMimetype, aHash, aName, aIcon, null, aBrowser).then(aInstall => {
-      function callCallback(uri, status) {
+    let installTelemetryInfo = {
+      source: AddonManager.getInstallSourceFromPrincipal(triggeringPrincipal),
+    };
+
+    if ("installMethod" in aPayload) {
+      installTelemetryInfo.method = aPayload.installMethod;
+    }
+
+    AddonManager.getInstallForURL(uri, mimetype, hash, name, icon, null, aBrowser,
+                                  installTelemetryInfo).then(aInstall => {
+      function callCallback(status) {
         try {
           aCallback.onInstallEnded(uri, status);
         } catch (e) {
           Cu.reportError(e);
         }
       }
 
       if (!aInstall) {
-        aCallback.onInstallEnded(aUri, UNSUPPORTED_TYPE);
+        aCallback.onInstallEnded(uri, UNSUPPORTED_TYPE);
         return;
       }
 
       if (aCallback) {
         aInstall.addListener({
           onDownloadCancelled(aInstall) {
-            callCallback(aUri, USER_CANCELLED);
+            callCallback(USER_CANCELLED);
           },
 
           onDownloadFailed(aInstall) {
             if (aInstall.error == AddonManager.ERROR_CORRUPT_FILE)
-              callCallback(aUri, CANT_READ_ARCHIVE);
+              callCallback(CANT_READ_ARCHIVE);
             else
-              callCallback(aUri, DOWNLOAD_ERROR);
+              callCallback(DOWNLOAD_ERROR);
           },
 
           onInstallFailed(aInstall) {
-            callCallback(aUri, EXECUTION_ERROR);
+            callCallback(EXECUTION_ERROR);
           },
 
           onInstallEnded(aInstall, aStatus) {
-            callCallback(aUri, SUCCESS);
+            callCallback(SUCCESS);
           }
         });
       }
 
-      AddonManager.installAddonFromWebpage(aMimetype, aBrowser, aInstallingPrincipal, aInstall);
+      AddonManager.installAddonFromWebpage(mimetype, aBrowser, triggeringPrincipal, aInstall);
     });
 
     return retval;
   },
 
   notify(aTimer) {
     AddonManagerPrivate.backgroundUpdateTimerHandler();
   },
@@ -183,19 +201,17 @@ amManager.prototype = {
                 callbackID: payload.callbackID,
                 url,
                 status
               });
             },
           };
         }
 
-        return this.installAddonFromWebpage(payload.mimetype,
-          aMessage.target, payload.triggeringPrincipal, payload.uri,
-          payload.hash, payload.name, payload.icon, callback);
+        return this.installAddonFromWebpage(payload, aMessage.target, callback);
       }
 
       case MSG_PROMISE_REQUEST: {
         let mm = aMessage.target.messageManager;
         let resolve = (value) => {
           mm.sendAsyncMessage(MSG_PROMISE_RESULT, {
             callbackID: payload.callbackID,
             resolve: value
--- a/toolkit/mozapps/extensions/amContentHandler.js
+++ b/toolkit/mozapps/extensions/amContentHandler.js
@@ -44,17 +44,18 @@ amContentHandler.prototype = {
 
     let install = {
       uri: uri.spec,
       hash: null,
       name: null,
       icon: null,
       mimetype: XPI_CONTENT_TYPE,
       triggeringPrincipal: aRequest.loadInfo.triggeringPrincipal,
-      callbackID: -1
+      callbackID: -1,
+      installMethod: "url",
     };
 
     if (Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) {
       // When running in the main process this might be a frame inside an
       // in-content UI page, walk up to find the first frame element in a chrome
       // privileged document
       let element = window.frameElement;
       let ssm = Services.scriptSecurityManager;
--- a/toolkit/mozapps/extensions/amInstallTrigger.js
+++ b/toolkit/mozapps/extensions/amInstallTrigger.js
@@ -174,16 +174,17 @@ InstallTrigger.prototype = {
       }
     }
 
     let installData = {
       uri: url.spec,
       hash: item.Hash || null,
       name: item.name,
       icon: iconUrl ? iconUrl.spec : null,
+      installMethod: "installTrigger",
     };
 
     return this._mediator.install(installData, this._principal, callback, this._window);
   },
 
   startSoftwareUpdate(url, flags) {
     let filename = Services.io.newURI(url)
                               .QueryInterface(Ci.nsIURL)
--- a/toolkit/mozapps/extensions/amWebAPI.js
+++ b/toolkit/mozapps/extensions/amWebAPI.js
@@ -224,17 +224,25 @@ class WebAPI extends APIObject {
         return null;
       }
       let addon = new Addon(this.window, this.broker, addonInfo);
       return this.window.Addon._create(this.window, addon);
     });
   }
 
   createInstall(options) {
-    return this._apiTask("createInstall", [options], installInfo => {
+    let installOptions = {
+      ...options,
+      // Provide the documentPrincipal from which the amWebAPI is being called
+      // (so that we can detect if the API is being used from the disco pane,
+      // AMO, testpilot or another unknown webpage).
+      installSourceHost: this.window.document.nodePrincipal.URI &&
+        this.window.document.nodePrincipal.URI.host,
+    };
+    return this._apiTask("createInstall", [installOptions], installInfo => {
       if (!installInfo) {
         return null;
       }
       let install = new AddonInstall(this.window, this.broker, installInfo);
       this.allInstalls.push(installInfo.id);
       return this.window.AddonInstall._create(this.window, install);
     });
   }
--- a/toolkit/mozapps/extensions/content/extensions.js
+++ b/toolkit/mozapps/extensions/content/extensions.js
@@ -1245,21 +1245,26 @@ var gViewController = {
                           "*.xpi;*.jar;*.zip");
           fp.appendFilters(nsIFilePicker.filterAll);
         } catch (e) { }
 
         fp.open(async result => {
           if (result != nsIFilePicker.returnOK)
             return;
 
+          let installTelemetryInfo = {
+            source: "about:addons",
+            method: "install-from-file",
+          };
+
           let browser = getBrowserElement();
           let files = fp.files;
           while (files.hasMoreElements()) {
             let file = files.getNext();
-            let install = await AddonManager.getInstallForFile(file);
+            let install = await AddonManager.getInstallForFile(file, null, installTelemetryInfo);
             AddonManager.installAddonFromAOM(browser, document.documentURIObject, install);
           }
         });
       }
     },
 
     cmd_debugAddons: {
       isEnabled() {
@@ -3439,17 +3444,21 @@ var gDragDrop = {
       } else {
         let file = dataTransfer.mozGetDataAt("application/x-moz-file", i);
         if (file) {
           url = Services.io.newFileURI(file).spec;
         }
       }
 
       if (url) {
-        let install = await AddonManager.getInstallForURL(url, "application/x-xpinstall");
+        let install = await AddonManager.getInstallForURL(url, "application/x-xpinstall",
+                                                          null, null, null, null, null, {
+                                                            source: "about:addons",
+                                                            method: "url",
+                                                          });
         AddonManager.installAddonFromAOM(browser, document.documentURIObject, install);
       }
     }
   }
 };
 
 // Stub tabbrowser implementation for use by the tab-modal alert code
 // when an alert/prompt/confirm method is called in a WebExtensions options_ui page
--- a/toolkit/mozapps/extensions/internal/GMPProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/GMPProvider.jsm
@@ -234,16 +234,20 @@ GMPWrapper.prototype = {
   get providesUpdatesSecurely() {
     return true;
   },
 
   get foreignInstall() {
     return false;
   },
 
+  get installTelemetryInfo() {
+    return {source: "gmp-plugin"};
+  },
+
   isCompatibleWith(aAppVersion, aPlatformVersion) {
     return true;
   },
 
   get applyBackgroundUpdates() {
     if (!GMPPrefs.isSet(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, this._plugin.id)) {
       return AddonManager.AUTOUPDATE_DEFAULT;
     }
--- a/toolkit/mozapps/extensions/internal/PluginProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/PluginProvider.jsm
@@ -499,16 +499,20 @@ PluginWrapper.prototype = {
   get providesUpdatesSecurely() {
     return true;
   },
 
   get foreignInstall() {
     return true;
   },
 
+  get installTelemetryInfo() {
+    return {source: "plugin"};
+  },
+
   isCompatibleWith(aAppVersion, aPlatformVersion) {
     return true;
   },
 
   findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) {
     if ("onNoCompatibilityUpdateAvailable" in aListener)
       aListener.onNoCompatibilityUpdateAvailable(this);
     if ("onNoUpdateAvailable" in aListener)
--- a/toolkit/mozapps/extensions/internal/XPIDatabase.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIDatabase.jsm
@@ -89,17 +89,18 @@ const KEY_APP_PROFILE                 = 
 const KEY_APP_TEMPORARY               = "app-temporary";
 
 const DEFAULT_THEME_ID = "default-theme@mozilla.org";
 
 // Properties to cache and reload when an addon installation is pending
 const PENDING_INSTALL_METADATA =
     ["syncGUID", "targetApplications", "userDisabled", "softDisabled",
      "existingAddonID", "sourceURI", "releaseNotesURI", "installDate",
-     "updateDate", "applyBackgroundUpdates", "compatibilityOverrides"];
+     "updateDate", "applyBackgroundUpdates", "compatibilityOverrides",
+     "installTelemetryInfo"];
 
 const COMPATIBLE_BY_DEFAULT_TYPES = {
   extension: true,
   dictionary: true,
   "webextension-dictionary": true,
 };
 
 // Properties to save in JSON file
@@ -111,17 +112,17 @@ const PROP_JSON_FIELDS = ["id", "syncGUI
                           "updateDate", "applyBackgroundUpdates", "path",
                           "skinnable", "sourceURI", "releaseNotesURI",
                           "softDisabled", "foreignInstall",
                           "strictCompatibility", "locales", "targetApplications",
                           "targetPlatforms", "signedState",
                           "seen", "dependencies", "hasEmbeddedWebExtension",
                           "userPermissions", "icons", "iconURL", "icon64URL",
                           "blocklistState", "blocklistURL", "startupData",
-                          "previewImage"];
+                          "previewImage", "installTelemetryInfo"];
 
 const LEGACY_TYPES = new Set([
   "extension",
 ]);
 
 // Some add-on types that we track internally are presented as other types
 // externally
 const TYPE_ALIASES = {
@@ -283,16 +284,17 @@ class AddonInternal {
     this.blocklistState = Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
     this.blocklistURL = null;
     this.sourceURI = null;
     this.releaseNotesURI = null;
     this.foreignInstall = false;
     this.seen = true;
     this.skinnable = false;
     this.startupData = null;
+    this.installTelemetryInfo = null;
 
     this.inDatabase = false;
 
     /**
      * @property {Array<string>} dependencies
      *   An array of bootstrapped add-on IDs on which this add-on depends.
      *   The add-on will remain appDisabled if any of the dependent
      *   add-ons is not installed and enabled.
@@ -703,16 +705,31 @@ AddonWrapper = class {
     return addonFor(this).hasEmbeddedWebExtension;
   }
 
   markAsSeen() {
     addonFor(this).seen = true;
     XPIDatabase.saveChanges();
   }
 
+  get installTelemetryInfo() {
+    const addon = addonFor(this);
+    if (!addon.installTelemetryInfo && addon.location) {
+      if (addon.location.isSystem) {
+        return {source: "system-addon"};
+      }
+
+      if (addon.location.isTemporary) {
+        return {source: "temporary-addon"};
+      }
+    }
+
+    return addon.installTelemetryInfo;
+  }
+
   get type() {
     return XPIDatabase.getExternalType(addonFor(this).type);
   }
 
   get isWebExtension() {
     return isWebExtension(addonFor(this).type);
   }
 
@@ -2420,16 +2437,22 @@ this.XPIDatabaseReconcile = {
     // Assume that add-ons in the system add-ons install location aren't
     // foreign and should default to enabled.
     aNewAddon.foreignInstall = isDetectedInstall && !aLocation.isSystem;
 
     // appDisabled depends on whether the add-on is a foreignInstall so update
     aNewAddon.appDisabled = !XPIDatabase.isUsableAddon(aNewAddon);
 
     if (isDetectedInstall && aNewAddon.foreignInstall) {
+      // Add the installation source info for the sideloaded extension.
+      aNewAddon.installTelemetryInfo = {
+        source: aLocation.name,
+        method: "sideload",
+      };
+
       // If the add-on is a foreign install and is in a scope where add-ons
       // that were dropped in should default to disabled then disable it
       let disablingScopes = Services.prefs.getIntPref(PREF_EM_AUTO_DISABLED_SCOPES, 0);
       if (aLocation.scope & disablingScopes) {
         logger.warn(`Disabling foreign installed add-on ${aNewAddon.id} in ${aLocation.name}`);
         aNewAddon.userDisabled = true;
         aNewAddon.seen = false;
       }
--- a/toolkit/mozapps/extensions/internal/XPIInstall.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm
@@ -1303,16 +1303,21 @@ class AddonInstall {
    * @param {string} [options.name]
    *        An optional name for the add-on
    * @param {string} [options.type]
    *        An optional type for the add-on
    * @param {object} [options.icons]
    *        Optional icons for the add-on
    * @param {string} [options.version]
    *        An optional version for the add-on
+   * @param {Object?} [options.installTelemetryInfo]
+   *         An optional object which provides details about the installation source
+   *         included in the addon manager telemetry events.
+   * @param {boolean} [options.isUserRequestedUpdate]
+   *        An optional boolean, true if the install object is related to a user triggered update.
    * @param {function(string) : Promise<void>} [options.promptHandler]
    *        A callback to prompt the user before installing.
    */
   constructor(installLocation, url, options = {}) {
     this.wrapper = new AddonInstallWrapper(this);
     this.location = installLocation;
     this.sourceURI = url;
 
@@ -1345,16 +1350,18 @@ class AddonInstall {
     this.maxProgress = -1;
 
     // Giving each instance of AddonInstall a reference to the logger.
     this.logger = logger;
 
     this.name = options.name || null;
     this.type = options.type || null;
     this.version = options.version || null;
+    this.installTelemetryInfo = options.installTelemetryInfo || null;
+    this.isUserRequestedUpdate = options.isUserRequestedUpdate;
 
     this.file = null;
     this.ownsTempFile = null;
 
     this.addon = null;
     this.state = null;
 
     XPIInstall.installs.add(this);
@@ -1511,16 +1518,25 @@ class AddonInstall {
    */
   updateAddonURIs() {
     this.addon.sourceURI = this.sourceURI.spec;
     if (this.releaseNotesURI)
       this.addon.releaseNotesURI = this.releaseNotesURI.spec;
   }
 
   /**
+   * Store the installTelemetryInfo into the persisted addon metadata.
+   */
+  updateInstallTelemetryInfo() {
+    if (this.installTelemetryInfo) {
+      this.addon.installTelemetryInfo = this.installTelemetryInfo;
+    }
+  }
+
+  /**
    * Called after the add-on is a local file and the signature and install
    * manifest can be read.
    *
    * @param {nsIFile} file
    *        The file from which to load the manifest.
    * @returns {Promise<void>}
    */
   async loadManifest(file) {
@@ -1571,16 +1587,17 @@ class AddonInstall {
                                  "signature verification failed"]);
         }
       }
     } finally {
       pkg.close();
     }
 
     this.updateAddonURIs();
+    this.updateInstallTelemetryInfo();
 
     this.addon._install = this;
     this.name = this.addon.selectedLocale.name;
     this.type = this.addon.type;
     this.version = this.addon.version;
 
     // Setting the iconURL to something inside the XPI locks the XPI and
     // makes it impossible to delete on Windows.
@@ -2400,29 +2417,36 @@ var DownloadAddonInstall = class extends
  * Creates a new AddonInstall for an update.
  *
  * @param {function} aCallback
  *        The callback to pass the new AddonInstall to
  * @param {AddonInternal} aAddon
  *        The add-on being updated
  * @param {Object} aUpdate
  *        The metadata about the new version from the update manifest
+ * @param {boolean} isUserRequested
+ *        An optional boolean, true if the install object is related to a user triggered update.
  */
-function createUpdate(aCallback, aAddon, aUpdate) {
+function createUpdate(aCallback, aAddon, aUpdate, isUserRequested) {
   let url = Services.io.newURI(aUpdate.updateURL);
 
   (async function() {
     let opts = {
       hash: aUpdate.updateHash,
       existingAddon: aAddon,
       name: aAddon.selectedLocale.name,
       type: aAddon.type,
       icons: aAddon.icons,
       version: aUpdate.version,
+      // Inherits the installTelemetryInfo on updates (so that the source of the original
+      // installation telemetry data is being preserved across the extension updates).
+      installTelemetryInfo: aAddon.installTelemetryInfo,
+      isUserRequestedUpdate: isUserRequested,
     };
+
     let install;
     if (url instanceof Ci.nsIFileURL) {
       install = new LocalAddonInstall(aAddon.location, url, opts);
       await install.init();
     } else {
       install = new DownloadAddonInstall(aAddon.location, url, opts);
     }
     try {
@@ -2476,16 +2500,24 @@ AddonInstallWrapper.prototype = {
   get sourceURI() {
     return installFor(this).sourceURI;
   },
 
   set promptHandler(handler) {
     installFor(this).promptHandler = handler;
   },
 
+  get installTelemetryInfo() {
+    return installFor(this).installTelemetryInfo;
+  },
+
+  get isUserRequestedUpdate() {
+    return installFor(this).isUserRequestedUpdate || false;
+  },
+
   install() {
     return installFor(this).install();
   },
 
   cancel() {
     installFor(this).cancel();
   },
 
@@ -2531,16 +2563,17 @@ var UpdateChecker = function(aAddon, aLi
 
   this.addon = aAddon;
   aAddon._updateCheck = this;
   XPIInstall.doing(this);
   this.listener = aListener;
   this.appVersion = aAppVersion;
   this.platformVersion = aPlatformVersion;
   this.syncCompatibility = (aReason == AddonManager.UPDATE_WHEN_NEW_APP_INSTALLED);
+  this.isUserRequested = (aReason == AddonManager.UPDATE_WHEN_USER_REQUESTED);
 
   let updateURL = aAddon.updateURL;
   if (!updateURL) {
     if (aReason == AddonManager.UPDATE_WHEN_PERIODIC_UPDATE &&
         Services.prefs.getPrefType(PREF_EM_UPDATE_BACKGROUND_URL) == Services.prefs.PREF_STRING) {
       updateURL = Services.prefs.getCharPref(PREF_EM_UPDATE_BACKGROUND_URL);
     } else {
       updateURL = Services.prefs.getCharPref(PREF_EM_UPDATE_URL);
@@ -2671,17 +2704,17 @@ UpdateChecker.prototype = {
           sendUpdateAvailableMessages(this, currentInstall);
         } else
           sendUpdateAvailableMessages(this, null);
         return;
       }
 
       createUpdate(aInstall => {
         sendUpdateAvailableMessages(this, aInstall);
-      }, this.addon, update);
+      }, this.addon, update, this.isUserRequested);
     } else {
       sendUpdateAvailableMessages(this, null);
     }
   },
 
   /**
    * Called when AddonUpdateChecker fails the update check
    *
@@ -2711,27 +2744,30 @@ UpdateChecker.prototype = {
 
 /**
  * Creates a new AddonInstall to install an add-on from a local file.
  *
  * @param {nsIFile} file
  *        The file to install
  * @param {XPIStateLocation} location
  *        The location to install to
+ * @param {Object?} [installTelemetryInfo]
+ *        An optional object which provides details about the installation source
+ *        included in the addon manager telemetry events.
  * @returns {Promise<AddonInstall>}
  *        A Promise that resolves with the new install object.
  */
-function createLocalInstall(file, location) {
+function createLocalInstall(file, location, installTelemetryInfo) {
   if (!location) {
     location = XPIStates.getLocation(KEY_APP_PROFILE);
   }
   let url = Services.io.newFileURI(file);
 
   try {
-    let install = new LocalAddonInstall(location, url);
+    let install = new LocalAddonInstall(location, url, {installTelemetryInfo});
     return install.init().then(() => install);
   } catch (e) {
     logger.error("Error creating install", e);
     XPIInstall.installs.delete(this);
     return Promise.resolve(null);
   }
 }
 
@@ -3420,16 +3456,17 @@ var XPIInstall = {
    *        The XPI file to install the add-on from.
    * @param {XPIStateLocation} location
    *        The install location to install the add-on to.
    * @returns {AddonInternal}
    *        The installed Addon object, upon success.
    */
   async installDistributionAddon(id, file, location) {
     let addon = await loadManifestFromFile(file, location);
+    addon.installTelemetryInfo = {source: "distro"};
 
     if (addon.id != id) {
       throw new Error(`File file ${file.path} contains an add-on with an incorrect ID`);
     }
 
     let state = location.get(id);
 
     if (state) {
@@ -3735,28 +3772,32 @@ var XPIInstall = {
    * @param {string} [aName]
    *        A name for the install
    * @param {Object} [aIcons]
    *        Icon URLs for the install
    * @param {string} [aVersion]
    *        A version for the install
    * @param {XULElement?} [aBrowser]
    *        The browser performing the install
+   * @param {Object?} [aInstallTelemetryInfo]
+   *        An optional object which provides details about the installation source
+   *        included in the addon manager telemetry events.
    * @returns {AddonInstall}
    */
-  async getInstallForURL(aUrl, aHash, aName, aIcons, aVersion, aBrowser) {
+  async getInstallForURL(aUrl, aHash, aName, aIcons, aVersion, aBrowser, aInstallTelemetryInfo) {
     let location = XPIStates.getLocation(KEY_APP_PROFILE);
     let url = Services.io.newURI(aUrl);
 
     let options = {
       hash: aHash,
       browser: aBrowser,
       name: aName,
       icons: aIcons,
       version: aVersion,
+      installTelemetryInfo: aInstallTelemetryInfo,
     };
 
     if (url instanceof Ci.nsIFileURL) {
       let install = new LocalAddonInstall(location, url, options);
       await install.init();
       return install.wrapper;
     }
 
@@ -3764,20 +3805,23 @@ var XPIInstall = {
     return install.wrapper;
   },
 
   /**
    * Called to get an AddonInstall to install an add-on from a local file.
    *
    * @param {nsIFile} aFile
    *        The file to be installed
+   * @param {Object?} [aInstallTelemetryInfo]
+   *        An optional object which provides details about the installation source
+   *        included in the addon manager telemetry events.
    * @returns {AddonInstall?}
    */
-  async getInstallForFile(aFile) {
-    let install = await createLocalInstall(aFile);
+  async getInstallForFile(aFile, aInstallTelemetryInfo) {
+    let install = await createLocalInstall(aFile, null, aInstallTelemetryInfo);
     return install ? install.wrapper : null;
   },
 
   /**
    * Called to get the current AddonInstalls, optionally limiting to a list of
    * types.
    *
    * @param {Array<string>?} aTypes
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_getInstallSourceFromPrincipal.js
@@ -0,0 +1,100 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* globals MatchPatternSet, WebExtensionPolicy */
+
+// Test the AddonManager.getInstallSourceFromPrincipal and AddonManager.getInstallSourceFromHost
+// helpers.
+
+add_task(function test_getInstallSourceFromPrincipal_helpers() {
+  const ssm = Services.scriptSecurityManager;
+
+  const addonId = "@test-extension";
+  const uuidGen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
+  const uuid = uuidGen.generateUUID().number.slice(1, -1);
+
+  let addonPolicy = new WebExtensionPolicy({
+    id: addonId,
+    mozExtensionHostname: uuid,
+    baseURL: "file:///",
+    allowedOrigins: new MatchPatternSet([]),
+    localizeCallback() {},
+  });
+  addonPolicy.active = true;
+
+  let extensionPrincipal = ssm.createCodebasePrincipalFromOrigin(`moz-extension://${uuid}`);
+
+  let addonExpandedPrincipal = Cu.getObjectPrincipal(Cu.Sandbox([
+    ssm.createCodebasePrincipalFromOrigin("https://example.com"),
+    extensionPrincipal,
+  ]));
+
+  let expandedPrincipal = Cu.getObjectPrincipal(Cu.Sandbox([
+    ssm.createCodebasePrincipalFromOrigin("https://example.com"),
+    ssm.createCodebasePrincipalFromOrigin("https://example.org"),
+  ]));
+
+  const principalTestCases = [
+    {
+      principal: ssm.createCodebasePrincipalFromOrigin("https://addons.allizom.org"),
+      installSourceFromPrincipal: "test-hosts",
+      installSourceFromHost: "test-hosts",
+    },
+    {
+      principal: ssm.createCodebasePrincipalFromOrigin("https://addons.mozilla.org"),
+      installSourceFromPrincipal: "amo",
+      installSourceFromHost: "amo",
+    },
+    {
+      principal: ssm.createCodebasePrincipalFromOrigin("https://discovery.addons.mozilla.org"),
+      installSourceFromPrincipal: "disco",
+      installSourceFromHost: "disco",
+    },
+    {
+      principal: ssm.createCodebasePrincipalFromOrigin("https://testpilot.firefox.com"),
+      installSourceFromPrincipal: "testpilot",
+      installSourceFromHost: "testpilot",
+    },
+    {
+      principal: ssm.createCodebasePrincipalFromOrigin("about:blank"),
+      installSourceFromPrincipal: "null-principal",
+      installSourceFromHost: "unknown",
+    },
+    {
+      principal: extensionPrincipal,
+      installSourceFromPrincipal: "webextension",
+      installSourceFromHost: "unknown",
+    },
+    {
+      principal: addonExpandedPrincipal,
+      installSourceFromPrincipal: "webextension",
+      installSourceFromHost: "unknown",
+    },
+    {
+      principal: expandedPrincipal,
+      installSourceFromPrincipal: "unknown",
+      installSourceFromHost: "unknown",
+    },
+    {
+      principal: Services.scriptSecurityManager.getSystemPrincipal(),
+      installSourceFromPrincipal: "system-principal",
+      installSourceFromHost: "unknown",
+    },
+    {
+      principal: null,
+      installSourceFromPrincipal: "no-principal",
+      installSourceFromHost: "unknown",
+    },
+  ];
+
+  for (let testCase of principalTestCases) {
+    let origin = testCase.principal ? testCase.principal.origin : null;
+    let host = testCase.principal && testCase.principal.URI && testCase.principal.URI.host;
+
+    equal(AddonManager.getInstallSourceFromPrincipal(testCase.principal), testCase.installSourceFromPrincipal,
+          `Got the expected result from getInstallFromPrincipal for a principal with origin ${origin}`);
+    equal(AddonManager.getInstallSourceFromHost(host), testCase.installSourceFromHost,
+          `Got the expected result from getInstallFromHost for a prinicipal with origin ${origin}`);
+  }
+});
--- a/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
@@ -7,17 +7,19 @@
 const IGNORE = ["getPreferredIconURL", "escapeAddonURI",
                 "shouldAutoUpdate", "getStartupChanges",
                 "addTypeListener", "removeTypeListener",
                 "addAddonListener", "removeAddonListener",
                 "addInstallListener", "removeInstallListener",
                 "addManagerListener", "removeManagerListener",
                 "shutdown", "init",
                 "stateToString", "errorToString", "getUpgradeListener",
-                "addUpgradeListener", "removeUpgradeListener"];
+                "addUpgradeListener", "removeUpgradeListener",
+                "getInstallSourceFromHost", "getInstallSourceFromPrincipal",
+               ];
 
 const IGNORE_PRIVATE = ["AddonAuthor", "AddonCompatibilityOverride",
                         "AddonScreenshot", "AddonType", "startup", "shutdown",
                         "addonIsActive", "registerProvider", "unregisterProvider",
                         "addStartupChange", "removeStartupChange",
                         "getNewSideloads",
                         "recordTimestamp", "recordSimpleMeasure",
                         "recordException", "getSimpleMeasures", "simpleTimer",
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
@@ -94,16 +94,17 @@ tags = webextensions
 # Bug 676992: test consistently hangs on Android
 skip-if = os == "android"
 [test_error.js]
 [test_ext_management.js]
 skip-if = appname == "thunderbird"
 tags = webextensions
 [test_general.js]
 [test_getresource.js]
+[test_getInstallSourceFromPrincipal.js]
 [test_gfxBlacklist_Device.js]
 tags = blocklist
 [test_gfxBlacklist_DriverNew.js]
 tags = blocklist
 [test_gfxBlacklist_Equal_DriverNew.js]
 tags = blocklist
 [test_gfxBlacklist_Equal_DriverOld.js]
 tags = blocklist