Bug 1559136 - Add urlbar event telemetry behind a pref. r=adw a=RyanVM
authorMarco Bonardo <mbonardo@mozilla.com>
Thu, 25 Jul 2019 12:39:02 +0000
changeset 544843 f6ce88e03a22d6fce924607c8f6e25898d456b76
parent 544842 6ea8998931b8b0e4262c3f18c268e998ed5355b2
child 544844 eb98783c379ce453a706d1f9b7b7558b417edb46
push id2131
push userffxbld-merge
push dateMon, 26 Aug 2019 18:30:20 +0000
treeherdermozilla-release@b19ffb3ca153 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersadw, RyanVM
bugs1559136
milestone69.0
Bug 1559136 - Add urlbar event telemetry behind a pref. r=adw a=RyanVM Differential Revision: https://phabricator.services.mozilla.com/D38521
browser/base/content/browser.js
browser/components/urlbar/UrlbarController.jsm
browser/components/urlbar/UrlbarInput.jsm
browser/components/urlbar/UrlbarPrefs.jsm
browser/components/urlbar/UrlbarUtils.jsm
browser/components/urlbar/tests/browser/browser.ini
browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry.js
toolkit/components/telemetry/Events.yaml
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -309,17 +309,20 @@ var gURLBarHandler = {
   },
 
   /**
    * The urlbar binding or object.
    */
   get urlbar() {
     if (!this._urlbar) {
       if (this.quantumbar) {
-        this._urlbar = new UrlbarInput({ textbox: this.textbox });
+        this._urlbar = new UrlbarInput({
+          textbox: this.textbox,
+          eventTelemetryCategory: "urlbar",
+        });
         if (this._lastValue) {
           this._urlbar.value = this._lastValue;
           delete this._lastValue;
         }
       } else {
         this._urlbar = this.textbox;
       }
       gBrowser.tabContainer.addEventListener("TabSelect", this._urlbar);
--- a/browser/components/urlbar/UrlbarController.jsm
+++ b/browser/components/urlbar/UrlbarController.jsm
@@ -60,16 +60,18 @@ class UrlbarController {
       throw new Error("browserWindow should be an actual browser window.");
     }
 
     this.manager = options.manager || UrlbarProvidersManager;
     this.browserWindow = options.browserWindow;
 
     this._listeners = new Set();
     this._userSelectionBehavior = "none";
+
+    this.engagementEvent = new TelemetryEvent(options.eventTelemetryCategory);
   }
 
   /**
    * Hooks up the controller with an input.
    *
    * @param {UrlbarInput} input
    *   The UrlbarInput instance associated with this controller.
    */
@@ -334,16 +336,17 @@ class UrlbarController {
             );
           }
         } else {
           if (this.keyEventMovesCaret(event)) {
             break;
           }
           if (executeAction) {
             this.userSelectionBehavior = "arrow";
+            this.engagementEvent.start(event);
             this.input.startQuery({ searchString: this.input.textValue });
           }
         }
         event.preventDefault();
         break;
       case KeyEvent.DOM_VK_LEFT:
       case KeyEvent.DOM_VK_RIGHT:
       case KeyEvent.DOM_VK_HOME:
@@ -576,8 +579,201 @@ class UrlbarController {
           listener[name](...params);
         } catch (ex) {
           Cu.reportError(ex);
         }
       }
     }
   }
 }
+
+/**
+ * Tracks and records telemetry events for the given category, if provided,
+ * otherwise it's a no-op.
+ * It is currently designed around the "urlbar" category, even if it can
+ * potentially be extended to other categories.
+ * To record an event, invoke start() with a starting event, then either
+ * invoke record() with a final event, or discard() to drop the recording.
+ * @see Events.yaml
+ */
+class TelemetryEvent {
+  constructor(category) {
+    this._category = category;
+  }
+
+  /**
+   * Start measuring the elapsed time from an input event.
+   * After this has been invoked, any subsequent calls to start() are ignored,
+   * until either record() or discard() are invoked. Thus, it is safe to keep
+   * invoking this on every input event.
+   * @param {event} event A DOM input event.
+   * @note This should never throw, or it may break the urlbar.
+   */
+  start(event) {
+    // Start is invoked at any input, but we only count the first one.
+    // Once an engagement or abandoment happens, we clear the _startEventInfo.
+    if (!this._category || this._startEventInfo) {
+      return;
+    }
+    if (!event) {
+      Cu.reportError("Must always provide an event");
+      return;
+    }
+    if (!["input", "drop", "mousedown", "keydown"].includes(event.type)) {
+      Cu.reportError("Can't start recording from event type: " + event.type);
+      return;
+    }
+
+    // "typed" is used when the user types something, while "pasted" and
+    // "dropped" are used when the text is inserted at once, by a paste or drop
+    // operation. "topsites" is a bit special, it is used when the user opens
+    // the empty search dropdown, that is supposed to show top sites. That
+    // happens by clicking on the urlbar dropmarker, or pressing DOWN with an
+    // empty input field. Even if the user later types something, we still
+    // report "topsites", with a positive numChars.
+    let interactionType = "topsites";
+    if (event.type == "input") {
+      interactionType = UrlbarUtils.isPasteEvent(event) ? "pasted" : "typed";
+    } else if (event.type == "drop") {
+      interactionType = "dropped";
+    }
+
+    this._startEventInfo = {
+      timeStamp: event.timeStamp || Cu.now(),
+      interactionType,
+    };
+  }
+
+  /**
+   * Record an engagement telemetry event.
+   * When the user picks a result from a search through the mouse or keyboard,
+   * an engagement event is recorded. If instead the user abandons a search, by
+   * blurring the input field, an abandonment event is recorded.
+   * @param {event} [event] A DOM event.
+   * @param {object} details An object describing action details.
+   * @param {string} details.numChars Number of input characters.
+   * @param {string} details.selIndex Index of the selected result, undefined
+   *        for "blur".
+   * @param {string} details.selType type of the selected element, undefined
+   *        for "blur". One of "none", "autofill", "visit", "bookmark",
+   *        "history", "keyword", "search", "searchsuggestion", "switchtab",
+   *         "remotetab", "extension", "oneoff".
+   * @note event can be null, that usually happens for paste&go or drop&go.
+   *       If there's no _startEventInfo this is a no-op.
+   */
+  record(event, details) {
+    // This should never throw, or it may break the urlbar.
+    try {
+      this._internalRecord(event, details);
+    } catch (ex) {
+      Cu.reportError("Could not record event: " + ex);
+    } finally {
+      this._startEventInfo = null;
+    }
+  }
+
+  _internalRecord(event, details) {
+    if (!this._category || !this._startEventInfo) {
+      return;
+    }
+    if (
+      !event &&
+      this._startEventInfo.interactionType != "pasted" &&
+      this._startEventInfo.interactionType != "dropped"
+    ) {
+      // If no event is passed, we must be executing either paste&go or drop&go.
+      throw new Error("Event must be defined, unless input was pasted/dropped");
+    }
+    if (!details) {
+      throw new Error("Invalid event details: " + details);
+    }
+
+    let endTime = (event && event.timeStamp) || Cu.now();
+    let startTime = this._startEventInfo.timeStamp || endTime;
+    // Synthesized events in tests may have a bogus timeStamp, causing a
+    // subtraction between monotonic and non-monotonic timestamps; that's why
+    // abs is necessary here. It should only happen in tests, anyway.
+    let elapsed = Math.abs(Math.round(endTime - startTime));
+
+    let action;
+    if (!event) {
+      action =
+        this._startEventInfo.interactionType == "dropped"
+          ? "drop_go"
+          : "paste_go";
+    } else if (event.type == "blur") {
+      action = "blur";
+    } else {
+      action = event instanceof MouseEvent ? "click" : "enter";
+    }
+    let method = action == "blur" ? "abandonment" : "engagement";
+    let value = this._startEventInfo.interactionType;
+
+    // Rather than listening to the pref, just update status when we record an
+    // event, if the pref changed from the last time.
+    let recordingEnabled = UrlbarPrefs.get("eventTelemetry.enabled");
+    if (this._eventRecordingEnabled != recordingEnabled) {
+      this._eventRecordingEnabled = recordingEnabled;
+      Services.telemetry.setEventRecordingEnabled("urlbar", recordingEnabled);
+    }
+
+    let extra = {
+      elapsed: elapsed.toString(),
+      numChars: details.numChars.toString(),
+    };
+    if (method == "engagement") {
+      extra.selIndex = details.selIndex.toString();
+      extra.selType = details.selType;
+    }
+
+    // We invoke recordEvent regardless, if recording is disabled this won't
+    // report the events remotely, but will count it in the event_counts scalar.
+    Services.telemetry.recordEvent(
+      this._category,
+      method,
+      action,
+      value,
+      extra
+    );
+  }
+
+  /**
+   * Resets the currently tracked input event, that was registered via start(),
+   * so it won't be recorded.
+   * If there's no tracked input event, this is a no-op.
+   */
+  discard() {
+    this._startEventInfo = null;
+  }
+
+  /**
+   * Extracts a type from a result, to be used in the telemetry event.
+   * @param {UrlbarResult} result The result to analyze.
+   * @returns {string} a string type for the telemetry event.
+   */
+  typeFromResult(result) {
+    if (result) {
+      switch (result.type) {
+        case UrlbarUtils.RESULT_TYPE.TAB_SWITCH:
+          return "switchtab";
+        case UrlbarUtils.RESULT_TYPE.SEARCH:
+          return result.payload.suggestion ? "searchsuggestion" : "search";
+        case UrlbarUtils.RESULT_TYPE.URL:
+          if (result.autofill) {
+            return "autofill";
+          }
+          if (result.heuristic) {
+            return "visit";
+          }
+          return result.source == UrlbarUtils.RESULT_SOURCE.BOOKMARKS
+            ? "bookmark"
+            : "history";
+        case UrlbarUtils.RESULT_TYPE.KEYWORD:
+          return "keyword";
+        case UrlbarUtils.RESULT_TYPE.OMNIBOX:
+          return "extension";
+        case UrlbarUtils.RESULT_TYPE.REMOTE_TAB:
+          return "remotetab";
+      }
+    }
+    return "none";
+  }
+}
--- a/browser/components/urlbar/UrlbarInput.jsm
+++ b/browser/components/urlbar/UrlbarInput.jsm
@@ -88,16 +88,17 @@ class UrlbarInput {
       `)
     );
     this.panel = this.document.getElementById("urlbar-results");
 
     this.controller =
       options.controller ||
       new UrlbarController({
         browserWindow: this.window,
+        eventTelemetryCategory: options.eventTelemetryCategory,
       });
     this.controller.setInput(this);
     this.view = new UrlbarView(this);
     this.valueIsTyped = false;
     this.userInitiatedFocus = false;
     this.isPrivate = PrivateBrowsingUtils.isWindowPrivate(this.window);
     this.lastQueryContextPromise = Promise.resolve();
     this._actionOverrideKeyCount = 0;
@@ -201,16 +202,20 @@ class UrlbarInput {
 
     // This is needed for the dropmarker. Once we remove that (i.e. make
     // openViewOnFocus = true the default), this won't be needed anymore.
     this.addEventListener("mousedown", this);
 
     this.view.panel.addEventListener("popupshowing", this);
     this.view.panel.addEventListener("popuphidden", this);
 
+    // This is used to detect commands launched from the panel, to avoid
+    // recording abandonment events when the command causes a blur event.
+    this.view.panel.addEventListener("command", this, true);
+
     this._copyCutController = new CopyCutController(this);
     this.inputField.controllers.insertControllerAt(0, this._copyCutController);
 
     this._initPasteAndGo();
 
     // Tracks IME composition.
     this._compositionState = UrlbarUtils.COMPOSITION.NONE;
     this._compositionClosedPopup = false;
@@ -365,17 +370,17 @@ class UrlbarInput {
     } else {
       throw new Error("Unrecognized UrlbarInput event: " + event.type);
     }
   }
 
   /**
    * Handles an event which would cause a url or text to be opened.
    *
-   * @param {Event} event The event triggering the open.
+   * @param {Event} [event] The event triggering the open.
    * @param {string} [openWhere] Where we expect the result to be opened.
    * @param {object} [openParams]
    *   The parameters related to where the result will be opened.
    * @param {object} [triggeringPrincipal]
    *   The principal that the action was triggered from.
    */
   handleCommand(event, openWhere, openParams = {}, triggeringPrincipal = null) {
     let isMouseEvent = event instanceof this.window.MouseEvent;
@@ -392,31 +397,36 @@ class UrlbarInput {
     let selectedOneOff;
     if (this.view.isOpen) {
       selectedOneOff = this.view.oneOffSearchButtons.selectedButton;
       if (selectedOneOff && isMouseEvent && event.target != selectedOneOff) {
         selectedOneOff = null;
       }
       // Do the command of the selected one-off if it's not an engine.
       if (selectedOneOff && !selectedOneOff.engine) {
+        this.controller.engagementEvent.discard();
         selectedOneOff.doCommand();
         return;
       }
     }
 
     // Use the selected result if we have one; this is usually the case
     // when the view is open.
     let result = this.view.selectedResult;
     if (!selectedOneOff && result) {
       this.pickResult(result, event);
       return;
     }
 
     let url;
+    let selType = this.controller.engagementEvent.typeFromResult(result);
+    let numChars = this.textValue.length;
     if (selectedOneOff) {
+      selType = "oneoff";
+      numChars = this._lastSearchString.length;
       // If there's a selected one-off button then load a search using
       // the button's engine.
       result = this._resultForCurrentValue;
       let searchString =
         (result && (result.payload.suggestion || result.payload.query)) ||
         this._lastSearchString;
       [url, openParams.postData] = UrlbarUtils.getSearchQueryUrl(
         selectedOneOff.engine,
@@ -438,16 +448,22 @@ class UrlbarInput {
       event,
       result || this.view.selectedResult
     );
 
     let where = openWhere || this._whereToOpen(event);
     openParams.allowInheritPrincipal = false;
     url = this._maybeCanonizeURL(event, url) || url.trim();
 
+    this.controller.engagementEvent.record(event, {
+      numChars,
+      selIndex: this.view.selectedIndex,
+      selType,
+    });
+
     try {
       new URL(url);
     } catch (ex) {
       let browser = this.window.gBrowser.selectedBrowser;
       let lastLocationChange = browser.lastLocationChange;
 
       UrlbarUtils.getShortcutOrURIAndPostData(url).then(data => {
         if (
@@ -481,23 +497,29 @@ class UrlbarInput {
    */
   pickResult(result, event) {
     let isCanonized = this.setValueFromResult(result, event);
     let where = this._whereToOpen(event);
     let openParams = {
       allowInheritPrincipal: false,
     };
 
+    let selIndex = this.view.selectedIndex;
     if (!result.payload.isKeywordOffer) {
       this.view.close();
     }
 
     this.controller.recordSelectedResult(event, result);
 
     if (isCanonized) {
+      this.controller.engagementEvent.record(event, {
+        numChars: this._lastSearchString.length,
+        selIndex,
+        selType: "canonized",
+      });
       this._loadURL(this.value, where, openParams);
       return;
     }
 
     let { url, postData } = UrlbarUtils.getUrlFromResult(result);
     openParams.postData = postData;
 
     switch (result.type) {
@@ -516,51 +538,67 @@ class UrlbarInput {
         this.handleRevert();
         let prevTab = this.window.gBrowser.selectedTab;
         let loadOpts = {
           adoptIntoActiveWindow: UrlbarPrefs.get(
             "switchTabs.adoptIntoActiveWindow"
           ),
         };
 
-        if (
-          this.window.switchToTabHavingURI(
-            Services.io.newURI(url),
-            false,
-            loadOpts
-          ) &&
-          prevTab.isEmpty
-        ) {
+        this.controller.engagementEvent.record(event, {
+          numChars: this._lastSearchString.length,
+          selIndex,
+          selType: "tabswitch",
+        });
+
+        let switched = this.window.switchToTabHavingURI(
+          Services.io.newURI(url),
+          false,
+          loadOpts
+        );
+        if (switched && prevTab.isEmpty) {
           this.window.gBrowser.removeTab(prevTab);
         }
         return;
       }
       case UrlbarUtils.RESULT_TYPE.SEARCH: {
         if (result.payload.isKeywordOffer) {
           // The user confirmed a token alias, so just move the caret
           // to the end of it. Because there's a trailing space in the value,
           // the user can directly start typing a query string at that point.
           this.selectionStart = this.selectionEnd = this.value.length;
 
+          this.controller.engagementEvent.record(event, {
+            numChars: this._lastSearchString.length,
+            selIndex,
+            selType: "keywordoffer",
+          });
+
           // Picking a keyword offer just fills it in the input and doesn't
           // visit anything.  The user can then type a search string.  Also
           // start a new search so that the offer appears in the view by itself
           // to make it even clearer to the user what's going on.
           this.startQuery();
           return;
         }
         const actionDetails = {
           isSuggestion: !!result.payload.suggestion,
           alias: result.payload.keyword,
         };
         const engine = Services.search.getEngineByName(result.payload.engine);
         this._recordSearch(engine, event, actionDetails);
         break;
       }
       case UrlbarUtils.RESULT_TYPE.OMNIBOX: {
+        this.controller.engagementEvent.record(event, {
+          numChars: this._lastSearchString.length,
+          selIndex,
+          selType: "extension",
+        });
+
         // The urlbar needs to revert to the loaded url when a command is
         // handled by the extension.
         this.handleRevert();
         // We don't directly handle a load when an Omnibox API result is picked,
         // instead we forward the request to the WebExtension itself, because
         // the value may not even be a url.
         // We pass the keyword and content, that actually is the retrieved value
         // prefixed by the keyword. ExtensionSearchHandler uses this keyword
@@ -580,16 +618,22 @@ class UrlbarInput {
 
     if (!this.isPrivate && !result.heuristic) {
       // This should not interrupt the load anyway.
       UrlbarUtils.addToInputHistory(url, this._lastSearchString).catch(
         Cu.reportError
       );
     }
 
+    this.controller.engagementEvent.record(event, {
+      numChars: this._lastSearchString.length,
+      selIndex,
+      selType: this.controller.engagementEvent.typeFromResult(result),
+    });
+
     this._loadURL(url, where, openParams, {
       source: result.source,
       type: result.type,
     });
   }
 
   /**
    * Called by the view when moving through results with the keyboard, and when
@@ -1401,17 +1445,33 @@ class UrlbarInput {
       this.inputField.selectionStart == this.inputField.selectionEnd
     ) {
       this.editor.selectAll();
     }
   }
 
   // Event handlers below.
 
+  _on_command(event) {
+    // Something is executing a command, likely causing a focus change. This
+    // should not be recorded as an abandonment.
+    this.controller.engagementEvent.discard();
+  }
+
   _on_blur(event) {
+    // We cannot count every blur events after a missed engagement as abandoment
+    // because the user may have clicked on some view element that executes
+    // a command causing a focus change. For example opening preferences from
+    // the oneoff settings button, or from a contextual tip button.
+    // For now we detect that case by discarding the event on command, but we
+    // may want to figure out a more robust way to detect abandonment.
+    this.controller.engagementEvent.record(event, {
+      numChars: this._lastSearchString.length,
+    });
+
     // In certain cases, like holding an override key and confirming an entry,
     // we don't key a keyup event for the override key, thus we make this
     // additional cleanup on blur.
     this._clearActionOverride();
     this.formatValue();
 
     // The extension input sessions depends more on blur than on the fact we
     // actually cancel a running query, so we do it here.
@@ -1473,31 +1533,33 @@ class UrlbarInput {
       if (event.button != 0) {
         return;
       }
 
       if (event.detail == 2 && UrlbarPrefs.get("doubleClickSelectsAll")) {
         this.editor.selectAll();
         event.preventDefault();
       } else if (this.openViewOnFocus && !this.view.isOpen) {
+        this.controller.engagementEvent.start(event);
         this.startQuery({
           allowAutofill: false,
         });
       }
       return;
     }
 
     if (
       event.originalTarget.classList.contains("urlbar-history-dropmarker") &&
       event.button == 0
     ) {
       if (this.view.isOpen) {
         this.view.close();
       } else {
         this.focus();
+        this.controller.engagementEvent.start(event);
         this.startQuery({
           allowAutofill: false,
         });
         this._maybeSelectAll();
       }
     }
   }
 
@@ -1541,22 +1603,23 @@ class UrlbarInput {
     if (
       compositionState == UrlbarUtils.COMPOSITION.COMPOSING ||
       (compositionState == UrlbarUtils.COMPOSITION.CANCELED &&
         !compositionClosedPopup)
     ) {
       return;
     }
 
+    this.controller.engagementEvent.start(event);
+
     // Autofill only when text is inserted (i.e., event.data is not empty) and
     // it's not due to pasting.
     let allowAutofill =
       !!event.data &&
-      !event.inputType.startsWith("insertFromPaste") &&
-      event.inputType != "insertFromYank" &&
+      !UrlbarUtils.isPasteEvent(event) &&
       this._maybeAutofillOnInput(value);
 
     this.startQuery({
       searchString: value,
       allowAutofill,
       resetSearchState: false,
     });
   }
@@ -1746,16 +1809,19 @@ class UrlbarInput {
     let droppedItem = getDroppableData(event);
     let droppedURL =
       droppedItem instanceof URL ? droppedItem.href : droppedItem;
     if (droppedURL && droppedURL !== this.window.gBrowser.currentURI.spec) {
       let principal = Services.droppedLinkHandler.getTriggeringPrincipal(event);
       this.value = droppedURL;
       this.window.SetPageProxyState("invalid");
       this.focus();
+      // To simplify tracking of events, register an initial event for event
+      // telemetry, to replace the missing input event.
+      this.controller.engagementEvent.start(event);
       this.handleCommand(null, undefined, undefined, principal);
       // For safety reasons, in the drop case we don't want to immediately show
       // the the dropped value, instead we want to keep showing the current page
       // url until an onLocationChange happens.
       // See the handling in URLBarSetURI for further details.
       this.window.gBrowser.userTypedValue = null;
       this.window.URLBarSetURI(null, true);
     }
--- a/browser/components/urlbar/UrlbarPrefs.jsm
+++ b/browser/components/urlbar/UrlbarPrefs.jsm
@@ -61,16 +61,19 @@ const PREF_URLBAR_DEFAULTS = new Map([
   // "heuristic" result).  We fetch it as fast as possible.
   ["delay", 50],
 
   // If true, this optimizes for replacing the full URL rather than selecting a
   // portion of it. This also copies the urlbar value to the selection
   // clipboard on systems that support it.
   ["doubleClickSelectsAll", false],
 
+  // Whether telemetry events should be recorded.
+  ["eventTelemetry.enabled", false],
+
   // When true, `javascript:` URLs are not included in search results.
   ["filter.javascript", true],
 
   // Applies URL highlighting and other styling to the text in the urlbar input.
   ["formatting.enabled", false],
 
   // Allows results from one search to be reused in the next search.  One of the
   // INSERTMETHOD values.
@@ -97,16 +100,17 @@ const PREF_URLBAR_DEFAULTS = new Map([
 
   // One-off search buttons enabled status.
   ["oneOffSearches", false],
 
   // Whether addresses and search results typed into the address bar
   // should be opened in new tabs by default.
   ["openintab", false],
 
+  // Whether to open the urlbar view when the input field is focused by the user.
   ["openViewOnFocus", false],
 
   // Whether the quantum bar is enabled.
   ["quantumbar", false],
 
   // Whether speculative connections should be enabled.
   ["speculativeConnect.enabled", true],
 
--- a/browser/components/urlbar/UrlbarUtils.jsm
+++ b/browser/components/urlbar/UrlbarUtils.jsm
@@ -414,16 +414,29 @@ var UrlbarUtils = {
         FROM moz_places h
         LEFT JOIN moz_inputhistory i ON i.place_id = h.id AND i.input = :input
         WHERE url_hash = hash(:url) AND url = :url
       `,
         { url, input }
       );
     });
   },
+
+  /**
+   * Whether the passed-in input event is paste event.
+   * @param {DOMEvent} event an input DOM event.
+   * @returns {boolean} Whether the event is a paste event.
+   */
+  isPasteEvent(event) {
+    return (
+      event.inputType &&
+      (event.inputType.startsWith("insertFromPaste") ||
+        event.inputType == "insertFromYank")
+    );
+  },
 };
 
 XPCOMUtils.defineLazyGetter(UrlbarUtils.ICON, "DEFAULT", () => {
   return PlacesUtils.favicons.defaultFavicon.spec;
 });
 
 /**
  * UrlbarQueryContext defines a user's autocomplete input from within the urlbar.
--- a/browser/components/urlbar/tests/browser/browser.ini
+++ b/browser/components/urlbar/tests/browser/browser.ini
@@ -1,15 +1,13 @@
 # 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/.
 
 [DEFAULT]
-prefs=browser.urlbar.quantumbar=true
-tags=quantumbar
 support-files =
   dummy_page.html
   head.js
   head-common.js
 
 [browser_action_searchengine.js]
 [browser_action_searchengine_alias.js]
 [browser_autocomplete_a11y_label.js]
@@ -107,16 +105,20 @@ support-files =
   moz.png
 [browser_textruns.js]
 [browser_urlbar_blanking.js]
 support-files =
   file_blank_but_not_blank.html
 [browser_urlbar_content_opener.js]
 [browser_urlbar_display_selectedAction_Extensions.js]
 [browser_urlbar_empty_search.js]
+[browser_urlbar_event_telemetry.js]
+support-files =
+  searchSuggestionEngine.xml
+  searchSuggestionEngine.sjs
 [browser_urlbar_locationchange_urlbar_edit_dos.js]
 support-files =
   file_urlbar_edit_dos.html
 [browser_urlbar_remoteness_switch.js]
 run-if = e10s
 [browser_urlbar_remove_match.js]
 [browser_urlbar_searchsettings.js]
 [browser_urlbar_speculative_connect.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry.js
@@ -0,0 +1,594 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function copyToClipboard(str) {
+  return new Promise((resolve, reject) => {
+    waitForClipboard(
+      str,
+      () => {
+        Cc["@mozilla.org/widget/clipboardhelper;1"]
+          .getService(Ci.nsIClipboardHelper)
+          .copyString(str);
+      },
+      resolve,
+      reject
+    );
+  });
+}
+
+// Each test is a function that executes an urlbar action and returns the
+// expected event object, or null if no event is expected.
+const tests = [
+  /*
+   * Engagement tests.
+   */
+  async function() {
+    info("Type something, press Enter.");
+    gURLBar.select();
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    await promiseAutocompleteResultPopup("x", window, true);
+    EventUtils.synthesizeKey("VK_RETURN");
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "enter",
+      value: "typed",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "1",
+        selIndex: "0",
+        selType: "search",
+      },
+    };
+  },
+
+  async function() {
+    info("Paste something, press Enter.");
+    gURLBar.select();
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    await copyToClipboard("test");
+    document.commandDispatcher
+      .getControllerForCommand("cmd_paste")
+      .doCommand("cmd_paste");
+    EventUtils.synthesizeKey("VK_RETURN");
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "enter",
+      value: "pasted",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "4",
+        selIndex: "0",
+        selType: "search",
+      },
+    };
+  },
+
+  async function() {
+    info("Type something, click one-off.");
+    gURLBar.select();
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    await promiseAutocompleteResultPopup("moz", window, true);
+    EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
+    UrlbarTestUtils.getOneOffSearchButtons(window).selectedButton.click();
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "click",
+      value: "typed",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "3",
+        selIndex: "0",
+        selType: "oneoff",
+      },
+    };
+  },
+
+  async function() {
+    info("Type something, select one-off, Enter.");
+    gURLBar.select();
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    await promiseAutocompleteResultPopup("moz", window, true);
+    EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
+    Assert.ok(UrlbarTestUtils.getOneOffSearchButtons(window).selectedButton);
+    EventUtils.synthesizeKey("VK_RETURN");
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "enter",
+      value: "typed",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "3",
+        selIndex: "0",
+        selType: "oneoff",
+      },
+    };
+  },
+
+  async function() {
+    info("Type something, ESC, type something else, press Enter.");
+    gURLBar.select();
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    EventUtils.synthesizeKey("x");
+    EventUtils.synthesizeKey("VK_ESCAPE");
+    EventUtils.synthesizeKey("y");
+    EventUtils.synthesizeKey("VK_RETURN");
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "enter",
+      value: "typed",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "1",
+        selIndex: "0",
+        selType: "search",
+      },
+    };
+  },
+
+  async function() {
+    info("Type a keyword, Enter.");
+    gURLBar.select();
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    await promiseAutocompleteResultPopup("kw test", window, true);
+    EventUtils.synthesizeKey("VK_RETURN");
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "enter",
+      value: "typed",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "7",
+        selIndex: "0",
+        selType: "keyword",
+      },
+    };
+  },
+
+  async function() {
+    info("Type something and canonize");
+    gURLBar.select();
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    await promiseAutocompleteResultPopup("example", window, true);
+    EventUtils.synthesizeKey("VK_RETURN", { ctrlKey: true });
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "enter",
+      value: "typed",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "7",
+        selIndex: "0",
+        selType: "canonized",
+      },
+    };
+  },
+
+  async function() {
+    info("Type something, click on bookmark entry.");
+    gURLBar.select();
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    await promiseAutocompleteResultPopup("exa", window, true);
+    while (gURLBar.value != "http://example.com/?q=%s") {
+      EventUtils.synthesizeKey("KEY_ArrowDown");
+    }
+    let element = UrlbarTestUtils.getSelectedElement(window);
+    EventUtils.synthesizeMouseAtCenter(element, {});
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "click",
+      value: "typed",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "3",
+        selIndex: val => parseInt(val) > 0,
+        selType: "bookmark",
+      },
+    };
+  },
+
+  async function() {
+    info("Type an autofilled string, Enter.");
+    gURLBar.select();
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    await promiseAutocompleteResultPopup("exa", window, true);
+    // Check it's autofilled.
+    Assert.equal(gURLBar.selectionStart, 3);
+    Assert.equal(gURLBar.selectionEnd, 12);
+    EventUtils.synthesizeKey("VK_RETURN");
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "enter",
+      value: "typed",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "3",
+        selIndex: "0",
+        selType: "autofill",
+      },
+    };
+  },
+
+  async function() {
+    info("Type something, select bookmark entry, Enter.");
+    gURLBar.select();
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    await promiseAutocompleteResultPopup("exa", window, true);
+    while (gURLBar.value != "http://example.com/?q=%s") {
+      EventUtils.synthesizeKey("KEY_ArrowDown");
+    }
+    EventUtils.synthesizeKey("VK_RETURN");
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "enter",
+      value: "typed",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "3",
+        selIndex: val => parseInt(val) > 0,
+        selType: "bookmark",
+      },
+    };
+  },
+
+  async function() {
+    info("Type @, Enter on a keywordoffer");
+    gURLBar.select();
+    await promiseAutocompleteResultPopup("@", window, true);
+    while (gURLBar.value != "@test ") {
+      EventUtils.synthesizeKey("KEY_ArrowDown");
+    }
+    EventUtils.synthesizeKey("VK_RETURN");
+    await UrlbarTestUtils.promiseSearchComplete(window);
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "enter",
+      value: "typed",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "1",
+        selIndex: val => parseInt(val) > 0,
+        selType: "keywordoffer",
+      },
+    };
+  },
+
+  async function() {
+    info("Drop something.");
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    EventUtils.synthesizeDrop(
+      document.getElementById("home-button"),
+      gURLBar.inputField,
+      [[{ type: "text/plain", data: "www.example.com" }]],
+      "copy",
+      window
+    );
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "drop_go",
+      value: "dropped",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "15",
+        selIndex: "-1",
+        selType: "none",
+      },
+    };
+  },
+
+  async function() {
+    info("Paste & Go something.");
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    await copyToClipboard("www.example.com");
+    let inputBox = document.getAnonymousElementByAttribute(
+      gURLBar.textbox,
+      "anonid",
+      "moz-input-box"
+    );
+    let cxmenu = inputBox.menupopup;
+    let cxmenuPromise = BrowserTestUtils.waitForEvent(cxmenu, "popupshown");
+    gURLBar.focus();
+    EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {
+      type: "contextmenu",
+      button: 2,
+    });
+    await cxmenuPromise;
+    let menuitem = inputBox.getMenuItem("paste-and-go");
+    EventUtils.synthesizeMouseAtCenter(menuitem, {});
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "paste_go",
+      value: "pasted",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "15",
+        selIndex: "-1",
+        selType: "none",
+      },
+    };
+  },
+
+  async function() {
+    info("Open the panel with DOWN, select with DOWN, Enter.");
+    gURLBar.value = "";
+    gURLBar.select();
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    await UrlbarTestUtils.promisePopupOpen(window, () => {
+      EventUtils.synthesizeKey("KEY_ArrowDown", {});
+    });
+    await UrlbarTestUtils.promiseSearchComplete(window);
+    while (gURLBar.value != "http://mochi.test:8888/") {
+      EventUtils.synthesizeKey("KEY_ArrowDown");
+    }
+    EventUtils.synthesizeKey("VK_RETURN");
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "enter",
+      value: "topsites",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "0",
+        selType: "history",
+        selIndex: val => parseInt(val) >= 0,
+      },
+    };
+  },
+
+  async function() {
+    info("Open the panel with DOWN, click on entry.");
+    gURLBar.value = "";
+    gURLBar.select();
+    let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+    await UrlbarTestUtils.promisePopupOpen(window, () => {
+      EventUtils.synthesizeKey("KEY_ArrowDown", {});
+    });
+    while (gURLBar.value != "http://mochi.test:8888/") {
+      EventUtils.synthesizeKey("KEY_ArrowDown");
+    }
+    let element = UrlbarTestUtils.getSelectedElement(window);
+    EventUtils.synthesizeMouseAtCenter(element, {});
+    await promise;
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "click",
+      value: "topsites",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "0",
+        selType: "history",
+        selIndex: "0",
+      },
+    };
+  },
+
+  async function() {
+    info("Open the panel with dropmarker, type something, Enter.");
+    await BrowserTestUtils.withNewTab(
+      { gBrowser, url: "about:blank" },
+      async browser => {
+        gURLBar.select();
+        let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+        await UrlbarTestUtils.promisePopupOpen(window, () => {
+          let dropmarker = document.getAnonymousElementByAttribute(
+            gURLBar.textbox,
+            "anonid",
+            "historydropmarker"
+          );
+          EventUtils.synthesizeMouseAtCenter(dropmarker, {}, window);
+        });
+        await promiseAutocompleteResultPopup("x", window, true);
+        EventUtils.synthesizeKey("VK_RETURN");
+        await promise;
+      }
+    );
+    return {
+      category: "urlbar",
+      method: "engagement",
+      object: "enter",
+      value: "topsites",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "1",
+        selType: "search",
+        selIndex: "0",
+      },
+    };
+  },
+
+  /*
+   * Abandonment tests.
+   */
+
+  async function() {
+    info("Type something, blur.");
+    gURLBar.select();
+    EventUtils.synthesizeKey("x");
+    gURLBar.blur();
+    return {
+      category: "urlbar",
+      method: "abandonment",
+      object: "blur",
+      value: "typed",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "1",
+      },
+    };
+  },
+
+  async function() {
+    info("Open the panel with DOWN, don't type, blur it.");
+    gURLBar.value = "";
+    gURLBar.select();
+    await UrlbarTestUtils.promisePopupOpen(window, () => {
+      EventUtils.synthesizeKey("KEY_ArrowDown", {});
+    });
+    gURLBar.blur();
+    return {
+      category: "urlbar",
+      method: "abandonment",
+      object: "blur",
+      value: "topsites",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "0",
+      },
+    };
+  },
+
+  async function() {
+    info("Open the panel with dropmarker, type something, blur it.");
+    await BrowserTestUtils.withNewTab(
+      { gBrowser, url: "about:blank" },
+      async browser => {
+        gURLBar.select();
+        await UrlbarTestUtils.promisePopupOpen(window, () => {
+          let dropmarker = document.getAnonymousElementByAttribute(
+            gURLBar.textbox,
+            "anonid",
+            "historydropmarker"
+          );
+          EventUtils.synthesizeMouseAtCenter(dropmarker, {}, window);
+        });
+        EventUtils.synthesizeKey("x");
+        gURLBar.blur();
+      }
+    );
+    return {
+      category: "urlbar",
+      method: "abandonment",
+      object: "blur",
+      value: "topsites",
+      extra: {
+        elapsed: val => parseInt(val) > 0,
+        numChars: "1",
+      },
+    };
+  },
+
+  /*
+   * No event tests.
+   */
+
+  async function() {
+    info("Type something, click on search settings.");
+    await BrowserTestUtils.withNewTab(
+      { gBrowser, url: "about:blank" },
+      async browser => {
+        gURLBar.select();
+        let promise = BrowserTestUtils.browserLoaded(browser);
+        await promiseAutocompleteResultPopup("x", window, true);
+        UrlbarTestUtils.getOneOffSearchButtons(window).settingsButton.click();
+        await promise;
+      }
+    );
+    return null;
+  },
+
+  async function() {
+    info("Type something, Up, Enter on search settings.");
+    await BrowserTestUtils.withNewTab(
+      { gBrowser, url: "about:blank" },
+      async browser => {
+        gURLBar.select();
+        let promise = BrowserTestUtils.browserLoaded(browser);
+        await promiseAutocompleteResultPopup("x", window, true);
+        EventUtils.synthesizeKey("KEY_ArrowUp");
+        Assert.ok(
+          UrlbarTestUtils.getOneOffSearchButtons(
+            window
+          ).selectedButton.classList.contains("search-setting-button-compact"),
+          "Should have selected the settings button"
+        );
+        EventUtils.synthesizeKey("VK_RETURN");
+        await promise;
+      }
+    );
+    return null;
+  },
+];
+
+add_task(async function test() {
+  await SpecialPowers.pushPrefEnv({
+    set: [
+      ["browser.urlbar.eventTelemetry.enabled", true],
+      ["browser.urlbar.suggest.searches", true],
+    ],
+  });
+  // Create a new search engine and mark it as default
+  let engine = await SearchTestUtils.promiseNewSearchEngine(
+    getRootDirectory(gTestPath) + "searchSuggestionEngine.xml"
+  );
+  let oldDefaultEngine = await Services.search.getDefault();
+  await Services.search.setDefault(engine);
+  await Services.search.moveEngine(engine, 0);
+
+  let aliasEngine = await Services.search.addEngineWithDetails("Test", {
+    alias: "@test",
+    template: "http://example.com/?search={searchTerms}",
+  });
+
+  // Add a bookmark and a keyword.
+  let bm = await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+    url: "http://example.com/?q=%s",
+    title: "test",
+  });
+  await PlacesUtils.keywords.insert({
+    keyword: "kw",
+    url: "http://example.com/?q=%s",
+  });
+  await PlacesTestUtils.addVisits([
+    {
+      uri: "http://mochi.test:8888/",
+      transition: PlacesUtils.history.TRANSITIONS.TYPED,
+    },
+  ]);
+
+  registerCleanupFunction(async function() {
+    await Services.search.setDefault(oldDefaultEngine);
+    await Services.search.removeEngine(aliasEngine);
+    await PlacesUtils.keywords.remove("kw");
+    await PlacesUtils.bookmarks.remove(bm);
+    await PlacesUtils.history.clear();
+  });
+
+  // This is not necessary after each loop, because assertEvents does it.
+  Services.telemetry.clearEvents();
+
+  for (let testFn of tests) {
+    let expectedEvents = [await testFn()].filter(e => !!e);
+    // Always blur to ensure it's not accounted as an additional abandonment.
+    gURLBar.blur();
+    TelemetryTestUtils.assertEvents(expectedEvents, { category: "urlbar" });
+  }
+});
--- a/toolkit/components/telemetry/Events.yaml
+++ b/toolkit/components/telemetry/Events.yaml
@@ -268,16 +268,62 @@ navigation:
     bug_numbers: [1316281, 1496764]
     notification_emails:
       - "mdeboer@mozilla.com"
       - "rharter@mozilla.com"
     expiry_version: never
     extra_keys:
       engine: The id of the search engine used.
 
+urlbar:
+  engagement:
+    objects: ["click", "enter", "paste_go", "drop_go"]
+    release_channel_collection: opt-out
+    products:
+      - "firefox"
+    record_in_processes: ["main"]
+    description: >
+      This is recorded on urlbar engagement, that is when the user picks a
+      search result.
+      The value field records the initial interaction type. One of:
+        "typed", "dropped", "pasted", "topsites"
+    bug_numbers: [1559136]
+    notification_emails:
+      - "rharter@mozilla.com"
+      - "fx-search@mozilla.com"
+    expiry_version: never
+    extra_keys:
+      elapsed: engagement time in milliseconds.
+      numChars: number of input characters.
+      selIndex: index of the selected result in the urlbar panel, or -1.
+      selType: >
+        type of the selected result in the urlbar panel. One of:
+          "autofill", "visit", "bookmark", "history", "keyword", "search",
+          "searchsuggestion", "switchtab", "remotetab", "extension", "oneoff",
+          "keywordoffer", "canonized", "none"
+  abandonment:
+    objects: ["blur"]
+    release_channel_collection: opt-out
+    products:
+      - "firefox"
+    record_in_processes: ["main"]
+    description: >
+      This is recorded on urlbar search abandon, that is when the user starts
+      an interaction but then blurs the urlbar.
+      The value field records the initial interaction type. One of:
+        "typed", "dropped", "pasted", "topsites"
+    bug_numbers: [1559136]
+    notification_emails:
+      - "rharter@mozilla.com"
+      - "fx-search@mozilla.com"
+    expiry_version: never
+    extra_keys:
+      elapsed: abandonement time in milliseconds.
+      numChars: number of input characters.
+
 normandy:
   enroll:
     objects: ["preference_study", "addon_study", "preference_rollout"]
     description: >
       Sent when applying a Normandy recipe of the above types has succeeded.
     extra_keys:
       experimentType: >
         For preference_study recipes, the type of experiment this is ("exp" or "exp-highpop").