Bug 1536454 - Part 4 - Add event telemetry for permission prompt studies. r=Ehsan
authorJohann Hofmann <jhofmann@mozilla.com>
Thu, 18 Apr 2019 13:43:29 +0000
changeset 470080 19b40bd2e67f28b2c8b11dc0671cd7337b408911
parent 470079 8c6ec5c528ff0d21865a3d22c743dc21b6061614
child 470081 4b317b418c1410ce46cacd6f52bbdf2b049f3387
push id112843
push useraiakab@mozilla.com
push dateFri, 19 Apr 2019 09:50:22 +0000
treeherdermozilla-inbound@c06f27cbfe40 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersEhsan
bugs1536454
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 1536454 - Part 4 - Add event telemetry for permission prompt studies. r=Ehsan Differential Revision: https://phabricator.services.mozilla.com/D26945
browser/app/profile/firefox.js
browser/components/BrowserGlue.jsm
browser/modules/PermissionUI.jsm
browser/modules/PermissionUITelemetry.jsm
browser/modules/moz.build
toolkit/components/telemetry/Events.yaml
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -414,16 +414,20 @@ pref("permissions.default.shortcuts", 0)
 #ifdef NIGHTLY_BUILD
 pref("permissions.desktop-notification.postPrompt.enabled", true);
 #else
 pref("permissions.desktop-notification.postPrompt.enabled", false);
 #endif
 
 pref("permissions.postPrompt.animate", true);
 
+// This is meant to be enabled only for studies, not for
+// permanent data collection on any channel.
+pref("permissions.eventTelemetry.enabled", false);
+
 // handle links targeting new windows
 // 1=current window/tab, 2=new window, 3=new tab in most recent window
 pref("browser.link.open_newwindow", 3);
 
 // handle external links (i.e. links opened from a different application)
 // default: use browser.link.open_newwindow
 // 1-3: see browser.link.open_newwindow for interpretation
 pref("browser.link.open_newwindow.override.external", -1);
--- a/browser/components/BrowserGlue.jsm
+++ b/browser/components/BrowserGlue.jsm
@@ -990,16 +990,17 @@ BrowserGlue.prototype = {
     os.removeObserver(this, "profile-before-change");
     os.removeObserver(this, "keyword-search");
     os.removeObserver(this, "browser-search-engine-modified");
     os.removeObserver(this, "flash-plugin-hang");
     os.removeObserver(this, "xpi-signature-changed");
     os.removeObserver(this, "sync-ui-state:update");
     os.removeObserver(this, "shield-init-complete");
 
+    Services.prefs.removeObserver("permissions.eventTelemetry.enabled", this._togglePermissionPromptTelemetry);
     Services.prefs.removeObserver("privacy.trackingprotection", this._matchCBCategory);
     Services.prefs.removeObserver("network.cookie.cookieBehavior", this._matchCBCategory);
     Services.prefs.removeObserver(ContentBlockingCategoriesPrefs.PREF_CB_CATEGORY, this._updateCBCategory);
     Services.prefs.removeObserver("browser.contentblocking.features.standard", this._setPrefExpectations);
     Services.prefs.removeObserver("browser.contentblocking.features.strict", this._setPrefExpectations);
   },
 
   // runs on startup, before the first command line handler is invoked
@@ -1353,16 +1354,30 @@ BrowserGlue.prototype = {
   _matchCBCategory() {
     ContentBlockingCategoriesPrefs.matchCBCategory();
   },
 
   _updateCBCategory() {
     ContentBlockingCategoriesPrefs.updateCBCategory();
   },
 
+  _togglePermissionPromptTelemetry() {
+    let enablePermissionPromptTelemetry =
+      Services.prefs.getBoolPref("permissions.eventTelemetry.enabled", false);
+
+    Services.telemetry.setEventRecordingEnabled("security.ui.permissionprompt",
+      enablePermissionPromptTelemetry);
+
+    if (!enablePermissionPromptTelemetry) {
+      // Remove the saved unique identifier to reduce the (remote) chance
+      // of leaking it to our servers in the future.
+      Services.prefs.clearUserPref("permissions.eventTelemetry.uuid");
+    }
+  },
+
   _recordContentBlockingTelemetry() {
     let recordIdentityPopupEvents = Services.prefs.getBoolPref("security.identitypopup.recordEventElemetry");
     Services.telemetry.setEventRecordingEnabled("security.ui.identitypopup", recordIdentityPopupEvents);
 
     let tpEnabled = Services.prefs.getBoolPref("privacy.trackingprotection.enabled");
     Services.telemetry.getHistogramById("TRACKING_PROTECTION_ENABLED").add(tpEnabled);
 
     let tpPBDisabled = Services.prefs.getBoolPref("privacy.trackingprotection.pbmode.enabled");
@@ -1594,16 +1609,21 @@ BrowserGlue.prototype = {
     Services.tm.idleDispatchToMainThread(() => {
       let enableCertErrorUITelemetry =
         Services.prefs.getBoolPref("security.certerrors.recordEventTelemetry", false);
       Services.telemetry.setEventRecordingEnabled("security.ui.certerror",
         enableCertErrorUITelemetry);
     });
 
     Services.tm.idleDispatchToMainThread(() => {
+      Services.prefs.addObserver("permissions.eventTelemetry.enabled", this._togglePermissionPromptTelemetry);
+      this._togglePermissionPromptTelemetry();
+    });
+
+    Services.tm.idleDispatchToMainThread(() => {
       this._recordContentBlockingTelemetry();
     });
 
     // Load the Login Manager data from disk off the main thread, some time
     // after startup.  If the data is required before this runs, for example
     // because a restored page contains a password field, it will be loaded on
     // the main thread, and this initialization request will be ignored.
     Services.tm.idleDispatchToMainThread(() => {
--- a/browser/modules/PermissionUI.jsm
+++ b/browser/modules/PermissionUI.jsm
@@ -65,16 +65,18 @@ const {XPCOMUtils} = ChromeUtils.import(
 ChromeUtils.defineModuleGetter(this, "Services",
   "resource://gre/modules/Services.jsm");
 ChromeUtils.defineModuleGetter(this, "SitePermissions",
   "resource:///modules/SitePermissions.jsm");
 ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "URICountListener",
   "resource:///modules/BrowserUsageTelemetry.jsm");
+ChromeUtils.defineModuleGetter(this, "PermissionUITelemetry",
+  "resource:///modules/PermissionUITelemetry.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "IDNService",
   "@mozilla.org/network/idn-service;1", "nsIIDNService");
 
 XPCOMUtils.defineLazyGetter(this, "gBrowserBundle", function() {
   return Services.strings
                  .createBundle("chrome://browser/locale/browser.properties");
 });
@@ -144,16 +146,27 @@ var PermissionPromptPrototype = {
    * the prompt will be skipped and the allow or deny choice
    * will be selected automatically.
    */
   get permissionKey() {
     return undefined;
   },
 
   /**
+   * A string that needs to be set to include this prompt in
+   * experimental event telemetry collection.
+   *
+   * This needs to conform to event telemetry string rules,
+   * i.e. it needs to be an alphabetic string under 20 characters.
+   */
+  get permissionTelemetryKey() {
+    return undefined;
+  },
+
+  /**
    * If true, user permissions will be read from and written to.
    * When this is false, we still provide integration with
    * infrastructure such as temporary permissions. permissionKey should
    * still return a valid name in those cases for that integration to work.
    */
   get usePermissionManager() {
     return true;
   },
@@ -398,16 +411,18 @@ var PermissionPromptPrototype = {
     }
 
     let chromeWin = this.browser.ownerGlobal;
     if (!chromeWin.PopupNotifications) {
       this.cancel();
       return;
     }
 
+    this._buttonAction = null;
+
     // Transform the PermissionPrompt actions into PopupNotification actions.
     let popupNotificationActions = [];
     for (let promptAction of this.promptActions) {
       let action = {
         label: promptAction.label,
         accessKey: promptAction.accessKey,
         callback: state => {
           if (promptAction.callback) {
@@ -435,19 +450,26 @@ var PermissionPromptPrototype = {
               SitePermissions.set(this.principal.URI,
                                   this.permissionKey,
                                   promptAction.action,
                                   SitePermissions.SCOPE_TEMPORARY,
                                   this.browser);
             }
 
             // Grant permission if action is ALLOW.
+            // Record buttonAction for telemetry.
             if (promptAction.action == SitePermissions.ALLOW) {
+              this._buttonAction = "accept";
               this.allow();
             } else {
+              if (promptAction.scope == SitePermissions.SCOPE_PERSISTENT) {
+                this._buttonAction = "never";
+              } else {
+                this._buttonAction = "deny";
+              }
               this.cancel();
             }
           } else if (this.permissionKey) {
             // TODO: Add support for permitTemporaryAllow
             if (promptAction.action == SitePermissions.BLOCK) {
               // Temporarily store BLOCK permissions.
               // We don't consider subframes when storing temporary
               // permissions on a tab, thus storing ALLOW could be exploited.
@@ -521,32 +543,45 @@ var PermissionPromptPrototype = {
       anchor.addEventListener("animationend", () => anchor.removeAttribute("animate"), {once: true});
       anchor.setAttribute("animate", "true");
     }
 
     this._showNotification(popupNotificationActions, true);
   },
 
   _showNotification(actions, postPrompt = false) {
+    let chromeWin = this.browser.ownerGlobal;
     let mainAction = actions.length ? actions[0] : null;
     let secondaryActions = actions.splice(1);
 
     let options = this.popupOptions;
 
+    let telemetryData = null;
+    if (this.request && this.permissionTelemetryKey) {
+      telemetryData = {
+        permissionTelemetryKey: this.permissionTelemetryKey,
+        permissionKey: this.permissionKey,
+        principal: this.principal,
+        documentDOMContentLoadedTimestamp: this.request.documentDOMContentLoadedTimestamp,
+        isHandlingUserInput: this.request.isHandlingUserInput,
+        userHadInteractedWithDocument: this.request.userHadInteractedWithDocument,
+      };
+    }
+
     if (!options.hasOwnProperty("displayURI") || options.displayURI) {
       options.displayURI = this.principal.URI;
     }
 
     if (!postPrompt) {
       // Permission prompts are always persistent; the close button is controlled by a pref.
       options.persistent = true;
       options.hideClose = true;
     }
 
-    options.eventCallback = (topic) => {
+    options.eventCallback = (topic, nextRemovalReason) => {
       // When the docshell of the browser is aboout to be swapped to another one,
       // the "swapping" event is called. Returning true causes the notification
       // to be moved to the new browser.
       if (topic == "swapping") {
         return true;
       }
       // The prompt has been shown, notify the PermissionUI.
       // onShown() is currently not called for post-prompts,
@@ -557,37 +592,43 @@ var PermissionPromptPrototype = {
         this.onShown();
       }
       // The prompt has been removed, notify the PermissionUI.
       // onAfterShow() is currently not called for post-prompts,
       // because there is no prompt that would make use of this.
       // You can remove this restriction if you need it, but be
       // mindful of other consumers.
       if (topic == "removed" && !postPrompt) {
+        if (telemetryData) {
+          PermissionUITelemetry.onRemoved(telemetryData, this._buttonAction,
+                                          nextRemovalReason);
+        }
         this.onAfterShow();
       }
       return false;
     };
 
     // Post-prompts show up as dismissed.
     options.dismissed = postPrompt;
 
     // onBeforeShow() is currently not called for post-prompts,
     // because there is no prompt that would make use of this.
     // You can remove this restriction if you need it, but be
     // mindful of other consumers.
     if (postPrompt || this.onBeforeShow() !== false) {
-      let chromeWin = this.browser.ownerGlobal;
       chromeWin.PopupNotifications.show(this.browser,
                                         this.notificationID,
                                         this.message,
                                         this.anchorID,
                                         mainAction,
                                         secondaryActions,
                                         options);
+      if (telemetryData) {
+        PermissionUITelemetry.onShow(telemetryData);
+      }
     }
   },
 };
 
 PermissionUI.PermissionPromptPrototype = PermissionPromptPrototype;
 
 /**
  * A subclass of PermissionPromptPrototype that assumes
@@ -639,16 +680,20 @@ function GeolocationPermissionPrompt(req
 
 GeolocationPermissionPrompt.prototype = {
   __proto__: PermissionPromptForRequestPrototype,
 
   get permissionKey() {
     return "geo";
   },
 
+  get permissionTelemetryKey() {
+    return "geo";
+  },
+
   get popupOptions() {
     let pref = "browser.geolocation.warning.infoURL";
     let options = {
       learnMoreURL: Services.urlFormatter.formatURLPref(pref),
       displayURI: false,
       name: this.principalName,
     };
 
@@ -722,16 +767,20 @@ function DesktopNotificationPermissionPr
 
 DesktopNotificationPermissionPrompt.prototype = {
   __proto__: PermissionPromptForRequestPrototype,
 
   get permissionKey() {
     return "desktop-notification";
   },
 
+  get permissionTelemetryKey() {
+    return "notifications";
+  },
+
   get popupOptions() {
     let learnMoreURL =
       Services.urlFormatter.formatURLPref("app.support.baseURL") + "push";
 
     return {
       learnMoreURL,
       displayURI: false,
       name: this.principalName,
new file mode 100644
--- /dev/null
+++ b/browser/modules/PermissionUITelemetry.jsm
@@ -0,0 +1,147 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = [
+  "PermissionUITelemetry",
+];
+
+ChromeUtils.defineModuleGetter(this, "Services",
+  "resource://gre/modules/Services.jsm");
+ChromeUtils.defineModuleGetter(this, "CryptoUtils",
+  "resource://services-crypto/utils.js");
+
+const TELEMETRY_STAT_REMOVAL_LEAVE_PAGE = 6;
+
+var PermissionUITelemetry = {
+  // Returns a hash of the host name in combination with a unique local user id.
+  // This allows us to track duplicate prompts on sites while not revealing the user's
+  // browsing history.
+  _uniqueHostHash(host) {
+    // Gets a unique user ID as salt, that needs to stay local to this profile and not be
+    // sent to any server!
+    let salt = Services.prefs.getStringPref("permissions.eventTelemetry.salt", null);
+    if (!salt) {
+      salt = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator)
+               .generateUUID().toString();
+      Services.prefs.setStringPref("permissions.eventTelemetry.salt", salt);
+    }
+
+    let domain;
+    try {
+      domain = Services.eTLD.getBaseDomainFromHost(host);
+    } catch (e) {
+      domain = host;
+    }
+
+    return CryptoUtils.sha256(domain + salt);
+  },
+
+  _previousVisitCount(host) {
+    let historyService = Cc["@mozilla.org/browser/nav-history-service;1"]
+                           .getService(Ci.nsINavHistoryService);
+
+    let options = historyService.getNewQueryOptions();
+    options.resultType = options.RESULTS_AS_VISIT;
+
+    // Search for visits to this host before today
+    let query = historyService.getNewQuery();
+    query.endTimeReference = query.TIME_RELATIVE_TODAY;
+    query.endTime = 0;
+    query.domain = host;
+
+    let result = historyService.executeQuery(query, options);
+    result.root.containerOpen = true;
+    let cc = result.root.childCount;
+    result.root.containerOpen = false;
+    return cc;
+  },
+
+  _collectExtraKeys(prompt) {
+    let lastInteraction = 0;
+    // "storageAccessAPI" is the name of the permission that tells us whether the
+    // user has interacted with a particular site in the first-party context before.
+    let interactionPermission = Services.perms.getPermissionObject(
+      prompt.principal, "storageAccessAPI", false);
+    if (interactionPermission) {
+      lastInteraction = interactionPermission.modificationTime;
+    }
+
+    let allPermsDenied = 0;
+    let allPermsGranted = 0;
+    let thisPermDenied = 0;
+    let thisPermGranted = 0;
+
+    let commonPermissions = ["geo", "desktop-notification", "camera", "microphone", "screen"];
+    for (let perm of Services.perms.enumerator) {
+      if (!commonPermissions.includes(perm.type)) {
+        continue;
+      }
+
+      if (perm.capability == Services.perms.ALLOW_ACTION) {
+        allPermsGranted++;
+        if (perm.type == prompt.permissionKey) {
+          thisPermGranted++;
+        }
+      }
+
+      if (perm.capability == Services.perms.DENY_ACTION) {
+        allPermsDenied++;
+        if (perm.type == prompt.permissionKey) {
+          thisPermDenied++;
+        }
+      }
+    }
+
+    let promptHost = prompt.principal.URI.host;
+
+    return {
+      previousVisits: this._previousVisitCount(promptHost).toString(),
+      timeOnPage: (Date.now() - prompt.documentDOMContentLoadedTimestamp).toString(),
+      hasUserInput: prompt.isHandlingUserInput.toString(),
+      docHasUserInput: prompt.userHadInteractedWithDocument.toString(),
+      lastInteraction: lastInteraction.toString(),
+      allPermsDenied: allPermsDenied.toString(),
+      allPermsGranted: allPermsGranted.toString(),
+      thisPermDenied: thisPermDenied.toString(),
+      thisPermGranted: thisPermGranted.toString(),
+    };
+  },
+
+  onShow(prompt) {
+    let object = prompt.permissionTelemetryKey;
+    if (!object) {
+      return;
+    }
+
+    let extraKeys = this._collectExtraKeys(prompt);
+    let hostHash = this._uniqueHostHash(prompt.principal.URI.host);
+    Services.telemetry.recordEvent("security.ui.permissionprompt",
+      "show", object, hostHash, extraKeys);
+  },
+
+  onRemoved(prompt, buttonAction, telemetryReason) {
+    let object = prompt.permissionTelemetryKey;
+    if (!object) {
+      return;
+    }
+
+    let method = "other";
+    if (buttonAction == "accept") {
+      method = "accept";
+    } else if (buttonAction == "deny") {
+      method = "deny";
+    } else if (buttonAction == "never") {
+      method = "never";
+    } else if (telemetryReason == TELEMETRY_STAT_REMOVAL_LEAVE_PAGE) {
+      method = "leave";
+    }
+
+    let extraKeys = this._collectExtraKeys(prompt);
+    let hostHash = this._uniqueHostHash(prompt.principal.URI.host);
+    Services.telemetry.recordEvent("security.ui.permissionprompt",
+      method, object, hostHash, extraKeys);
+  },
+};
--- a/browser/modules/moz.build
+++ b/browser/modules/moz.build
@@ -74,16 +74,19 @@ with Files("LiveBookmarkMigrator.jsm"):
     BUG_COMPONENT = ("Firefox", "General")
 
 with Files("OpenInTabsUtils.jsm"):
     BUG_COMPONENT = ("Firefox", "Tabbed Browser")
 
 with Files("PermissionUI.jsm"):
    BUG_COMPONENT = ("Firefox", "Site Identity and Permission Panels")
 
+with Files("PermissionUITelemetry.jsm"):
+   BUG_COMPONENT = ("Firefox", "Site Identity and Permission Panels")
+
 with Files("ProcessHangMonitor.jsm"):
     BUG_COMPONENT = ("Core", "DOM: Content Processes")
 
 with Files("ReaderParent.jsm"):
     BUG_COMPONENT = ("Toolkit", "Reader Mode")
 
 with Files("Sanitizer.jsm"):
     BUG_COMPONENT = ("Firefox", "Preferences")
@@ -145,16 +148,17 @@ EXTRA_JS_MODULES += [
     'FormValidationHandler.jsm',
     'HomePage.jsm',
     'LaterRun.jsm',
     'LiveBookmarkMigrator.jsm',
     'NewTabPagePreloading.jsm',
     'OpenInTabsUtils.jsm',
     'PageActions.jsm',
     'PermissionUI.jsm',
+    'PermissionUITelemetry.jsm',
     'PingCentre.jsm',
     'ProcessHangMonitor.jsm',
     'ReaderParent.jsm',
     'RemotePrompt.jsm',
     'Sanitizer.jsm',
     'SelectionChangedMenulist.jsm',
     'SiteDataManager.jsm',
     'SitePermissions.jsm',
--- a/toolkit/components/telemetry/Events.yaml
+++ b/toolkit/components/telemetry/Events.yaml
@@ -1086,8 +1086,78 @@ intl.ui.browserLanguage:
       - firefox
     expiry_version: "70"
     notification_emails:
       - flod@mozilla.com
       - mstriemer@mozilla.com
     release_channel_collection: opt-out
     record_in_processes: ["main"]
     bug_numbers: [1486507]
+
+# This data collection is intended for study-only collection
+# and is not meant to be enabled permanently on opt-in or opt-out.
+# It is controlled by the permissions.eventTelemetry.enabled pref.
+security.ui.permissionprompt:
+  show:
+    objects: [
+      "notifications",
+      "geo",
+    ]
+    bug_numbers:
+      - 1536454
+    description: >
+      When a permission prompt was shown.
+    expiry_version: "72"
+    notification_emails:
+      - jhofmann@mozilla.com
+      - ehsan@mozilla.com
+      - seceng-telemetry@mozilla.com
+    release_channel_collection: opt-out
+    record_in_processes:
+      - main
+    extra_keys:
+      previousVisits: How often the user has visited this site before today
+      timeOnPage: How much time the user has spent on this page so far
+      hasUserInput: Whether the prompt was handling user input (not including scrolling)
+      docHasUserInput: Whether the document has been interacted with (includes scrolling)
+      lastInteraction: When the site was last interacted with (includes scrolling)
+      allPermsDenied: How many permissions were denied by the user in total
+      allPermsGranted: How many permissions were granted by the user in total
+      thisPermDenied: How many permissions of the same kind were denied by the user in total
+      thisPermGranted: How many permissions of the same kind were granted by the user in total
+    products:
+      - firefox
+  remove:
+    objects: [
+      "notifications",
+      "geo",
+    ]
+    methods: [
+      "accept",
+      "deny",
+      "never",
+      "leave",
+      "other",
+    ]
+    bug_numbers:
+      - 1536454
+    description: >
+      When a permission prompt was removed, with the reasons being represented in the method field.
+    expiry_version: "72"
+    notification_emails:
+      - jhofmann@mozilla.com
+      - ehsan@mozilla.com
+      - seceng-telemetry@mozilla.com
+    release_channel_collection: opt-out
+    record_in_processes:
+      - main
+    extra_keys:
+      previousVisits: How often the user has visited this site before today
+      timeOnPage: How much time the user has spent on this page so far
+      hasUserInput: Whether the prompt was handling user input (not including scrolling)
+      docHasUserInput: Whether the document has been interacted with (includes scrolling)
+      lastInteraction: When the site was last interacted with (includes scrolling)
+      allPermsDenied: How many permissions were denied by the user in total
+      allPermsGranted: How many permissions were granted by the user in total
+      thisPermDenied: How many permissions of the same kind were denied by the user in total
+      thisPermGranted: How many permissions of the same kind were granted by the user in total
+    products:
+      - firefox