Bug 1433335 - Send AddonManager telemetry events on extension prompts. draft
authorLuca Greco <lgreco@mozilla.com>
Mon, 23 Jul 2018 17:02:33 +0200
changeset 830179 6fce46fa73af5dc250be113e0281d9d55f9f67bc
parent 830178 201f12568db6a51fd2539d1864b4d7b901fbac41
push id118822
push userluca.greco@alcacoop.it
push dateMon, 20 Aug 2018 14:44:05 +0000
bugs1433335
milestone63.0a1
Bug 1433335 - Send AddonManager telemetry events on extension prompts. MozReview-Commit-ID: D1pgGPLRXnF
browser/base/content/test/webextensions/browser_extension_sideloading.js
browser/base/content/test/webextensions/browser_extension_update_background.js
browser/base/content/test/webextensions/head.js
browser/modules/ExtensionsUI.jsm
toolkit/components/telemetry/Events.yaml
toolkit/mozapps/extensions/AddonManager.jsm
--- a/browser/base/content/test/webextensions/browser_extension_sideloading.js
+++ b/browser/base/content/test/webextensions/browser_extension_sideloading.js
@@ -44,16 +44,55 @@ async function createXULExtension(detail
 }
 
 function promiseEvent(eventEmitter, event) {
   return new Promise(resolve => {
     eventEmitter.once(event, resolve);
   });
 }
 
+function testTelemetryEvents() {
+  const expectedExtra = {source: "app-profile", method: "sideload"};
+  const noPermissionsExtra = {...expectedExtra, perms_length: "0", origins_length: "0"};
+  const baseEvent = {object: "extension", extra: expectedExtra};
+  const createBaseEventAddon = (n) => ({...baseEvent, value: `addon${n}@tests.mozilla.org`});
+  const getEventsForAddonId = (events, addonId) => events.filter(ev => ev.value === addonId);
+
+  const amEvents = getTelemetryEvents();
+
+  // Test telemetry events for addon1 (1 permission and 1 origin).
+  const baseEventAddon1 = createBaseEventAddon(1);
+  Assert.deepEqual(getEventsForAddonId(amEvents, baseEventAddon1.value), [
+    {
+      ...baseEventAddon1, method: "sideload_prompt",
+      extra: {...expectedExtra, perms_length: "1", origins_length: "1"},
+    },
+    {...baseEventAddon1, method: "uninstall"},
+  ], "Got the expected telemetry events for addon1");
+
+  // Test telemetry events for addon2 (no permissions).
+  const baseEventAddon2 = createBaseEventAddon(2);
+  Assert.deepEqual(getEventsForAddonId(amEvents, baseEventAddon2.value), [
+    {...baseEventAddon2, method: "sideload_prompt", extra: {...noPermissionsExtra}},
+    {...baseEventAddon2, method: "enable"},
+    {...baseEventAddon2, method: "uninstall"},
+  ], "Got the expected telemetry events for addon2");
+
+  // Test telemetry events for addon3 (no permissions and 1 origin).
+  const baseEventAddon3 = createBaseEventAddon(3);
+  Assert.deepEqual(getEventsForAddonId(amEvents, baseEventAddon3.value), [
+    {
+      ...baseEventAddon3, method: "sideload_prompt",
+      extra: {...expectedExtra, perms_length: "0", origins_length: "1"},
+    },
+    {...baseEventAddon3, method: "enable"},
+    {...baseEventAddon3, method: "uninstall"},
+  ], "Got the expected telemetry events for addon3");
+}
+
 let cleanup;
 
 add_task(async function() {
   const DEFAULT_ICON_URL = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
 
   await SpecialPowers.pushPrefEnv({
     set: [
       ["xpinstall.signatures.required", false],
@@ -105,16 +144,17 @@ add_task(async function() {
 
   registerCleanupFunction(async function() {
     // Return to about:blank when we're done
     gBrowser.selectedBrowser.loadURI("about:blank");
     await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
   });
 
   hookExtensionsTelemetry();
+  hookAMTelemetryEvents();
 
   let changePromise = new Promise(resolve => {
     ExtensionsUI.on("change", function listener() {
       ExtensionsUI.off("change", listener);
       resolve();
     });
   });
   ExtensionsUI._checkForSideloaded();
@@ -265,9 +305,11 @@ add_task(async function() {
 
   await new Promise(resolve => setTimeout(resolve, 100));
 
   for (let addon of [addon1, addon2, addon3, addon4]) {
     await addon.uninstall();
   }
 
   BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+  testTelemetryEvents();
 });
--- a/browser/base/content/test/webextensions/browser_extension_update_background.js
+++ b/browser/base/content/test/webextensions/browser_extension_update_background.js
@@ -1,14 +1,15 @@
 const {AddonManagerPrivate} = ChromeUtils.import("resource://gre/modules/AddonManager.jsm", {});
 
 const ID = "update2@tests.mozilla.org";
 const ID_ICON = "update_icon2@tests.mozilla.org";
 const ID_PERMS = "update_perms@tests.mozilla.org";
 const ID_LEGACY = "legacy_update@tests.mozilla.org";
+const FAKE_INSTALL_TELEMETRY_SOURCE = "fake-install-source";
 
 requestLongerTimeout(2);
 
 function promiseViewLoaded(tab, viewid) {
   let win = tab.linkedBrowser.contentWindow;
   if (win.gViewController && !win.gViewController.isLoading &&
       win.gViewController.currentViewId == viewid) {
      return Promise.resolve();
@@ -47,29 +48,62 @@ add_task(async function setup() {
   registerCleanupFunction(async function() {
     // Return to about:blank when we're done
     gBrowser.selectedBrowser.loadURI("about:blank");
     await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
   });
 });
 
 hookExtensionsTelemetry();
+hookAMTelemetryEvents();
+
+function testTelemetryEvents(addonId) {
+    // Test that the expected telemetry events have been recorded (and that they include the
+  // permission_prompt event).
+  const amEvents = getTelemetryEvents();
+  const updateEvents = amEvents.filter(evt => evt.method === "update").map(evt => {
+    delete evt.value;
+    return evt;
+  });
+
+  Assert.deepEqual(updateEvents.map(evt => evt.extra && evt.extra.step), [
+    // First update (cancelled).
+    "started", "download_started", "download_completed", "permissions_prompt", "cancelled",
+    // Second update (completed).
+    "started", "download_started", "download_completed", "permissions_prompt", "completed",
+  ], "Got the steps from the collected telemetry events");
+
+  const method = "update";
+  const object = "extension";
+  const baseExtra = {
+    addon_id: addonId,
+    source: FAKE_INSTALL_TELEMETRY_SOURCE,
+    step: "permissions_prompt",
+    updated_from: "app",
+  };
+
+  Assert.deepEqual(updateEvents.filter(evt => evt.extra && evt.extra.step === "permissions_prompt"), [
+    {method, object, extra: {...baseExtra, perms_length: "1", origins_length: "1"}},
+    {method, object, extra: {...baseExtra, perms_length: "1", origins_length: "1"}}
+  ], "Got the expected permission_prompts events");
+}
 
 // Helper function to test background updates.
 async function backgroundUpdateTest(url, id, checkIconFn) {
   await 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`],
   ]});
 
   // Install version 1.0 of the test extension
-  let addon = await promiseInstallAddon(url);
+  let addon = await promiseInstallAddon(url, {source: FAKE_INSTALL_TELEMETRY_SOURCE});
+  let addonId = addon.id;
 
   ok(addon, "Addon was installed");
   is(getBadgeStatus(), "", "Should not start out with an addon alert badge");
 
   // Trigger an update check and wait for the update for this addon
   // to be downloaded.
   let updatePromise = promiseInstallEvent(addon, "onDownloadEnded");
 
@@ -165,18 +199,20 @@ async function backgroundUpdateTest(url,
 
   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();
+  await addon.uninstall();
   await SpecialPowers.popPrefEnv();
+
+  testTelemetryEvents(addonId);
 }
 
 function checkDefaultIcon(icon) {
   is(icon, "chrome://mozapps/skin/extensions/extensionGeneric.svg",
      "Popup has the default extension icon");
 }
 
 add_task(() => backgroundUpdateTest(`${BASE}/browser_webext_update1.xpi`,
--- a/browser/base/content/test/webextensions/head.js
+++ b/browser/base/content/test/webextensions/head.js
@@ -7,16 +7,18 @@ XPCOMUtils.defineLazyGetter(this, "Manag
   // eslint-disable-next-line no-shadow
   const {Management} = ChromeUtils.import("resource://gre/modules/Extension.jsm", {});
   return Management;
 });
 
 ChromeUtils.import("resource://testing-common/CustomizableUITestUtils.jsm", this);
 let gCUITestUtils = new CustomizableUITestUtils(window);
 
+const {AMTelemetry} = ChromeUtils.import("resource://gre/modules/AddonManager.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.
@@ -63,23 +65,27 @@ function promiseInstallEvent(addon, even
   });
 }
 
 /**
  * Install an (xpi packaged) extension
  *
  * @param {string} url
  *        URL of the .xpi file to install
+ * @param {Object?} installTelemetryInfo
+ *        an optional object that contains additional details used by the telemetry events.
  *
  * @returns {Promise}
  *          Resolves when the extension has been installed with the Addon
  *          object as the resolution value.
  */
-async function promiseInstallAddon(url) {
-  let install = await AddonManager.getInstallForURL(url, "application/x-xpinstall");
+async function promiseInstallAddon(url, installTelemetryInfo) {
+  let install = await AddonManager.getInstallForURL(url, "application/x-xpinstall",
+                                                    null, null, null, null, null,
+                                                    installTelemetryInfo);
   install.install();
 
   let addon = await new Promise(resolve => {
     install.addListener({
       onInstallEnded(_install, _addon) {
         resolve(_addon);
       },
     });
@@ -487,8 +493,26 @@ function hookExtensionsTelemetry() {
   });
 }
 
 function expectTelemetry(values) {
   Assert.deepEqual(values, collectedTelemetry);
   collectedTelemetry = [];
 }
 
+let collectedTelemetryEvents = [];
+function hookAMTelemetryEvents() {
+  let originalRecordEvent = AMTelemetry.recordEvent;
+  AMTelemetry.recordEvent = (event) => {
+    collectedTelemetryEvents.push(event);
+  };
+  registerCleanupFunction(() => {
+    Assert.deepEqual([], collectedTelemetryEvents, "No unexamined telemetry events after test is finished");
+    AMTelemetry.recordEvent = originalRecordEvent;
+  });
+}
+
+function getTelemetryEvents() {
+  let events = collectedTelemetryEvents;
+  collectedTelemetryEvents = [];
+
+  return events;
+}
--- a/browser/modules/ExtensionsUI.jsm
+++ b/browser/modules/ExtensionsUI.jsm
@@ -116,16 +116,29 @@ var ExtensionsUI = {
     this.sideloaded.delete(addon);
     this._updateNotifications();
 
     let strings = this._buildStrings({
       addon,
       permissions: addon.userPermissions,
       type: "sideload",
     });
+
+    // Notify the "webextension-sideload-prompt" message to allow AMTelemetry to record an event
+    // for this permision prompt.
+    Services.obs.notifyObservers({
+      wrappedJSObject: {
+        info: {
+          type: "sideload",
+          addon,
+          permissions: addon.userPermissions,
+        }
+      }
+    }, "webextension-sideload-prompt");
+
     this.showAddonsManager(browser, strings, addon.iconURL, "sideload")
         .then(async answer => {
           if (answer) {
             await addon.enable();
           }
           this.emit("sideload-response");
         });
   },
--- a/toolkit/components/telemetry/Events.yaml
+++ b/toolkit/components/telemetry/Events.yaml
@@ -98,22 +98,24 @@ addonsManager:
     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"]
+    methods: ["disable", "enable", "sideload_prompt", "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).
+      perms_length: The number of permissions shown to the user for a sideload extension permissions doorhanger
+      origins_length: The number of origins shown to the user for a sideload extension permissions doorhanger
     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:
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -1236,17 +1236,20 @@ var AddonManagerInternal = {
     if (difference.origins.length == 0 && difference.permissions.length == 0) {
       return Promise.resolve();
     }
 
     return new Promise((resolve, reject) => {
       let subject = {wrappedJSObject: {
         addon: info.addon,
         permissions: difference,
-        resolve, reject
+        resolve, reject,
+        // Reference to the related AddonInstall object (used in AMTelemetry to
+        // link the recorded event to the other events from the same install flow).
+        install: info.install,
       }};
       Services.obs.notifyObservers(subject, "webextension-update-permissions");
     });
   },
 
   // Returns true if System Addons should be updated
   systemUpdateEnabled() {
     if (!Services.prefs.getBoolPref(PREF_SYS_ADDON_UPDATE_ENABLED)) {
@@ -3558,16 +3561,20 @@ var AMTelemetry = {
     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");
 
+    Services.obs.addObserver(this, "webextension-permission-prompt");
+    Services.obs.addObserver(this, "webextension-update-permissions");
+    Services.obs.addObserver(this, "webextension-sideload-prompt");
+
     AddonManager.addInstallListener(this);
     AddonManager.addAddonListener(this);
   },
 
   // Observer Service notification callback.
 
   observe(subject, topic, data) {
     switch (topic) {
@@ -3581,16 +3588,44 @@ var AMTelemetry = {
         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;
       }
+      case "webextension-sideload-prompt":
+      case "webextension-permission-prompt": {
+        const {info} = subject.wrappedJSObject;
+        const {permissions, origins} = info.permissions || {permissions: [], origins: []};
+        if (info.type === "sideload") {
+          this.recordManageEvent(info.addon, "sideload_prompt", {
+            perms_length: permissions.length,
+            origins_length: origins.length,
+          });
+        } else {
+          this.recordInstallEvent(info.install, {
+            step: "permissions_prompt",
+            perms_length: permissions.length,
+            origins_length: origins.length,
+          });
+        }
+        break;
+      }
+      case "webextension-update-permissions": {
+        const update = subject.wrappedJSObject;
+        const {permissions, origins} = update.permissions || {permissions: [], origins: []};
+        this.recordInstallEvent(update.install, {
+          step: "permissions_prompt",
+          perms_length: permissions.length,
+          origins_length: origins.length,
+        });
+        break;
+      }
     }
   },
 
   // AddonManager install listener callbacks.
 
   onNewInstall(install) {
     this.recordInstallEvent(install, {step: "started"});
   },