Bug 1544927 - Record telemetry on integrated about:addons abuse reporting. r=aswan,janerik
authorLuca Greco <lgreco@mozilla.com>
Mon, 06 May 2019 18:38:28 +0000
changeset 472754 692bd19485ff6f49ce7c074d9a52fa9ac2c42eec
parent 472753 dc5df04e9ce9c61d95b8e1b5e2183c8ebe0fc41b
child 472755 a7e62502388cab9d11a42f7596a611df477c27a7
push id35978
push usershindli@mozilla.com
push dateTue, 07 May 2019 09:44:39 +0000
treeherdermozilla-central@7aee5a30dd15 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan, janerik
bugs1544927
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 1544927 - Record telemetry on integrated about:addons abuse reporting. r=aswan,janerik Differential Revision: https://phabricator.services.mozilla.com/D29184
toolkit/components/telemetry/Events.yaml
toolkit/mozapps/extensions/AbuseReporter.jsm
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/test/browser/browser_html_abuse_report.js
toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js
--- a/toolkit/components/telemetry/Events.yaml
+++ b/toolkit/components/telemetry/Events.yaml
@@ -177,16 +177,35 @@ addonsManager:
       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]
     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
+    - uninstall
+    extra_keys:
+      addon_type: The type of the add-on being reported (missing on ERROR_ADDON_NOT_FOUND).
+      error_type: >
+        AbuseReport Error Type (included in case of submission failures). The error types include
+        ERROR_ABORTED_SUBMIT, ERROR_ADDON_NOT_FOUND, ERROR_CLIENT, ERROR_NETWORK, ERROR_UNKNOWN,
+        ERROR_RECENT_SUBMIT, ERROR_SERVER
+    notification_emails: ["addons-dev-internal@mozilla.com"]
+    expiry_version: "73"
+    record_in_processes: ["main"]
+    bug_numbers: [1544927]
+    release_channel_collection: opt-out
 
 extensions.data:
   migrateResult:
     objects: ["storageLocal"]
     bug_numbers: [1470213]
     notification_emails: ["addons-dev-internal@mozilla.com"]
     expiry_version: "70"
     record_in_processes: ["main"]
--- a/toolkit/mozapps/extensions/AbuseReporter.jsm
+++ b/toolkit/mozapps/extensions/AbuseReporter.jsm
@@ -9,16 +9,17 @@ const {XPCOMUtils} = ChromeUtils.import(
 Cu.importGlobalProperties(["fetch"]);
 
 const PREF_ABUSE_REPORT_URL  = "extensions.abuseReport.url";
 // Minimum time between report submissions (in ms).
 const MIN_MS_BETWEEN_SUBMITS = 30000;
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManager: "resource://gre/modules/AddonManager.jsm",
+  AMTelemetry: "resource://gre/modules/AddonManager.jsm",
   AppConstants: "resource://gre/modules/AppConstants.jsm",
   ClientID: "resource://gre/modules/ClientID.jsm",
   Services: "resource://gre/modules/Services.jsm",
 });
 
 XPCOMUtils.defineLazyPreferenceGetter(this, "ABUSE_REPORT_URL", PREF_ABUSE_REPORT_URL);
 
 const PRIVATE_REPORT_PROPS = Symbol("privateReportProps");
@@ -82,16 +83,21 @@ const AbuseReporter = {
    * @returns {AbuseReport}
    *          An instance of the AbuseReport class, which represent an ongoing
    *          report.
    */
   async createAbuseReport(addonId, {reportEntryPoint} = {}) {
     const addon = await AddonManager.getAddonByID(addonId);
 
     if (!addon) {
+      AMTelemetry.recordReportEvent({
+        addonId,
+        errorType: "ERROR_ADDON_NOTFOUND",
+        reportEntryPoint,
+      });
       throw new AbuseReportError("ERROR_ADDON_NOTFOUND");
     }
 
     const reportData = await this.getReportData(addon);
 
     return new AbuseReport({
       addon,
       reportData,
@@ -229,16 +235,26 @@ class AbuseReport {
       aborted: false,
       abortController: new AbortController(),
       addon,
       reportData,
       reportEntryPoint,
     };
   }
 
+  recordTelemetry(errorType) {
+    const {addon, reportEntryPoint} = this;
+    AMTelemetry.recordReportEvent({
+      addonId: addon.id,
+      addonType: addon.type,
+      errorType,
+      reportEntryPoint,
+    });
+  }
+
   /**
    * Submit the current report, given a reason and a message.
    *
    * @params {object} options
    * @params {string} options.reason
    *         String identifier for the report reason.
    * @params {string} [options.message]
    *         An optional string which contains a description for the reported issue.
@@ -250,25 +266,31 @@ class AbuseReport {
    */
   async submit({reason, message}) {
     const {
       aborted, abortController,
       reportData,
       reportEntryPoint,
     } = this[PRIVATE_REPORT_PROPS];
 
+    // Record telemetry event and throw an AbuseReportError.
+    const throwReportError = (errorType) => {
+      this.recordTelemetry(errorType);
+      throw new AbuseReportError(errorType);
+    };
+
     if (aborted) {
       // Report aborted before being actually submitted.
-      throw new AbuseReportError("ERROR_ABORTED_SUBMIT");
+      throwReportError("ERROR_ABORTED_SUBMIT");
     }
 
     // Prevent submit of a new abuse report in less than MIN_MS_BETWEEN_SUBMITS.
     let msFromLastReport = AbuseReporter.getTimeFromLastReport();
     if (msFromLastReport < MIN_MS_BETWEEN_SUBMITS) {
-      throw new AbuseReportError("ERROR_RECENT_SUBMIT");
+      throwReportError("ERROR_RECENT_SUBMIT");
     }
 
     let response;
     try {
       response = await fetch(ABUSE_REPORT_URL, {
         signal: abortController.signal,
         method: "POST",
         credentials: "omit",
@@ -278,39 +300,45 @@ class AbuseReport {
           ...reportData,
           report_entry_point: reportEntryPoint,
           message,
           reason,
         }),
       });
     } catch (err) {
       if (err.name === "AbortError") {
-        throw new AbuseReportError("ERROR_ABORTED_SUBMIT");
+        throwReportError("ERROR_ABORTED_SUBMIT");
       }
       Cu.reportError(err);
-      throw new AbuseReportError("ERROR_NETWORK");
+      throwReportError("ERROR_NETWORK");
     }
 
     if (response.ok && response.status >= 200 && response.status < 400) {
       // Ensure that the response is also a valid json format.
-      await response.json();
+      try {
+        await response.json();
+      } catch (err) {
+        this.recordTelemetry("ERROR_UNKNOWN");
+        throw err;
+      }
       AbuseReporter.updateLastReportTimestamp();
+      this.recordTelemetry();
       return;
     }
 
     if (response.status >= 400 && response.status < 500) {
-      throw new AbuseReportError("ERROR_CLIENT");
+      throwReportError("ERROR_CLIENT");
     }
 
     if (response.status >= 500 && response.status < 600) {
-      throw new AbuseReportError("ERROR_SERVER");
+      throwReportError("ERROR_SERVER");
     }
 
     // We got an unexpected HTTP status code.
-    throw new AbuseReportError("ERROR_UNKNOWN");
+    throwReportError("ERROR_UNKNOWN");
   }
 
   /**
    * Abort the report submission.
    */
   abort() {
     const {abortController} = this[PRIVATE_REPORT_PROPS];
     abortController.abort();
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -3896,16 +3896,42 @@ AMTelemetry = {
     this.recordEvent({
       method: "view",
       object: "aboutAddons",
       value: view,
       extra: this.formatExtraVars({type, addon}),
     });
   },
 
+  /**
+   * Record an event on abuse report submissions.
+   *
+   * @params {object} opts
+   * @params {string} opts.addonId
+   *         The id of the addon being reported.
+   * @params {string} [opts.addonType]
+   *         The type of the addon being reported  (only present for an existing
+   *         addonId).
+   * @params {string} [opts.errorType]
+   *         The AbuseReport errorType for a submission failure.
+   * @params {string} opts.reportEntryPoint
+   *         The entry point of the abuse report.
+   */
+  recordReportEvent({addonId, addonType, errorType, reportEntryPoint}) {
+    this.recordEvent({
+      method: "report",
+      object: reportEntryPoint,
+      value: addonId,
+      extra: this.formatExtraVars({
+        addon_type: addonType,
+        error_type: errorType,
+      }),
+    });
+  },
+
   recordEvent({method, object, value, extra}) {
     if (typeof value != "string") {
       // The value must be a string or null, make sure it's valid so sending
       // the event doesn't fail.
       value = null;
     }
     try {
       Services.telemetry.recordEvent("addonsManager", method, object, value, extra);
--- a/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report.js
@@ -11,17 +11,17 @@ const {
 } = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
 const {
   ExtensionCommon,
 } = ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm");
 
 const {makeWidgetId} = ExtensionCommon;
 
 const ADDON_ID = "test-extension-to-report@mochi.test";
-const REPORT_ENTRY_POINT = "test-entrypoint";
+const REPORT_ENTRY_POINT = "menu";
 const BASE_TEST_MANIFEST = {
   name: "Fake extension to report",
   author: "Fake author",
   homepage_url: "https://fake.extension.url/",
   applications: {gecko: {id: ADDON_ID}},
   icons: {
     32: "test-icon.png",
   },
--- a/toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js
@@ -3,23 +3,30 @@
  */
 
 const {
   AbuseReporter,
   AbuseReportError,
 } = ChromeUtils.import("resource://gre/modules/AbuseReporter.jsm");
 
 const {ClientID} = ChromeUtils.import("resource://gre/modules/ClientID.jsm");
+const {
+  TelemetryController,
+} = ChromeUtils.import("resource://gre/modules/TelemetryController.jsm");
+const {
+  TelemetryTestUtils,
+} = ChromeUtils.import("resource://testing-common/TelemetryTestUtils.jsm");
 
 const APPNAME = "XPCShell";
 const APPVERSION = "1";
 const ADDON_ID = "test-addon@tests.mozilla.org";
 const ADDON_ID2 = "test-addon2@tests.mozilla.org";
 const FAKE_INSTALL_INFO = {source: "fake-install-method"};
 const REPORT_OPTIONS = {reportEntryPoint: "menu"};
+const TELEMETRY_EVENTS_FILTERS = {category: "addonsManager", method: "report"};
 
 createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
 
 let apiRequestHandler;
 const server = createHttpServer({hosts: ["test.addons.org"]});
 server.registerPathHandler("/api/report/", (request, response) => {
   const stream = request.bodyInputStream;
   const buffer = NetUtil.readInputStream(stream, stream.available());
@@ -85,16 +92,28 @@ async function assertBaseReportData({rep
   equal(reportData.operating_system, AppConstants.platform, "Got expected 'operating_system'");
   equal(reportData.operating_system_version, Services.sysinfo.getProperty("version"),
         "Got expected 'operating_system_version'");
 }
 
 add_task(async function test_setup() {
   Services.prefs.setCharPref("extensions.abuseReport.url", "http://test.addons.org/api/report/");
   await promiseStartupManager();
+  // Telemetry test setup needed to ensure that the builtin events are defined
+  // and they can be collected and verified.
+  await TelemetryController.testSetup();
+
+  // This is actually only needed on Android, because it does not properly support unified telemetry
+  // and so, if not enabled explicitly here, it would make these tests to fail when running on a
+  // non-Nightly build.
+  const oldCanRecordBase = Services.telemetry.canRecordBase;
+  Services.telemetry.canRecordBase = true;
+  registerCleanupFunction(() => {
+    Services.telemetry.canRecordBase = oldCanRecordBase;
+  });
 });
 
 add_task(async function test_addon_report_data() {
   info("Verify report property for a privileged extension");
   const {addon, extension} = await installTestExtension();
   const data = await AbuseReporter.getReportData(addon);
   await assertBaseReportData({reportData: data, addon});
   await extension.unload();
@@ -117,19 +136,27 @@ add_task(async function test_addon_repor
   } = await installTestExtension({useAddonManager: "temporary"});
   const data3 = await AbuseReporter.getReportData(addon3);
   equal(data3.addon_install_method, "temporary_addon",
         "Got expected 'addon_install_method' on temporary install");
   await extension3.unload();
 });
 
 add_task(async function test_report_on_not_installed_addon() {
+  Services.telemetry.clearEvents();
+
   await assertRejectsAbuseReportError(
     AbuseReporter.createAbuseReport(ADDON_ID, REPORT_OPTIONS),
     "ERROR_ADDON_NOTFOUND");
+
+  TelemetryTestUtils.assertEvents([{
+    object: REPORT_OPTIONS.reportEntryPoint,
+    value: ADDON_ID,
+    extra: {error_type: "ERROR_ADDON_NOTFOUND"},
+  }], TELEMETRY_EVENTS_FILTERS);
 });
 
 // This tests verifies the mapping between the addon installTelemetryInfo
 // values and the addon_install_method expected by the API endpoint.
 add_task(async function test_addon_install_method_mapping() {
   async function assertAddonInstallMethod(amInstallTelemetryInfo, expected) {
     const {addon, extension} = await installTestExtension({amInstallTelemetryInfo});
     const {addon_install_method} = await AbuseReporter.getReportData(addon);
@@ -160,16 +187,18 @@ add_task(async function test_addon_insta
   ];
 
   for (const [expected, telemetryInfo] of TEST_CASES) {
     await assertAddonInstallMethod(telemetryInfo, expected);
   }
 });
 
 add_task(async function test_report_create_and_submit() {
+  Services.telemetry.clearEvents();
+
   // Override the test api server request handler, to be able to
   // intercept the submittions to the test api server.
   let reportSubmitted;
   apiRequestHandler = ({data, request, response}) => {
     reportSubmitted = JSON.parse(data);
     handleSubmitRequest({request, response});
   };
 
@@ -196,57 +225,83 @@ add_task(async function test_report_crea
     ...reportProperties,
   });
 
   for (const [expectedKey, expectedValue] of expectedEntries) {
     equal(reportSubmitted[expectedKey], expectedValue,
           `Got the expected submitted value for "${expectedKey}"`);
   }
 
+  TelemetryTestUtils.assertEvents([{
+    object: reportEntryPoint,
+    value: ADDON_ID,
+    extra: {addon_type: "extension"},
+  }], TELEMETRY_EVENTS_FILTERS);
+
   await extension.unload();
 });
 
 add_task(async function test_error_recent_submit() {
+  Services.telemetry.clearEvents();
   await clearAbuseReportState();
 
   let reportSubmitted;
   apiRequestHandler = ({data, request, response}) => {
     reportSubmitted = JSON.parse(data);
     handleSubmitRequest({request, response});
   };
 
   const {extension} = await installTestExtension();
-  const report = await AbuseReporter.createAbuseReport(ADDON_ID, REPORT_OPTIONS);
+  const report = await AbuseReporter.createAbuseReport(ADDON_ID, {
+    reportEntryPoint: "uninstall",
+  });
 
   const {extension: extension2} = await installTestExtension({
     manifest: {
       applications: {gecko: {id: ADDON_ID2}},
       name: "Test Extension2",
     },
   });
   const report2 = await AbuseReporter.createAbuseReport(ADDON_ID2, REPORT_OPTIONS);
 
   // Submit the two reports in fast sequence.
   await report.submit({reason: "reason1"});
   await assertRejectsAbuseReportError(report2.submit({reason: "reason2"}),
                                       "ERROR_RECENT_SUBMIT");
   equal(reportSubmitted.reason, "reason1",
         "Server only received the data from the first submission");
 
+  TelemetryTestUtils.assertEvents([
+    {
+      object: "uninstall",
+      value: ADDON_ID,
+      extra: {addon_type: "extension"},
+    },
+    {
+      object: REPORT_OPTIONS.reportEntryPoint,
+      value: ADDON_ID2,
+      extra: {
+        addon_type: "extension",
+        error_type: "ERROR_RECENT_SUBMIT",
+      },
+    },
+  ], TELEMETRY_EVENTS_FILTERS);
+
   await extension.unload();
   await extension2.unload();
 });
 
 add_task(async function test_submission_server_error() {
   const {extension} = await installTestExtension();
 
   async function testErrorCode(
     responseStatus, expectedErrorType, expectRequest = true
   ) {
     info(`Test expected AbuseReportError on response status "${responseStatus}"`);
+    Services.telemetry.clearEvents();
     await clearAbuseReportState();
 
     let requestReceived = false;
     apiRequestHandler = ({request, response}) => {
       requestReceived = true;
       response.setStatusLine(request.httpVersion, responseStatus, "Error");
       response.write("");
     };
@@ -257,16 +312,26 @@ add_task(async function test_submission_
       // Assert a specific AbuseReportError errorType.
       await assertRejectsAbuseReportError(promiseSubmit, expectedErrorType);
     } else {
       // Assert on a given Error class.
       await Assert.rejects(promiseSubmit, expectedErrorType);
     }
     equal(requestReceived, expectRequest,
           `${expectRequest ? "" : "Not "}received a request as expected`);
+
+    TelemetryTestUtils.assertEvents([{
+      object: REPORT_OPTIONS.reportEntryPoint,
+      value: ADDON_ID,
+      extra: {
+        addon_type: "extension",
+        error_type: typeof expectedErrorType === "string" ?
+          expectedErrorType : "ERROR_UNKNOWN",
+      },
+    }], TELEMETRY_EVENTS_FILTERS);
   }
 
   await testErrorCode(500, "ERROR_SERVER");
   await testErrorCode(404, "ERROR_CLIENT");
   // Test response with unexpected status code.
   await testErrorCode(604, "ERROR_UNKNOWN");
   // Test response status 200 with invalid json data.
   await testErrorCode(200, /SyntaxError: JSON.parse/);
@@ -280,16 +345,17 @@ add_task(async function test_submission_
 });
 
 add_task(async function set_test_abusereport_url() {
   Services.prefs.setCharPref("extensions.abuseReport.url",
                              "http://test.addons.org/api/report/");
 });
 
 add_task(async function test_submission_aborting() {
+  Services.telemetry.clearEvents();
   await clearAbuseReportState();
 
   const {extension} = await installTestExtension();
 
   // override the api request handler with one that is never going to reply.
   let receivedRequestsCount = 0;
   let resolvePendingResponses;
   const waitToReply = new Promise(resolve => resolvePendingResponses = resolve);
@@ -317,15 +383,21 @@ add_task(async function test_submission_
   ok(receivedRequestsCount > 0, "Got the expected number of requests");
   ok(await Promise.race([promiseResult, Promise.resolve("pending")]) === "pending",
     "Submission fetch request should still be pending");
 
   report.abort();
 
   await assertRejectsAbuseReportError(promiseResult, "ERROR_ABORTED_SUBMIT");
 
+  TelemetryTestUtils.assertEvents([{
+    object: REPORT_OPTIONS.reportEntryPoint,
+    value: ADDON_ID,
+    extra: {addon_type: "extension", error_type: "ERROR_ABORTED_SUBMIT"},
+  }], TELEMETRY_EVENTS_FILTERS);
+
   await extension.unload();
 
   // Unblock pending requests on the server request handler side, so that the
   // test file can shutdown (otherwise the test run will be stuck after this
   // task completed).
   resolvePendingResponses();
 });