Bug 1546248 - Add telemetry to discopane r=rpl,chutten
authorRob Wu <rob@robwu.nl>
Mon, 13 May 2019 15:03:22 +0000
changeset 532430 04b9eaf2e4391fa9e0cc9b70b875b4c7628b8c82
parent 532429 96e678846de742425e0f48cbea15bad6731160f4
child 532431 37b9048629cfd3197b289f9c5d396116b6a0eb1f
push id11268
push usercsabou@mozilla.com
push dateTue, 14 May 2019 15:24:22 +0000
treeherdermozilla-beta@5fb7fcd568d6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrpl, chutten
bugs1546248, 1523406
milestone68.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1546248 - Add telemetry to discopane r=rpl,chutten This adds telemetry with new actions and link values to uniquely identify interactions with the discovery pane and recommended add-on cards. The card may appear in other views (bug 1523406), so the event names must carefully be chosen to avoid confusion with other events. The new "installFromRecommendation" and most link values are unique for this reason. The "manage" event can safely be reused, because it is only shown in the discopane (in other views, the recommended add-on card would be replaced with an actual add-on card). Unrelated to this change, the documentation of "homepage" at Events.yaml has been updated to clarify that it can also be a URL outside of AMO, because that is the reality. Differential Revision: https://phabricator.services.mozilla.com/D30533
toolkit/components/telemetry/Events.yaml
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/content/aboutaddons.js
toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js
--- a/toolkit/components/telemetry/Events.yaml
+++ b/toolkit/components/telemetry/Events.yaml
@@ -136,28 +136,30 @@ addonsManager:
     expiry_version: "73"
     record_in_processes: ["main"]
     bug_numbers: [1433335, 1515697, 1523641]
     release_channel_collection: opt-out
   link:
     description: >
       A link method event triggered when a user follows a link. The object is the page that the link is on:
       aboutAddons or aboutPreferences. The value is where the link goes: about:addons, about:preferences,
-      about:debugging, support (on SUMO) or rating, search, author or homepage (on AMO).
+      about:debugging, support (on SUMO) or rating, search, author (on AMO) or homepage (on AMO or elsewhere),
+      discohome (on AMO via a recommended add-on card),
+      discomore (on AMO via discover), disconotice (on SUMO via discover)
     objects:
       - aboutAddons
       - aboutPreferences
       - customize
     extra_keys:
-      view: The view the user was on (list, detail or updates).
+      view: The view the user was on (discover, list, detail or updates).
       type: "For search: the type of page for this view (especially extension or theme list)."
     notification_emails: ["addons-dev-internal@mozilla.com"]
     expiry_version: "73"
     record_in_processes: ["main"]
-    bug_numbers: [1500147]
+    bug_numbers: [1500147, 1546248]
     release_channel_collection: opt-out
   view:
     description: >
       A view method event is triggered when a user views a page in about:addons. The object is always
       aboutAddons. The value is the view name: discover, list, updates or detail.
     objects:
       - aboutAddons
     extra_keys:
@@ -183,25 +185,25 @@ addonsManager:
       - customize
       - pageAction
       - doorhanger
       - appUpgrade
     extra_keys:
       action: >
         The action that was performed. Options include disable, enable, uninstall, undo, contribute, preferences,
         installFromFile, manage, dismiss, checkForUpdates, checkForUpdate, setUpdatePolicy, setAddonUpdate,
-        resetUpdatePolicy, privateBrowsingAllowed and releaseNotes.
-      type: "For enable, disable, uninstall and undo: the add-on type that is being acted upon."
+        installFromRecommendation, resetUpdatePolicy, privateBrowsingAllowed and releaseNotes.
+      type: "For enable, disable, uninstall, undo and installFromRecommendation: the add-on type that is being acted upon."
       view: >
         The view for the event when object is aboutAddons, or the specific doorhanger when object is doorhanger.
       addonId: The id of the add-on being acted upon.
     notification_emails: ["addons-dev-internal@mozilla.com"]
     expiry_version: "73"
     record_in_processes: ["main"]
-    bug_numbers: [1500147, 1513344, 1529347]
+    bug_numbers: [1500147, 1513344, 1529347, 1546248]
     release_channel_collection: opt-out
   report:
     description: >
       An abuse report submitted by a user for a given extension. The object of the event
       represent the report entry point, the value is the id of the addon being reported.
     objects:
     - menu
     - toolbar_context_menu
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -3852,19 +3852,23 @@ AMTelemetry = {
    *
    * @param {object} opts
    * @param {string} opts.object
    *        The object of the event, should an identifier for where the action
    *        took place. The accepted values are listed in the
    *        addonsManager.action object of the Events.yaml file.
    * @param {string} opts.action The identifier for the action.
    * @param {string} opts.value An optional value for the action.
-   * @param {AddonWrapper} opts.addon
-   *        An optional add-on object related to the event. Passing this will
-   *        set extra.addonId and extra.type based on the add-on.
+   * @param {object} opts.addon
+   *        An optional object with the "id" and "type" properties, for example
+   *        an AddonWrapper object. Passing this will set some extra properties.
+   * @param {string} opts.addon.id
+   *        The add-on ID to assign to extra.addonId.
+   * @param {string} opts.addon.type
+   *        The add-on type to assign to extra.type.
    * @param {string} opts.view The current view, when object is aboutAddons.
    * @param {object} opts.extra
    *        The extra data to be sent, all keys must be registered in the
    *        extra_keys section of addonsManager.action in Events.yaml. If
    *        opts.addon is passed then it will overwrite the addonId and type
    *        properties in this object, if they are set.
    */
   recordActionEvent({object, action, value, addon, view, extra}) {
--- a/toolkit/mozapps/extensions/content/aboutaddons.js
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -7,16 +7,17 @@
 /* import-globals-from abuse-reports.js */
 /* global windowRoot */
 
 "use strict";
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManager: "resource://gre/modules/AddonManager.jsm",
   AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm",
+  AMTelemetry: "resource://gre/modules/AddonManager.jsm",
   ClientID: "resource://gre/modules/ClientID.jsm",
   ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm",
   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
 });
 
 XPCOMUtils.defineLazyPreferenceGetter(
   this, "allowPrivateBrowsingByDefault",
   "extensions.allowPrivateBrowsingByDefault", true);
@@ -1300,24 +1301,55 @@ class RecommendedAddonCard extends HTMLE
 
     this.addEventListener("click", this);
   }
 
   handleEvent(event) {
     let action = event.target.getAttribute("action");
     switch (action) {
       case "install-addon":
+        AMTelemetry.recordActionEvent({
+          object: "aboutAddons",
+          view: this.getTelemetryViewName(),
+          action: "installFromRecommendation",
+          addon: this.discoAddon,
+        });
         this.installDiscoAddon();
         break;
       case "manage-addon":
+        AMTelemetry.recordActionEvent({
+          object: "aboutAddons",
+          view: this.getTelemetryViewName(),
+          action: "manage",
+          addon: this.discoAddon,
+        });
         loadViewFn("detail", this.addonId);
         break;
+      default:
+        if (event.target.matches(".disco-addon-author a[href]")) {
+          AMTelemetry.recordLinkEvent({
+            object: "aboutAddons",
+            // Note: This is not "author" nor "homepage", because the link text
+            // is the author name, but the link URL the add-on's listing URL.
+            value: "discohome",
+            extra: {
+              view: this.getTelemetryViewName(),
+            },
+          });
+        }
     }
   }
 
+  /**
+   * The name of the view for use in addonsManager telemetry events.
+   */
+  getTelemetryViewName() {
+    return "discover";
+  }
+
   async installDiscoAddon() {
     let addon = this.discoAddon;
     let url = addon.sourceURI.spec;
     let install = await AddonManager.getInstallForURL(url, {
       name: addon.name,
       telemetryInfo: {source: "disco"},
     });
     // We are hosted in a <browser> in about:addons, but we can just use the
@@ -1778,21 +1810,37 @@ class DiscoveryPane extends HTMLElement 
     this.querySelector("recommended-addon-list").loadCardsIfNeeded()
       .finally(() => { footer.hidden = false; });
   }
 
   handleEvent(event) {
     let action = event.target.getAttribute("action");
     switch (action) {
       case "notice-learn-more":
+        // The element is a button but opens a URL, so record as link.
+        AMTelemetry.recordLinkEvent({
+          object: "aboutAddons",
+          value: "disconotice",
+          extra: {
+            view: "discover",
+          },
+        });
         windowRoot.ownerGlobal.openTrustedLinkIn(
           Services.urlFormatter.formatURLPref("app.support.baseURL") +
           "personalized-extension-recommendations", "tab");
         break;
       case "open-amo":
+        // The element is a button but opens a URL, so record as link.
+        AMTelemetry.recordLinkEvent({
+          object: "aboutAddons",
+          value: "discomore",
+          extra: {
+            view: "discover",
+          },
+        });
         let amoUrl =
           Services.urlFormatter.formatURLPref("extensions.getAddons.link.url");
         amoUrl = formatAmoUrl("find-more-link-bottom", amoUrl);
         windowRoot.ownerGlobal.openTrustedLinkIn(amoUrl, "tab");
         break;
     }
   }
 }
--- a/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
@@ -7,16 +7,20 @@ const {
 
 const {
   ExtensionUtils: {
     promiseEvent,
     promiseObserved,
   },
 }  = ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
 
+const {
+  TelemetryTestUtils,
+} = ChromeUtils.import("resource://testing-common/TelemetryTestUtils.jsm");
+
 // The response to the discovery API, as documented at:
 // https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
 //
 // The test is designed to easily verify whether the discopane works with the
 // latest AMO API, by replacing API_RESPONSE_FILE's content with latest AMO API
 // response, e.g. from https://addons.allizom.org/api/v4/discovery/?lang=en-US
 // The response must contain at least one theme, and one extension.
 const API_RESPONSE_FILE = RELATIVE_DIR + "discovery/api_response.json";
@@ -29,16 +33,19 @@ const ArrayBufferInputStream =
 
 AddonTestUtils.initMochitest(this);
 
 const amoServer = AddonTestUtils.createHttpServer({hosts: [AMO_TEST_HOST]});
 
 amoServer.registerFile("/png",
   FileUtils.getFile("CurWorkD",
                     `${RELATIVE_DIR}discovery/small-1x1.png`.split("/")));
+amoServer.registerPathHandler("/dummy", (request, response) => {
+  response.write("Dummy");
+});
 
 // `result` is an element in the `results` array from AMO's discovery API,
 // stored in API_RESPONSE_FILE.
 function getTestExpectationFromApiResult(result) {
   return {
     typeIsTheme: result.addon.type === "statictheme",
     addonName: result.addon.name,
     authorName: result.addon.authors[0].name,
@@ -252,16 +259,26 @@ async function testAddonUninstall(card) 
   await updatePromise;
 
   Assert.deepEqual(
     getVisibleActions(card).map(getActionName),
     ["install-addon"],
     "Should have an Install button after uninstall");
 }
 
+function checkTelemetryEvents(expectations) {
+  TelemetryTestUtils.assertEvents(expectations, {
+    category: "addonsManager",
+    method(actual) {
+      return actual === "action" || actual === "link";
+    },
+    object: "aboutAddons",
+  });
+}
+
 add_task(async function setup() {
   await SpecialPowers.pushPrefEnv({
     set: [
       ["extensions.getAddons.discovery.api_url",
        `http://${AMO_TEST_HOST}/discoapi`],
       // Enable HTML for all because some tests load non-discopane views.
       ["extensions.htmlaboutaddons.enabled", true],
       ["extensions.htmlaboutaddons.discover.enabled", true],
@@ -380,16 +397,18 @@ add_task(async function install_from_dis
   const FIRST_THEME_ID = getAddonIdByAMOAddonType("statictheme");
 
   let apiHandler = new DiscoveryAPIHandler(apiText);
 
   let win = await loadInitialView("discover");
   await promiseDiscopaneUpdate(win);
   await waitForAllImagesLoaded(win);
 
+  Services.telemetry.clearEvents();
+
   // Test extension install.
   let installExtensionPromise = promiseAddonInstall(amoServer, {
     manifest: {
       name: "My Awesome Add-on",
       description: "Test extension install button",
       applications: {gecko: {id: FIRST_EXTENSION_ID}},
       permissions: ["<all_urls>"],
     },
@@ -423,22 +442,69 @@ add_task(async function install_from_dis
     [
       "manage-addon",
       "manage-addon",
       ...new Array(apiResultArray.length - 2).fill("install-addon"),
       "open-amo",
     ],
     "The Install buttons should be replaced with Manage buttons");
 
+  checkTelemetryEvents([{
+    category: "addonsManager",
+    method: "action",
+    object: "aboutAddons",
+    extra: {
+      action: "installFromRecommendation",
+      view: "discover",
+      addonId: FIRST_EXTENSION_ID,
+      type: "extension",
+    },
+  }, {
+    category: "addonsManager",
+    method: "action",
+    object: "aboutAddons",
+    extra: {
+      action: "installFromRecommendation",
+      view: "discover",
+      addonId: FIRST_THEME_ID,
+      type: "theme",
+    },
+  }]);
+
   // End of the testing installation from a card.
+
+  // Click on the Manage button to verify that it does something useful,
+  // and in order to be able to force the discovery pane to be rendered again.
+  let loaded = waitForViewLoad(win);
+  getCardByAddonId(win, FIRST_EXTENSION_ID)
+    .querySelector("[action='manage-addon']").click();
+  await loaded;
+  {
+    let addonCard =
+      win.document.querySelector(
+        `addon-card[addon-id="${FIRST_EXTENSION_ID}"]`);
+    ok(addonCard, "Add-on details should be shown");
+    ok(addonCard.expanded, "The card should have been expanded");
+    // TODO bug 1540253: Check that the "recommended" badge is visible.
+  }
+
+  checkTelemetryEvents([{
+    category: "addonsManager",
+    method: "action",
+    object: "aboutAddons",
+    extra: {
+      action: "manage",
+      view: "discover",
+      addonId: FIRST_EXTENSION_ID,
+      type: "extension",
+    },
+  }]);
+
   // Now we are going to force an updated rendering and check that the cards are
   // in the expected order, and then test uninstallation of the above add-ons.
-
-  // Force the pane to render again.
-  await switchToNonDiscoView(win);
   await switchToDiscoView(win);
   await waitForAllImagesLoaded(win);
 
   Assert.deepEqual(
     getVisibleActions(win.document).map(getActionName),
     [
       ...new Array(apiResultArray.length - 2).fill("install-addon"),
       "manage-addon",
@@ -559,8 +625,87 @@ add_task(async function discopane_no_coo
   });
   Services.cookies.add(AMO_TEST_HOST, "/", "name", "value", false, false,
     false, Date.now() / 1000 + 600, {}, Ci.nsICookie2.SAMESITE_UNSET);
   let win = await loadInitialView("discover");
   let request = await requestPromise;
   ok(!request.hasHeader("Cookie"), "discovery API should not receive cookies");
   await closeView(win);
 });
+
+// Telemetry for interaction that have not been covered by other tests yet.
+// - "Find more addons" button.
+// - Link to add-on listing in recommended add-on card.
+// Other interaction is already covered elsewhere:
+// - Install/Manage buttons have bee tested before in install_from_discopane.
+// - "Learn more" button is checked by browser_html_discover_view_clientid.js.
+add_task(async function discopane_interaction_telemetry() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["extensions.getAddons.link.url", `http://${AMO_TEST_HOST}/dummy`]],
+  });
+  // Minimal API response to get the link in recommended-addon-card to render.
+  const DUMMY_EXTENSION_ID = "dummy@extensionid";
+  const apiResponse = {
+    results: [{
+      addon: {
+        guid: DUMMY_EXTENSION_ID,
+        type: "extension",
+        authors: [{
+          name: "Some author",
+        }],
+        url: `http://${AMO_TEST_HOST}/dummy`,
+        icon_url: `http://${AMO_TEST_HOST}/png`,
+      },
+    }],
+  };
+  let apiHandler = new DiscoveryAPIHandler(JSON.stringify(apiResponse));
+
+  let expectedAmoUrlFor = (where) => {
+    // eslint-disable-next-line max-len
+    return `http://${AMO_TEST_HOST}/dummy?utm_source=firefox-browser&utm_medium=firefox-browser&utm_content=${where}`;
+  };
+
+  let testClickInDiscoCard = async (selector, utmContentParam) => {
+    let tabbrowser = win.windowRoot.ownerGlobal.gBrowser;
+    let tabPromise = BrowserTestUtils.waitForNewTab(tabbrowser);
+    getDiscoveryElement(win).querySelector(selector).click();
+    let tab = await tabPromise;
+    is(tab.linkedBrowser.currentURI.spec,
+       expectedAmoUrlFor(utmContentParam),
+      "Expected URL of new tab");
+    BrowserTestUtils.removeTab(tab);
+  };
+
+  let win = await loadInitialView("discover");
+  await promiseDiscopaneUpdate(win);
+  is(await waitForAllImagesLoaded(win), 1, "One recommendation in results");
+
+  Services.telemetry.clearEvents();
+
+  // "Find more add-ons" button.
+  await testClickInDiscoCard("[action='open-amo']", "find-more-link-bottom");
+
+  // Link to AMO listing
+  await testClickInDiscoCard(".disco-addon-author a", "discopane-entry-link");
+
+  checkTelemetryEvents([{
+    category: "addonsManager",
+    method: "link",
+    object: "aboutAddons",
+    value: "discomore",
+    extra: {
+      view: "discover",
+    },
+  }, {
+    category: "addonsManager",
+    method: "link",
+    object: "aboutAddons",
+    value: "discohome",
+    extra: {
+      view: "discover",
+    },
+  }]);
+
+  is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+  await closeView(win);
+  await SpecialPowers.popPrefEnv();
+});
--- a/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js
@@ -2,16 +2,20 @@
 "use strict";
 
 const {ClientID} = ChromeUtils.import("resource://gre/modules/ClientID.jsm");
 
 const {
   AddonTestUtils,
 } = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
 
+const {
+  TelemetryTestUtils,
+} = ChromeUtils.import("resource://testing-common/TelemetryTestUtils.jsm");
+
 AddonTestUtils.initMochitest(this);
 const server = AddonTestUtils.createHttpServer();
 const serverBaseUrl = `http://localhost:${server.identity.primaryPort}/`;
 server.registerPathHandler("/sumo/personalized-extension-recommendations",
   (request, response) => {
     response.write("This is a SUMO page that explains personalized add-ons.");
   });
 
@@ -71,28 +75,42 @@ add_task(async function clientid_enabled
   // When TelemetryController.jsm detects this, it asynchronously resets the
   // ClientID to knownClientId - which may happen at the next run of the test.
   // TODO: Fix this together with bug 1537933
   //
   // is(await requestPromise, EXPECTED_CLIENT_ID,
   ok(await requestPromise,
      "Moz-Client-Id should be set when telemetry & discovery are enabled");
 
+  Services.telemetry.clearEvents();
+
   let tabbrowser = win.windowRoot.ownerGlobal.gBrowser;
   let expectedUrl =
     `${serverBaseUrl}sumo/personalized-extension-recommendations`;
   let tabPromise = BrowserTestUtils.waitForNewTab(tabbrowser, expectedUrl);
 
   getNoticeButton(win).click();
 
   info(`Waiting for new tab with URL: ${expectedUrl}`);
   let tab = await tabPromise;
   BrowserTestUtils.removeTab(tab);
 
   await closeView(win);
+
+  TelemetryTestUtils.assertEvents([{
+    method: "link",
+    value: "disconotice",
+    extra: {
+      view: "discover",
+    },
+  }], {
+    category: "addonsManager",
+    method: "link",
+    object: "aboutAddons",
+  });
 });
 
 // Test that the clientid is not sent when disabled via prefs.
 add_task(async function clientid_disabled() {
   // Temporarily override the prefs that we had set in setup.
   await SpecialPowers.pushPrefEnv({
     set: [["browser.discovery.enabled", false]],
   });