Bug 1338713 Extension install telemetry draft
authorAndrew Swan <aswan@mozilla.com>
Tue, 28 Feb 2017 09:08:49 -0800
changeset 494189 b4689ba81570a1734263eeda3f35f7069e8c72b9
parent 494079 517c553ad64746c479456653ce11b04ab8e4977f
child 548028 1ba9f53f03774285705fd5441f585dbfcb031f4a
push id47955
push useraswan@mozilla.com
push dateMon, 06 Mar 2017 19:33:43 +0000
bugs1338713
milestone54.0a1
Bug 1338713 Extension install telemetry MozReview-Commit-ID: KFd7k7zaDL6
browser/base/content/test/webextensions/browser_extension_sideloading.js
browser/base/content/test/webextensions/browser_extension_update_background.js
browser/base/content/test/webextensions/browser_permissions_addons_search.js
browser/base/content/test/webextensions/browser_permissions_installTrigger.js
browser/base/content/test/webextensions/browser_permissions_local_file.js
browser/base/content/test/webextensions/browser_permissions_mozAddonManager.js
browser/base/content/test/webextensions/head.js
browser/modules/ExtensionsUI.jsm
toolkit/components/telemetry/Histograms.json
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/content/extensions.xml
--- a/browser/base/content/test/webextensions/browser_extension_sideloading.js
+++ b/browser/base/content/test/webextensions/browser_extension_sideloading.js
@@ -167,16 +167,18 @@ add_task(function* () {
   yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
 
   registerCleanupFunction(function*() {
     // Return to about:blank when we're done
     gBrowser.selectedBrowser.loadURI("about:blank");
     yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
   });
 
+  hookExtensionsTelemetry();
+
   let changePromise = new Promise(resolve => {
     ExtensionsUI.on("change", function listener() {
       ExtensionsUI.off("change", listener);
       resolve();
     });
   });
   ExtensionsUI._checkForSideloaded();
   yield changePromise;
@@ -322,12 +324,15 @@ add_task(function* () {
   disablePromise = promiseSetDisabled(mock4);
   panel.button.click();
   value = yield disablePromise;
   is(value, false, "userDisabled should be set on addon 4");
 
   addon4 = yield AddonManager.getAddonByID(ID4);
   is(addon4.userDisabled, false, "Addon 4 should be enabled");
 
+  // We should have recorded 1 cancelled followed by 3 accepted sideloads.
+  expectTelemetry(["sideloadRejected", "sideloadAccepted", "sideloadAccepted", "sideloadAccepted"]);
+
   isnot(menuButton.getAttribute("badge-status"), "addon-alert", "Should no longer have addon alert badge");
 
   yield BrowserTestUtils.removeTab(gBrowser.selectedTab);
 });
--- a/browser/base/content/test/webextensions/browser_extension_update_background.js
+++ b/browser/base/content/test/webextensions/browser_extension_update_background.js
@@ -47,16 +47,18 @@ add_task(function* setup() {
 
   registerCleanupFunction(function*() {
     // Return to about:blank when we're done
     gBrowser.selectedBrowser.loadURI("about:blank");
     yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
   });
 });
 
+hookExtensionsTelemetry();
+
 // Helper function to test background updates.
 function* backgroundUpdateTest(url, id, checkIconFn) {
   yield SpecialPowers.pushPrefEnv({set: [
     // Turn on background updates
     ["extensions.update.enabled", true],
 
     // Point updates to the local mochitest server
     ["extensions.update.background.url", `${BASE}/browser_webext_update.json`],
@@ -158,16 +160,19 @@ function* backgroundUpdateTest(url, id, 
 
   addon = yield updatePromise;
   is(addon.version, "2.0", "Should have upgraded to the new version");
 
   yield BrowserTestUtils.removeTab(tab);
 
   is(getBadgeStatus(), "", "Addon alert badge should be gone");
 
+  // Should have recorded 1 canceled followed by 1 accepted update.
+  expectTelemetry(["updateRejected", "updateAccepted"]);
+
   addon.uninstall();
   yield SpecialPowers.popPrefEnv();
 }
 
 function checkDefaultIcon(icon) {
   is(icon, "chrome://mozapps/skin/extensions/extensionGeneric.svg",
      "Popup has the default extension icon");
 }
--- a/browser/base/content/test/webextensions/browser_permissions_addons_search.js
+++ b/browser/base/content/test/webextensions/browser_permissions_addons_search.js
@@ -34,9 +34,9 @@ async function installSearch(filename) {
   // abracadabara XBL
   item.clientTop;
 
   let install = win.document.getAnonymousElementByAttribute(item, "anonid", "install-status");
   let button = win.document.getAnonymousElementByAttribute(install, "anonid", "install-remote-btn");
   EventUtils.synthesizeMouseAtCenter(button, {}, win);
 }
 
-add_task(() => testInstallMethod(installSearch));
+add_task(() => testInstallMethod(installSearch, "installAmo"));
--- a/browser/base/content/test/webextensions/browser_permissions_installTrigger.js
+++ b/browser/base/content/test/webextensions/browser_permissions_installTrigger.js
@@ -6,9 +6,9 @@ async function installTrigger(filename) 
   gBrowser.selectedBrowser.loadURI(INSTALL_PAGE);
   await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
 
   ContentTask.spawn(gBrowser.selectedBrowser, `${BASE}/${filename}`, function*(url) {
     content.wrappedJSObject.installTrigger(url);
   });
 }
 
-add_task(() => testInstallMethod(installTrigger));
+add_task(() => testInstallMethod(installTrigger, "installAmo"));
--- a/browser/base/content/test/webextensions/browser_permissions_local_file.js
+++ b/browser/base/content/test/webextensions/browser_permissions_local_file.js
@@ -15,9 +15,9 @@ async function installFile(filename) {
 
   await BrowserOpenAddonsMgr("addons://list/extension");
   let contentWin = gBrowser.selectedTab.linkedBrowser.contentWindow;
 
   // Do the install...
   contentWin.gViewController.doCommand("cmd_installFromFile");
 }
 
-add_task(() => testInstallMethod(installFile));
+add_task(() => testInstallMethod(installFile, "installLocal"));
--- a/browser/base/content/test/webextensions/browser_permissions_mozAddonManager.js
+++ b/browser/base/content/test/webextensions/browser_permissions_mozAddonManager.js
@@ -6,9 +6,9 @@ async function installMozAM(filename) {
   gBrowser.selectedBrowser.loadURI(INSTALL_PAGE);
   await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
 
   await ContentTask.spawn(gBrowser.selectedBrowser, `${BASE}/${filename}`, function*(url) {
     yield content.wrappedJSObject.installMozAM(url);
   });
 }
 
-add_task(() => testInstallMethod(installMozAM));
+add_task(() => testInstallMethod(installMozAM, "installAmo"));
--- a/browser/base/content/test/webextensions/head.js
+++ b/browser/base/content/test/webextensions/head.js
@@ -1,12 +1,14 @@
 
 const BASE = getRootDirectory(gTestPath)
   .replace("chrome://mochitests/content/", "https://example.com/");
 
+Cu.import("resource:///modules/ExtensionsUI.jsm");
+
 /**
  * Wait for the given PopupNotification to display
  *
  * @param {string} name
  *        The name of the notification to wait for.
  *
  * @returns {Promise}
  *          Resolves with the notification window.
@@ -196,32 +198,39 @@ function checkNotification(panel, checkI
 /**
  * Test that install-time permission prompts work for a given
  * installation method.
  *
  * @param {Function} installFn
  *        Callable that takes the name of an xpi file to install and
  *        starts to install it.  Should return a Promise that resolves
  *        when the install is finished or rejects if the install is canceled.
+ * @param {string} telemetryBase
+ *        If supplied, the base type for telemetry events that should be
+ *        recorded for this install method.
  *
  * @returns {Promise}
  */
-async function testInstallMethod(installFn) {
+async function testInstallMethod(installFn, telemetryBase) {
   const PERMS_XPI = "browser_webext_permissions.xpi";
   const NO_PERMS_XPI = "browser_webext_nopermissions.xpi";
   const ID = "permissions@test.mozilla.org";
 
   await SpecialPowers.pushPrefEnv({set: [
     ["extensions.webapi.testing", true],
     ["extensions.install.requireBuiltInCerts", false],
 
     // XXX remove this when prompts are enabled by default
     ["extensions.webextPermissionPrompts", true],
   ]});
 
+  if (telemetryBase !== undefined) {
+    hookExtensionsTelemetry();
+  }
+
   let testURI = makeURI("https://example.com/");
   Services.perms.add(testURI, "install", Services.perms.ALLOW_ACTION);
   registerCleanupFunction(() => Services.perms.remove(testURI, "install"));
 
   async function runOnce(filename, cancel) {
     let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
 
     let installPromise = new Promise(resolve => {
@@ -311,16 +320,22 @@ async function testInstallMethod(install
   // 2. Same as #1 but with an extension that requests some permissions.
   await runOnce(PERMS_XPI, true);
 
   // 3. Repeat with the same extension from step 2 but this time,
   //    accept the permissions to install the extension.  (Then uninstall
   //    the extension to clean up.)
   await runOnce(PERMS_XPI, false);
 
+  if (telemetryBase !== undefined) {
+    // Should see 2 canceled installs followed by 1 successful install
+    // for this method.
+    expectTelemetry([`${telemetryBase}Rejected`, `${telemetryBase}Rejected`, `${telemetryBase}Accepted`]);
+  }
+
   await SpecialPowers.popPrefEnv();
 }
 
 // The tests in this directory install a bunch of extensions but they
 // need to uninstall them before exiting, as a stray leftover extension
 // after one test can foul up subsequent tests.
 // So, add a task to run before any tests that grabs a list of all the
 // add-ons that are pre-installed in the test environment and then checks
@@ -342,8 +357,26 @@ add_task(async function() {
     for (let addon of await AddonManager.getAllAddons()) {
       if (!existingAddons.has(addon.id)) {
         ok(false, `Addon ${addon.id} was left installed at the end of the test`);
         addon.uninstall();
       }
     }
   });
 });
+
+let collectedTelemetry = [];
+function hookExtensionsTelemetry() {
+  let originalHistogram = ExtensionsUI.histogram;
+  ExtensionsUI.histogram = {
+    add(value) { collectedTelemetry.push(value); },
+  };
+  registerCleanupFunction(() => {
+    is(collectedTelemetry.length, 0, "No unexamined telemetry after test is finished");
+    ExtensionsUI.histogram = originalHistogram;
+  });
+}
+
+function expectTelemetry(values) {
+  Assert.deepEqual(values, collectedTelemetry);
+  collectedTelemetry = [];
+}
+
--- a/browser/modules/ExtensionsUI.jsm
+++ b/browser/modules/ExtensionsUI.jsm
@@ -28,18 +28,21 @@ const BROWSER_PROPERTIES = "chrome://bro
 const BRAND_PROPERTIES = "chrome://branding/locale/brand.properties";
 
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 
 this.ExtensionsUI = {
   sideloaded: new Set(),
   updates: new Set(),
   sideloadListener: null,
+  histogram: null,
 
   init() {
+    this.histogram = Services.telemetry.getHistogramById("EXTENSION_INSTALL_PROMPT_RESULT");
+
     Services.obs.addObserver(this, "webextension-permission-prompt", false);
     Services.obs.addObserver(this, "webextension-update-permissions", false);
     Services.obs.addObserver(this, "webextension-install-notify", false);
 
     this._checkForSideloaded();
   },
 
   _checkForSideloaded() {
@@ -83,43 +86,44 @@ this.ExtensionsUI = {
         let win = RecentWindow.getMostRecentBrowserWindow();
         for (let addon of sideloaded) {
           win.openUILinkIn(`about:newaddon?id=${addon.id}`, "tab");
         }
       }
     });
   },
 
-  showAddonsManager(browser, strings, icon) {
+  showAddonsManager(browser, strings, icon, histkey) {
     let global = browser.selectedBrowser.ownerGlobal;
     return global.BrowserOpenAddonsMgr("addons://list/extension").then(aomWin => {
       let aomBrowser = aomWin.QueryInterface(Ci.nsIInterfaceRequestor)
                              .getInterface(Ci.nsIDocShell)
                              .chromeEventHandler;
-      return this.showPermissionsPrompt(aomBrowser, strings, icon);
+      return this.showPermissionsPrompt(aomBrowser, strings, icon, histkey);
     });
   },
 
   showSideloaded(browser, addon) {
     addon.markAsSeen();
     this.sideloaded.delete(addon);
     this.emit("change");
 
     let strings = this._buildStrings({
       addon,
       permissions: addon.userPermissions,
       type: "sideload",
     });
-    this.showAddonsManager(browser, strings, addon.iconURL).then(answer => {
-      addon.userDisabled = !answer;
-    });
+    this.showAddonsManager(browser, strings, addon.iconURL, "sideload")
+        .then(answer => {
+          addon.userDisabled = !answer;
+        });
   },
 
   showUpdate(browser, info) {
-    this.showAddonsManager(browser, info.strings, info.addon.iconURL)
+    this.showAddonsManager(browser, info.strings, info.addon.iconURL, "update")
         .then(answer => {
           if (answer) {
             info.resolve();
           } else {
             info.reject();
           }
           // At the moment, this prompt will re-appear next time we do an update
           // check.  See bug 1332360 for proposal to avoid this.
@@ -142,23 +146,37 @@ this.ExtensionsUI = {
 
       let strings = this._buildStrings(info);
       // If this is an update with no promptable permissions, just apply it
       if (info.type == "update" && strings.msgs.length == 0) {
         info.resolve();
         return;
       }
 
-      this.showPermissionsPrompt(target, strings, info.icon).then(answer => {
-        if (answer) {
-          info.resolve();
-        } else {
-          info.reject();
-        }
-      });
+      let histkey;
+      if (info.type == "sideload") {
+        histkey = "sideload";
+      } else if (info.type == "update") {
+        histkey = "update";
+      } else if (info.source == "AMO") {
+        histkey = "installAmo";
+      } else if (info.source == "local") {
+        histkey = "installLocal";
+      } else {
+        histkey = "installWeb";
+      }
+
+      this.showPermissionsPrompt(target, strings, info.icon, histkey)
+          .then(answer => {
+            if (answer) {
+              info.resolve();
+            } else {
+              info.reject();
+            }
+          });
     } else if (topic == "webextension-update-permissions") {
       let info = subject.wrappedJSObject;
       info.type = "update";
       let strings = this._buildStrings(info);
 
       // If we don't prompt for any new permissions, just apply it
       if (strings.msgs.length == 0) {
         info.resolve();
@@ -302,17 +320,17 @@ this.ExtensionsUI = {
       result.text = bundle.formatStringFromName("webextPerms.updateText", [addonName], 1);
       result.acceptText = bundle.GetStringFromName("webextPerms.updateAccept.label");
       result.acceptKey = bundle.GetStringFromName("webextPerms.updateAccept.accessKey");
     }
 
     return result;
   },
 
-  showPermissionsPrompt(browser, strings, icon) {
+  showPermissionsPrompt(browser, strings, icon, histkey) {
     function eventCallback(topic) {
       if (topic == "showing") {
         let doc = this.browser.ownerDocument;
         doc.getElementById("addon-webext-perm-header").innerHTML = strings.header;
 
         let textEl = doc.getElementById("addon-webext-perm-text");
         textEl.innerHTML = strings.text;
         textEl.hidden = !strings.text;
@@ -344,23 +362,29 @@ this.ExtensionsUI = {
       eventCallback,
     };
 
     let win = browser.ownerGlobal;
     return new Promise(resolve => {
       let action = {
         label: strings.acceptText,
         accessKey: strings.acceptKey,
-        callback: () => resolve(true),
+        callback: () => {
+          this.histogram.add(histkey + "Accepted");
+          resolve(true);
+        },
       };
       let secondaryActions = [
         {
           label: strings.cancelText,
           accessKey: strings.cancelKey,
-          callback: () => resolve(false),
+          callback: () => {
+            this.histogram.add(histkey + "Rejected");
+            resolve(false);
+          },
         },
       ];
 
       win.PopupNotifications.show(browser, "addon-webext-permissions", "",
                                   "addons-notification-icon",
                                   action, secondaryActions, popupOptions);
     });
   },
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -11072,10 +11072,19 @@
   },
   "BUSY_TAB_ABANDONED": {
     "alert_emails": ["hkirschner@mozilla.com"],
     "expires_in_version": "60",
     "kind": "categorical",
     "bug_numbers": [1307689],
     "description": "Records a value each time a tab that is showing the loading throbber is interrupted. Desktop only.",
     "labels": ["stop", "back", "forward", "historyNavigation", "reload", "tabClosed", "newURI"]
+  },
+  "EXTENSION_INSTALL_PROMPT_RESULT": {
+    "alert_emails": ["aswan@mozilla.com", "andym@mozilla.com"],
+    "bug_numbers": [1338713],
+    "expires_in_version": "60",
+    "kind": "categorical",
+    "labels": ["installAmoAccepted", "installAmoRejected", "installLocalAccepted", "installLocalRejected", "installWebAccepted", "installWebRejected", "sideloadAccepted", "sideloadRejected", "updateAccepted", "updateRejected"],
+    "description": "Results of displaying add-on installation notifications.",
+    "releaseChannelCollection": "opt-out"
   }
 }
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -2186,27 +2186,27 @@ var AddonManagerInternal = {
         return;
       }
 
       // The install may start now depending on the web install listener,
       // listen for the browser navigating to a new origin and cancel the
       // install in that case.
       new BrowserListener(aBrowser, aInstallingPrincipal, aInstall);
 
-      AddonManagerInternal.setupPromptHandler(aBrowser, aInstallingPrincipal.URI, aInstall, true);
-
-      let startInstall = () => {
+      let startInstall = (source) => {
+        AddonManagerInternal.setupPromptHandler(aBrowser, aInstallingPrincipal.URI, aInstall, true, source);
+
         AddonManagerInternal.startInstall(aBrowser, aInstallingPrincipal.URI, aInstall);
       };
       if (!this.isInstallAllowed(aMimetype, aInstallingPrincipal)) {
         this.installNotifyObservers("addon-install-blocked", topBrowser,
                                     aInstallingPrincipal.URI, aInstall,
-                                    startInstall);
+                                    () => startInstall("other"));
       } else {
-        startInstall();
+        startInstall("AMO");
       }
     } catch (e) {
       // In the event that the weblistener throws during instantiation or when
       // calling onWebInstallBlocked or onWebInstallRequested the
       // install should get cancelled.
       logger.warn("Failure calling web installer", e);
       aInstall.cancel();
     }
@@ -2223,17 +2223,17 @@ var AddonManagerInternal = {
    * @param  install
    *         The AddonInstall to be installed
    */
   installAddonFromAOM(browser, uri, install) {
     if (!gStarted)
       throw Components.Exception("AddonManager is not initialized",
                                  Cr.NS_ERROR_NOT_INITIALIZED);
 
-    AddonManagerInternal.setupPromptHandler(browser, uri, install, true);
+    AddonManagerInternal.setupPromptHandler(browser, uri, install, true, "local");
     AddonManagerInternal.startInstall(browser, uri, install);
   },
 
   /**
    * Adds a new InstallListener if the listener is not already registered.
    *
    * @param  aListener
    *         The InstallListener to add
@@ -2820,17 +2820,17 @@ var AddonManagerInternal = {
       Services.prefs.setBoolPref(PREF_EM_UPDATE_ENABLED, aValue);
     return aValue;
   },
 
   get hotfixID() {
     return gHotfixID;
   },
 
-  setupPromptHandler(browser, url, install, requireConfirm) {
+  setupPromptHandler(browser, url, install, requireConfirm, source) {
     install.promptHandler = info => new Promise((resolve, _reject) => {
       let reject = () => {
         this.installNotifyObservers("addon-install-cancelled",
                                     browser, url, install);
         _reject();
       };
 
       // All installs end up in this callback when the add-on is available
@@ -2847,17 +2847,17 @@ var AddonManagerInternal = {
       // "@mozilla.org/addons/web-install-prompt;1" contract or by setting
       // the customConfirmationUI preference and responding to the
       // "addon-install-confirmation" notification.  If the application
       // does not implement its own prompt, use the built-in xul dialog.
       if (info.addon.userPermissions && WEBEXT_PERMISSION_PROMPTS) {
         let subject = {
           wrappedJSObject: {
             target: browser,
-            info: Object.assign({resolve, reject}, info),
+            info: Object.assign({resolve, reject, source}, info),
           }
         };
         subject.wrappedJSObject.info.permissions = info.addon.userPermissions;
         Services.obs.notifyObservers(subject, "webextension-permission-prompt", null);
       } else if (requireConfirm) {
         // The methods below all want to call the install() or cancel()
         // method on the provided AddonInstall object to either accept
         // or reject the confirmation.  Fit that into our promise-based
@@ -3036,17 +3036,17 @@ var AddonManagerInternal = {
       try {
         checkInstallUrl(options.url);
       } catch (err) {
         return Promise.reject({message: err.message});
       }
 
       return AddonManagerInternal.getInstallForURL(options.url, "application/x-xpinstall", options.hash)
                                  .then(install => {
-        AddonManagerInternal.setupPromptHandler(target, null, install, false);
+        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});
 
         let result = {id};
--- a/toolkit/mozapps/extensions/content/extensions.xml
+++ b/toolkit/mozapps/extensions/content/extensions.xml
@@ -660,16 +660,17 @@
           if (prompt) {
             this.mInstall.promptHandler = info => new Promise((resolve, reject) => {
               let subject = {
                 wrappedJSObject: {
                   target: window.QueryInterface(Ci.nsIInterfaceRequestor)
                                 .getInterface(Ci.nsIDocShell).chromeEventHandler,
                   info: {
                     addon: info.addon,
+                    source: "AMO",
                     icon: info.addon.iconURL,
                     permissions: info.addon.userPermissions,
                     resolve,
                     reject,
                   },
                 },
               };
               Services.obs.notifyObservers(subject, "webextension-permission-prompt", null);