Bug 1374388 - Add performance telemetry to activity-stream system add-on r=ursula
authork88hudson <khudson@mozilla.com>
Tue, 20 Jun 2017 13:33:13 -0400
changeset 365340 3943c712e1cc71d45b129530b8c6d129a006ff1a
parent 365339 e0972fb49258381b0559b41cefa6058e9a3c55f1
child 365341 17a1967006d4da594594b5757096b9675d3340c3
push id91734
push userkwierso@gmail.com
push dateThu, 22 Jun 2017 01:05:37 +0000
treeherdermozilla-inbound@2576a0695305 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersursula
bugs1374388
milestone56.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 1374388 - Add performance telemetry to activity-stream system add-on r=ursula MozReview-Commit-ID: 61zE79jckem
browser/extensions/activity-stream/bootstrap.js
browser/extensions/activity-stream/common/PerfService.jsm
browser/extensions/activity-stream/data/content/activity-stream.bundle.js
browser/extensions/activity-stream/data/locales.json
browser/extensions/activity-stream/lib/TelemetryFeed.jsm
browser/extensions/activity-stream/lib/TelemetrySender.jsm
browser/extensions/activity-stream/test/schemas/pings.js
browser/extensions/activity-stream/test/unit/common/PerfService.test.js
browser/extensions/activity-stream/test/unit/lib/TelemetryFeed.test.js
browser/extensions/activity-stream/test/unit/lib/TelemetrySender.test.js
browser/extensions/activity-stream/test/unit/unit-entry.js
browser/extensions/activity-stream/test/unit/utils.js
--- a/browser/extensions/activity-stream/bootstrap.js
+++ b/browser/extensions/activity-stream/bootstrap.js
@@ -9,17 +9,17 @@ Cu.import("resource://gre/modules/XPCOMU
 
 XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
   "resource://gre/modules/Preferences.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
   "resource://gre/modules/Services.jsm");
 
 const ACTIVITY_STREAM_ENABLED_PREF = "browser.newtabpage.activity-stream.enabled";
-const BROWSER_READY_NOTIFICATION = "browser-ui-startup-complete";
+const BROWSER_READY_NOTIFICATION = "browser-delayed-startup-finished";
 const REASON_SHUTDOWN_ON_PREF_CHANGE = "PREF_OFF";
 const REASON_STARTUP_ON_PREF_CHANGE = "PREF_ON";
 const RESOURCE_BASE = "resource://activity-stream";
 
 const ACTIVITY_STREAM_OPTIONS = {newTabURL: "about:newtab"};
 
 let activityStream;
 let modulesToUnload = new Set();
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/common/PerfService.jsm
@@ -0,0 +1,95 @@
+/* globals Services */
+"use strict";
+
+let usablePerfObj;
+
+let Cu;
+const isRunningInChrome = typeof Window === "undefined";
+
+/* istanbul ignore if */
+if (isRunningInChrome) {
+  Cu = Components.utils;
+} else {
+  Cu = {import() {}};
+}
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+/* istanbul ignore if */
+if (isRunningInChrome) {
+  // Borrow the high-resolution timer from the hidden window....
+  usablePerfObj = Services.appShell.hiddenDOMWindow.performance;
+} else { // we must be running in content space
+  usablePerfObj = performance;
+}
+
+this._PerfService = function _PerfService(options) {
+  // For testing, so that we can use a fake Window.performance object with
+  // known state.
+  if (options && options.performanceObj) {
+    this._perf = options.performanceObj;
+  } else {
+    this._perf = usablePerfObj;
+  }
+};
+
+_PerfService.prototype = {
+  /**
+   * Calls the underlying mark() method on the appropriate Window.performance
+   * object to add a mark with the given name to the appropriate performance
+   * timeline.
+   *
+   * @param  {String} name  the name to give the current mark
+   * @return {void}
+   */
+  mark: function mark(str) {
+    this._perf.mark(str);
+  },
+
+  /**
+   * Calls the underlying getEntriesByName on the appropriate Window.performance
+   * object.
+   *
+   * @param  {String} name
+   * @param  {String} type eg "mark"
+   * @return {Array}       Performance* objects
+   */
+  getEntriesByName: function getEntriesByName(name, type) {
+    return this._perf.getEntriesByName(name, type);
+  },
+
+  /**
+   * The timeOrigin property from the appropriate performance object.
+   * Used to ensure that timestamps from the add-on code and the content code
+   * are comparable.
+   *
+   * @return {Number} A double of milliseconds with a precision of 0.5us.
+   */
+  get timeOrigin() {
+    return this._perf.timeOrigin;
+  },
+
+  /**
+   * This returns the startTime from the most recen!t performance.mark()
+   * with the given name.
+   *
+   * @param  {String} name  the name to lookup the start time for
+   *
+   * @return {Number}       the returned start time, as a DOMHighResTimeStamp
+   *
+   * @throws {Error}        "No Marks with the name ..." if none are available
+   */
+  getMostRecentAbsMarkStartByName(name) {
+    let entries = this.getEntriesByName(name, "mark");
+
+    if (!entries.length) {
+      throw new Error(`No marks with the name ${name}`);
+    }
+
+    let mostRecentEntry = entries[entries.length - 1];
+    return this._perf.timeOrigin + mostRecentEntry.startTime;
+  }
+};
+
+this.perfService = new _PerfService();
+this.EXPORTED_SYMBOLS = ["_PerfService", "perfService"];
--- a/browser/extensions/activity-stream/data/content/activity-stream.bundle.js
+++ b/browser/extensions/activity-stream/data/content/activity-stream.bundle.js
@@ -58,17 +58,17 @@
 /******/
 /******/ 	// Object.prototype.hasOwnProperty.call
 /******/ 	__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
 /******/
 /******/ 	// __webpack_public_path__
 /******/ 	__webpack_require__.p = "";
 /******/
 /******/ 	// Load entry module and return exports
-/******/ 	return __webpack_require__(__webpack_require__.s = 17);
+/******/ 	return __webpack_require__(__webpack_require__.s = 18);
 /******/ })
 /************************************************************************/
 /******/ ([
 /* 0 */
 /***/ (function(module, exports) {
 
 module.exports = React;
 
@@ -389,28 +389,32 @@ module.exports = connect(state => ({ App
 
 "use strict";
 
 
 var _require = __webpack_require__(1);
 
 const at = _require.actionTypes;
 
+var _require2 = __webpack_require__(15);
+
+const perfSvc = _require2.perfService;
+
 
 const VISIBLE = "visible";
 const VISIBILITY_CHANGE_EVENT = "visibilitychange";
 
 module.exports = class DetectUserSessionStart {
   constructor() {
     let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
 
     // Overrides for testing
     this.sendAsyncMessage = options.sendAsyncMessage || window.sendAsyncMessage;
     this.document = options.document || document;
-
+    this._perfService = options.perfService || perfSvc;
     this._onVisibilityChange = this._onVisibilityChange.bind(this);
   }
 
   /**
    * sendEventOrAddListener - Notify immediately if the page is already visible,
    *                    or else set up a listener for when visibility changes.
    *                    This is needed for accurate session tracking for telemetry,
    *                    because tabs are pre-loaded.
@@ -422,21 +426,29 @@ module.exports = class DetectUserSession
       this._sendEvent();
     } else {
       // If the document is not visible, listen for when it does become visible.
       this.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
     }
   }
 
   /**
-   * _sendEvent - Sends a message to the main process to indicate the current tab
-   *             is now visible to the user.
+   * _sendEvent - Sends a message to the main process to indicate the current
+   *              tab is now visible to the user, includes the
+   *              visibility-change-event time in ms from the UNIX epoch.
    */
   _sendEvent() {
-    this.sendAsyncMessage("ActivityStream:ContentToMain", { type: at.NEW_TAB_VISIBLE });
+    this._perfService.mark("visibility-change-event");
+
+    let absVisChangeTime = this._perfService.getMostRecentAbsMarkStartByName("visibility-change-event");
+
+    this.sendAsyncMessage("ActivityStream:ContentToMain", {
+      type: at.NEW_TAB_VISIBLE,
+      data: { absVisibilityChangeTime: absVisChangeTime }
+    });
   }
 
   /**
    * _onVisibilityChange - If the visibility has changed to visible, sends a notification
    *                      and removes the event listener. This should only be called once per tab.
    */
   _onVisibilityChange() {
     if (this.document.visibilityState === VISIBLE) {
@@ -450,17 +462,17 @@ module.exports = class DetectUserSession
 /* 6 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 
 
 /* eslint-env mozilla/frame-script */
 
-var _require = __webpack_require__(16);
+var _require = __webpack_require__(17);
 
 const createStore = _require.createStore,
       combineReducers = _require.combineReducers,
       applyMiddleware = _require.applyMiddleware;
 
 var _require2 = __webpack_require__(1);
 
 const au = _require2.actionUtils;
@@ -887,17 +899,17 @@ var _require = __webpack_require__(2);
 
 const connect = _require.connect;
 
 var _require2 = __webpack_require__(3);
 
 const injectIntl = _require2.injectIntl,
       FormattedMessage = _require2.FormattedMessage;
 
-const classNames = __webpack_require__(15);
+const classNames = __webpack_require__(16);
 
 var _require3 = __webpack_require__(1);
 
 const ac = _require3.actionCreators;
 
 
 const PreferencesInput = props => React.createElement(
   "section",
@@ -1039,39 +1051,49 @@ class Search extends React.Component {
       this.props.dispatch(ac.UserEvent({ event: "SEARCH" }));
     }
   }
   onClick(event) {
     this.controller.search(event);
   }
   onInputMount(input) {
     if (input) {
-      this.controller = new ContentSearchUIController(input, input.parentNode, "activity", "newtab");
+      // The first "newtab" parameter here is called the "healthReportKey" and needs
+      // to be "newtab" so that BrowserUsageTelemetry.jsm knows to handle events with
+      // this name, and can add the appropriate telemetry probes for search. Without the
+      // correct name, certain tests like browser_UsageTelemetry_content.js will fail (See
+      // github ticket #2348 for more details)
+      this.controller = new ContentSearchUIController(input, input.parentNode, "newtab", "newtab");
       addEventListener("ContentSearchClient", this);
     } else {
       this.controller = null;
       removeEventListener("ContentSearchClient", this);
     }
   }
 
+  /*
+   * Do not change the ID on the input field, as legacy newtab code
+   * specifically looks for the id 'newtab-search-text' on input fields
+   * in order to execute searches in various tests
+   */
   render() {
     return React.createElement(
       "form",
       { className: "search-wrapper" },
       React.createElement(
         "label",
-        { htmlFor: "search-input", className: "search-label" },
+        { htmlFor: "newtab-search-text", className: "search-label" },
         React.createElement(
           "span",
           { className: "sr-only" },
           React.createElement(FormattedMessage, { id: "search_web_placeholder" })
         )
       ),
       React.createElement("input", {
-        id: "search-input",
+        id: "newtab-search-text",
         maxLength: "256",
         placeholder: this.props.intl.formatMessage({ id: "search_web_placeholder" }),
         ref: this.onInputMount,
         title: this.props.intl.formatMessage({ id: "search_web_placeholder" }),
         type: "search" }),
       React.createElement(
         "button",
         {
@@ -1246,16 +1268,121 @@ module.exports = function shortURL(link)
   const eTLDExtra = eTLDLength > 0 ? -(eTLDLength + 1) : Infinity;
   return hostname.slice(0, eTLDExtra).toLowerCase();
 };
 
 /***/ }),
 /* 15 */
 /***/ (function(module, exports, __webpack_require__) {
 
+"use strict";
+/* globals Services */
+
+
+let usablePerfObj;
+
+let Cu;
+const isRunningInChrome = typeof Window === "undefined";
+
+/* istanbul ignore if */
+if (isRunningInChrome) {
+  Cu = Components.utils;
+} else {
+  Cu = { import() {} };
+}
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+/* istanbul ignore if */
+if (isRunningInChrome) {
+  // Borrow the high-resolution timer from the hidden window....
+  usablePerfObj = Services.appShell.hiddenDOMWindow.performance;
+} else {
+  // we must be running in content space
+  usablePerfObj = performance;
+}
+
+var _PerfService = function _PerfService(options) {
+  // For testing, so that we can use a fake Window.performance object with
+  // known state.
+  if (options && options.performanceObj) {
+    this._perf = options.performanceObj;
+  } else {
+    this._perf = usablePerfObj;
+  }
+};
+
+_PerfService.prototype = {
+  /**
+   * Calls the underlying mark() method on the appropriate Window.performance
+   * object to add a mark with the given name to the appropriate performance
+   * timeline.
+   *
+   * @param  {String} name  the name to give the current mark
+   * @return {void}
+   */
+  mark: function mark(str) {
+    this._perf.mark(str);
+  },
+
+  /**
+   * Calls the underlying getEntriesByName on the appropriate Window.performance
+   * object.
+   *
+   * @param  {String} name
+   * @param  {String} type eg "mark"
+   * @return {Array}       Performance* objects
+   */
+  getEntriesByName: function getEntriesByName(name, type) {
+    return this._perf.getEntriesByName(name, type);
+  },
+
+  /**
+   * The timeOrigin property from the appropriate performance object.
+   * Used to ensure that timestamps from the add-on code and the content code
+   * are comparable.
+   *
+   * @return {Number} A double of milliseconds with a precision of 0.5us.
+   */
+  get timeOrigin() {
+    return this._perf.timeOrigin;
+  },
+
+  /**
+   * This returns the startTime from the most recen!t performance.mark()
+   * with the given name.
+   *
+   * @param  {String} name  the name to lookup the start time for
+   *
+   * @return {Number}       the returned start time, as a DOMHighResTimeStamp
+   *
+   * @throws {Error}        "No Marks with the name ..." if none are available
+   */
+  getMostRecentAbsMarkStartByName(name) {
+    let entries = this.getEntriesByName(name, "mark");
+
+    if (!entries.length) {
+      throw new Error(`No marks with the name ${name}`);
+    }
+
+    let mostRecentEntry = entries[entries.length - 1];
+    return this._perf.timeOrigin + mostRecentEntry.startTime;
+  }
+};
+
+var perfService = new _PerfService();
+module.exports = {
+  _PerfService,
+  perfService
+};
+
+/***/ }),
+/* 16 */
+/***/ (function(module, exports, __webpack_require__) {
+
 var __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;/*!
   Copyright (c) 2016 Jed Watson.
   Licensed under the MIT License (MIT), see
   http://jedwatson.github.io/classnames
 */
 /* global define */
 
 (function () {
@@ -1298,23 +1425,23 @@ var __WEBPACK_AMD_DEFINE_ARRAY__, __WEBP
 				__WEBPACK_AMD_DEFINE_RESULT__ !== undefined && (module.exports = __WEBPACK_AMD_DEFINE_RESULT__));
 	} else {
 		window.classNames = classNames;
 	}
 }());
 
 
 /***/ }),
-/* 16 */
+/* 17 */
 /***/ (function(module, exports) {
 
 module.exports = Redux;
 
 /***/ }),
-/* 17 */
+/* 18 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 
 
 const React = __webpack_require__(0);
 const ReactDOM = __webpack_require__(8);
 const Base = __webpack_require__(4);
--- a/browser/extensions/activity-stream/data/locales.json
+++ b/browser/extensions/activity-stream/data/locales.json
@@ -856,16 +856,17 @@
     "edit_topsites_dismiss_button": "Dismiss this site"
   },
   "en-US": {
     "newtab_page_title": "New Tab",
     "default_label_loading": "Loading…",
     "header_top_sites": "Top Sites",
     "header_highlights": "Highlights",
     "header_stories": "Top Stories",
+    "header_visit_again": "Visit Again",
     "header_bookmarks": "Recent Bookmarks",
     "header_bookmarks_placeholder": "You don't have any bookmarks yet.",
     "header_stories_from": "from",
     "type_label_visited": "Visited",
     "type_label_bookmarked": "Bookmarked",
     "type_label_synced": "Synced from another device",
     "type_label_recommended": "Trending",
     "type_label_open": "Open",
@@ -898,16 +899,18 @@
     "settings_pane_search_body": "Search the Web from your new tab.",
     "settings_pane_topsites_header": "Top Sites",
     "settings_pane_topsites_body": "Access the websites you visit most.",
     "settings_pane_topsites_options_showmore": "Show two rows",
     "settings_pane_highlights_header": "Highlights",
     "settings_pane_highlights_body": "Look back at your recent browsing history and newly created bookmarks.",
     "settings_pane_bookmarks_header": "Recent Bookmarks",
     "settings_pane_bookmarks_body": "Your newly created bookmarks in one handy location.",
+    "settings_pane_visit_again_header": "Visit Again",
+    "settings_pane_visit_again_body": "Firefox will show you parts of your browsing history that you might want to remember or get back to.",
     "settings_pane_pocketstories_header": "Top Stories",
     "settings_pane_pocketstories_body": "Pocket, a part of the Mozilla family, will help connect you to high-quality content that you may not have found otherwise.",
     "settings_pane_done_button": "Done",
     "edit_topsites_button_text": "Edit",
     "edit_topsites_button_label": "Customize your Top Sites section",
     "edit_topsites_showmore_button": "Show more",
     "edit_topsites_showless_button": "Show less",
     "edit_topsites_done_button": "Done",
--- a/browser/extensions/activity-stream/lib/TelemetryFeed.jsm
+++ b/browser/extensions/activity-stream/lib/TelemetryFeed.jsm
@@ -1,51 +1,95 @@
 /* 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/. */
+/* globals Services */
 
 "use strict";
 
-const {utils: Cu} = Components;
+const {interfaces: Ci, utils: Cu} = Components;
 const {actionTypes: at, actionUtils: au} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
+const {perfService} = Cu.import("resource://activity-stream/common/PerfService.jsm", {});
 
 Cu.import("resource://gre/modules/ClientID.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "gUUIDGenerator",
   "@mozilla.org/uuid-generator;1",
   "nsIUUIDGenerator");
 XPCOMUtils.defineLazyModuleGetter(this, "TelemetrySender",
   "resource://activity-stream/lib/TelemetrySender.jsm");
 
 this.TelemetryFeed = class TelemetryFeed {
   constructor(options) {
     this.sessions = new Map();
     this.telemetryClientId = null;
     this.telemetrySender = null;
   }
 
   async init() {
+    Services.obs.addObserver(this.browserOpenNewtabStart, "browser-open-newtab-start");
+
     // TelemetrySender adds pref observers, so we initialize it after INIT
     this.telemetrySender = new TelemetrySender();
 
     const id = await ClientID.getClientID();
     this.telemetryClientId = id;
   }
 
+  browserOpenNewtabStart() {
+    perfService.mark("browser-open-newtab-start");
+  }
+
   /**
    * addSession - Start tracking a new session
    *
    * @param  {string} id the portID of the open session
+   * @param  {number} absVisChangeTime absolute timestamp of
+   *                                   document.visibilityState becoming visible
    */
-  addSession(id) {
+  addSession(id, absVisChangeTime) {
+    // XXX note that there is a race condition here; we're assuming that no
+    // other tab will be interleaving calls to browserOpenNewtabStart and
+    // addSession on this object.  For manually created windows, it's hard to
+    // imagine us hitting this race condition.
+    //
+    // However, for session restore, where multiple windows with multiple tabs
+    // might be restored much closer together in time, it's somewhat less hard,
+    // though it should still be pretty rare.
+    //
+    // The fix to this would be making all of the load-trigger notifications
+    // return some data with their notifications, and somehow propagate that
+    // data through closures into the tab itself so that we could match them
+    //
+    // As of this writing (very early days of system add-on perf telemetry),
+    // the hypothesis is that hitting this race should be so rare that makes
+    // more sense to live with the slight data inaccuracy that it would
+    // introduce, rather than doing the correct by complicated thing.  It may
+    // well be worth reexamining this hypothesis after we have more experience
+    // with the data.
+    let absBrowserOpenTabStart =
+      perfService.getMostRecentAbsMarkStartByName("browser-open-newtab-start");
+
     this.sessions.set(id, {
       start_time: Components.utils.now(),
       session_id: String(gUUIDGenerator.generateUUID()),
-      page: "about:newtab" // TODO: Handle about:home
+      page: "about:newtab", // TODO: Handle about:home here and in perf below
+      perf: {
+        load_trigger_ts: absBrowserOpenTabStart,
+        load_trigger_type: "menu_plus_or_keyboard",
+        visibility_event_rcvd_ts: absVisChangeTime
+      }
+    });
+
+    let duration = absVisChangeTime - absBrowserOpenTabStart;
+    this.store.dispatch({
+      type: at.TELEMETRY_PERFORMANCE_EVENT,
+      data: {visability_duration: duration}
     });
   }
 
   /**
    * endSession - Stop tracking a session
    *
    * @param  {string} portID the portID of the session that just closed
    */
@@ -114,32 +158,34 @@ this.TelemetryFeed = class TelemetryFeed
 
   createSessionEndEvent(session) {
     return Object.assign(
       this.createPing(),
       {
         session_id: session.session_id,
         page: session.page,
         session_duration: session.session_duration,
-        action: "activity_stream_session"
+        action: "activity_stream_session",
+        perf: session.perf
       }
     );
   }
 
   sendEvent(event) {
     this.telemetrySender.sendPing(event);
   }
 
   onAction(action) {
     switch (action.type) {
       case at.INIT:
         this.init();
         break;
       case at.NEW_TAB_VISIBLE:
-        this.addSession(au.getPortIdOfSender(action));
+        this.addSession(au.getPortIdOfSender(action),
+          action.data.absVisibilityChangeTime);
         break;
       case at.NEW_TAB_UNLOAD:
         this.endSession(au.getPortIdOfSender(action));
         break;
       case at.TELEMETRY_UNDESIRED_EVENT:
         this.sendEvent(this.createUndesiredEvent(action));
         break;
       case at.TELEMETRY_USER_EVENT:
@@ -147,15 +193,18 @@ this.TelemetryFeed = class TelemetryFeed
         break;
       case at.TELEMETRY_PERFORMANCE_EVENT:
         this.sendEvent(this.createPerformanceEvent(action));
         break;
     }
   }
 
   uninit() {
+    Services.obs.removeObserver(this.browserOpenNewtabStart,
+      "browser-open-newtab-start");
+
     this.telemetrySender.uninit();
     this.telemetrySender = null;
     // TODO: Send any unfinished sessions
   }
 };
 
 this.EXPORTED_SYMBOLS = ["TelemetryFeed"];
--- a/browser/extensions/activity-stream/lib/TelemetrySender.jsm
+++ b/browser/extensions/activity-stream/lib/TelemetrySender.jsm
@@ -1,68 +1,79 @@
 /* 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/. */
 
 const {interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.importGlobalProperties(["fetch"]);
-Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Console.jsm"); // eslint-disable-line no-console
 
 // This is intentionally a different pref-branch than the SDK-based add-on
 // used, to avoid extra weirdness for people who happen to have the SDK-based
 // installed.  Though maybe we should just forcibly disable the old add-on?
 const PREF_BRANCH = "browser.newtabpage.activity-stream.";
 
-const ENDPOINT_PREF = "telemetry.ping.endpoint";
-const TELEMETRY_PREF = "telemetry";
-const LOGGING_PREF = "telemetry.log";
+const ENDPOINT_PREF = `${PREF_BRANCH}telemetry.ping.endpoint`;
+const TELEMETRY_PREF = `${PREF_BRANCH}telemetry`;
+const LOGGING_PREF = `${PREF_BRANCH}telemetry.log`;
+
+const FHR_UPLOAD_ENABLED_PREF = "datareporting.healthreport.uploadEnabled";
 
 /**
  * Observe various notifications and send them to a telemetry endpoint.
  *
  * @param {Object} args - optional arguments
  * @param {Function} args.prefInitHook - if present, will be called back
  *                   inside the Prefs constructor. Typically used from tests
  *                   to save off a pointer to a fake Prefs instance so that
  *                   stubs and spies can be inspected by the test code.
- *
  */
 function TelemetrySender(args) {
-  let prefArgs = {branch: PREF_BRANCH};
+  let prefArgs = {};
   if (args) {
     if ("prefInitHook" in args) {
       prefArgs.initHook = args.prefInitHook;
     }
   }
 
   this._prefs = new Preferences(prefArgs);
 
-  this.enabled = this._prefs.get(TELEMETRY_PREF);
+  this._enabled = this._prefs.get(TELEMETRY_PREF);
   this._onTelemetryPrefChange = this._onTelemetryPrefChange.bind(this);
   this._prefs.observe(TELEMETRY_PREF, this._onTelemetryPrefChange);
 
+  this._fhrEnabled = this._prefs.get(FHR_UPLOAD_ENABLED_PREF);
+  this._onFhrPrefChange = this._onFhrPrefChange.bind(this);
+  this._prefs.observe(FHR_UPLOAD_ENABLED_PREF, this._onFhrPrefChange);
+
   this.logging = this._prefs.get(LOGGING_PREF);
   this._onLoggingPrefChange = this._onLoggingPrefChange.bind(this);
   this._prefs.observe(LOGGING_PREF, this._onLoggingPrefChange);
 
   this._pingEndpoint = this._prefs.get(ENDPOINT_PREF);
 }
 
 TelemetrySender.prototype = {
+  get enabled() {
+    return this._enabled && this._fhrEnabled;
+  },
 
   _onLoggingPrefChange(prefVal) {
     this.logging = prefVal;
   },
 
   _onTelemetryPrefChange(prefVal) {
-    this.enabled = prefVal;
+    this._enabled = prefVal;
+  },
+
+  _onFhrPrefChange(prefVal) {
+    this._fhrEnabled = prefVal;
   },
 
   async sendPing(data) {
     if (this.logging) {
       // performance related pings cause a lot of logging, so we mute them
       if (data.action !== "activity_stream_performance") {
         console.log(`TELEMETRY PING: ${JSON.stringify(data)}\n`); // eslint-disable-line no-console
       }
@@ -78,21 +89,23 @@ TelemetrySender.prototype = {
       Cu.reportError(`Ping failure with error: ${e}`);
     });
   },
 
   uninit() {
     try {
       this._prefs.ignore(TELEMETRY_PREF, this._onTelemetryPrefChange);
       this._prefs.ignore(LOGGING_PREF, this._onLoggingPrefChange);
+      this._prefs.ignore(FHR_UPLOAD_ENABLED_PREF, this._onFhrPrefChange);
     } catch (e) {
       Cu.reportError(e);
     }
   }
 };
 
 this.TelemetrySender = TelemetrySender;
 this.TelemetrySenderConstants = {
   ENDPOINT_PREF,
+  FHR_UPLOAD_ENABLED_PREF,
   TELEMETRY_PREF,
   LOGGING_PREF
 };
 this.EXPORTED_SYMBOLS = ["TelemetrySender", "TelemetrySenderConstants"];
--- a/browser/extensions/activity-stream/test/schemas/pings.js
+++ b/browser/extensions/activity-stream/test/schemas/pings.js
@@ -60,24 +60,49 @@ const PerfPing = Joi.object().keys(Objec
   action: Joi.valid("activity_stream_performance_event").required(),
   value: Joi.number().required()
 }));
 
 const SessionPing = Joi.object().keys(Object.assign({}, baseKeys, {
   session_id: baseKeys.session_id.required(),
   page: baseKeys.page.required(),
   session_duration: Joi.number().integer().required(),
-  action: Joi.valid("activity_stream_session").required()
+  action: Joi.valid("activity_stream_session").required(),
+  perf: Joi.object().keys({
+    // Timestamp of the action perceived by the user to trigger the load
+    // of this page.
+    //
+    // Not required at least for the error cases where the
+    // observer event doesn't fire
+    load_trigger_ts: Joi.number().positive()
+      .notes(["server counter", "server counter alert"]),
+
+    // What was the perceived trigger of the load action?
+    //
+    // Not required at least for the error cases where the observer event
+    // doesn't fire
+    load_trigger_type: Joi.valid(["menu_plus_or_keyboard"])
+      .notes(["server counter", "server counter alert"]),
+
+    // When the page itself receives an event that document.visibilityState
+    // == visible.
+    //
+    // Not required at least for the (error?) case where the
+    // visibility_event doesn't fire.  (It's not clear whether this
+    // can happen in practice, but if it does, we'd like to know about it).
+    visibility_event_rcvd_ts: Joi.number().positive()
+      .notes(["server counter", "server counter alert"])
+  }).required()
 }));
 
 function chaiAssertions(_chai, utils) {
   const {Assertion} = _chai;
 
   Assertion.addMethod("validate", function(schema, schemaName) {
-    const {error} = Joi.validate(this._obj, schema);
+    const {error} = Joi.validate(this._obj, schema, {allowUnknown: false});
     this.assert(
       !error,
       `Expected to be ${schemaName ? `a valid ${schemaName}` : "valid"} but there were errors: ${error}`
     );
   });
 
   const assertions = {
     /**
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/test/unit/common/PerfService.test.js
@@ -0,0 +1,84 @@
+/* globals assert, beforeEach, describe, it */
+const {_PerfService} = require("common/PerfService.jsm");
+const {FakePerformance} = require("test/unit/utils.js");
+
+let perfService;
+
+describe("_PerfService", () => {
+  let sandbox;
+  let fakePerfObj;
+
+  beforeEach(() => {
+    sandbox = sinon.sandbox.create();
+    fakePerfObj = new FakePerformance();
+    perfService = new _PerfService({performanceObj: fakePerfObj});
+  });
+
+  afterEach(() => {
+    sandbox.restore();
+  });
+
+  describe("#getEntriesByName", () => {
+    it("should call getEntriesByName on the appropriate Window.performance",
+    () => {
+      sandbox.spy(fakePerfObj, "getEntriesByName");
+
+      perfService.getEntriesByName("monkey", "mark");
+
+      assert.calledOnce(fakePerfObj.getEntriesByName);
+      assert.calledWithExactly(fakePerfObj.getEntriesByName, "monkey", "mark");
+    });
+
+    it("should return entries with the given name", () => {
+      sandbox.spy(fakePerfObj, "getEntriesByName");
+      perfService.mark("monkey");
+      perfService.mark("dog");
+
+      let marks = perfService.getEntriesByName("monkey", "mark");
+
+      assert.isArray(marks);
+      assert.lengthOf(marks, 1);
+      assert.propertyVal(marks[0], "name", "monkey");
+    });
+  });
+
+  describe("#getMostRecentAbsMarkStartByName", () => {
+    it("should throw an error if there is no mark with the given name", () => {
+      function bogusGet() {
+        perfService.getMostRecentAbsMarkStartByName("rheeeet");
+      }
+
+      assert.throws(bogusGet, Error, /No marks with the name/);
+    });
+
+    it("should return the Number from the most recent mark with the given name + the time origin",
+      () => {
+        perfService.mark("dog");
+        perfService.mark("dog");
+
+        let absMarkStart = perfService.getMostRecentAbsMarkStartByName("dog");
+
+        // 2 because we want the result of the 2nd call to mark, and an instance
+        // of FakePerformance just returns the number of time mark has been
+        // called.
+        assert.equal(absMarkStart - perfService.timeOrigin, 2);
+      });
+  });
+
+  describe("#mark", () => {
+    it("should call the wrapped version of mark", () => {
+      sandbox.spy(fakePerfObj, "mark");
+
+      perfService.mark("monkey");
+
+      assert.calledOnce(fakePerfObj.mark);
+      assert.calledWithExactly(fakePerfObj.mark, "monkey");
+    });
+  });
+
+  describe("#timeOrigin", () => {
+    it("should get the origin of the wrapped performance object", () => {
+      assert.equal(perfService.timeOrigin, 10000); // fake origin from utils.js
+    });
+  });
+});
--- a/browser/extensions/activity-stream/test/unit/lib/TelemetryFeed.test.js
+++ b/browser/extensions/activity-stream/test/unit/lib/TelemetryFeed.test.js
@@ -1,8 +1,10 @@
+/* global Services */
+
 const injector = require("inject!lib/TelemetryFeed.jsm");
 const {GlobalOverrider} = require("test/unit/utils");
 const {actionCreators: ac, actionTypes: at} = require("common/Actions.jsm");
 const {
   BasePing,
   UndesiredPing,
   UserEventPing,
   PerfPing,
@@ -10,20 +12,28 @@ const {
 } = require("test/schemas/pings");
 
 const FAKE_TELEMETRY_ID = "foo123";
 const FAKE_UUID = "{foo-123-foo}";
 
 describe("TelemetryFeed", () => {
   let globals;
   let sandbox;
-  let store = {getState() { return {App: {version: "1.0.0", locale: "en-US"}}; }};
+  let store = {
+    dispatch() {},
+    getState() { return {App: {version: "1.0.0", locale: "en-US"}}; }
+  };
   let instance;
   class TelemetrySender {sendPing() {} uninit() {}}
-  const {TelemetryFeed} = injector({"lib/TelemetrySender.jsm": {TelemetrySender}});
+  class PerfService {getMostRecentAbsMarkStartByName() { return 1234; } mark() {}}
+  const perfService = new PerfService();
+  const {TelemetryFeed} = injector({
+    "lib/TelemetrySender.jsm": {TelemetrySender},
+    "common/PerfService.jsm": {perfService}
+  });
 
   function addSession(id) {
     instance.addSession(id);
     return instance.sessions.get(id);
   }
 
   beforeEach(() => {
     globals = new GlobalOverrider();
@@ -42,16 +52,25 @@ describe("TelemetryFeed", () => {
       await instance.init();
       assert.instanceOf(instance.telemetrySender, TelemetrySender);
     });
     it("should add .telemetryClientId from the ClientID module", async () => {
       assert.isNull(instance.telemetryClientId);
       await instance.init();
       assert.equal(instance.telemetryClientId, FAKE_TELEMETRY_ID);
     });
+    it("should make this.browserOpenNewtabStart() observe browser-open-newtab-start", async () => {
+      sandbox.spy(Services.obs, "addObserver");
+
+      await instance.init();
+
+      assert.calledOnce(Services.obs.addObserver);
+      assert.calledWithExactly(Services.obs.addObserver,
+        instance.browserOpenNewtabStart, "browser-open-newtab-start");
+    });
   });
   describe("#addSession", () => {
     it("should add a session", () => {
       addSession("foo");
       assert.isTrue(instance.sessions.has("foo"));
     });
     it("should set the start_time", () => {
       sandbox.spy(Components.utils, "now");
@@ -65,16 +84,27 @@ describe("TelemetryFeed", () => {
       assert.calledOnce(global.gUUIDGenerator.generateUUID);
       assert.equal(session.session_id, global.gUUIDGenerator.generateUUID.firstCall.returnValue);
     });
     it("should set the page", () => {
       const session = addSession("foo");
       assert.equal(session.page, "about:newtab"); // This is hardcoded for now.
     });
   });
+  describe("#browserOpenNewtabStart", () => {
+    it("should call perfService.mark with browser-open-newtab-start", () => {
+      sandbox.stub(perfService, "mark");
+
+      instance.browserOpenNewtabStart();
+
+      assert.calledOnce(perfService.mark);
+      assert.calledWithExactly(perfService.mark, "browser-open-newtab-start");
+    });
+  });
+
   describe("#endSession", () => {
     it("should not throw if there is no session for the given port ID", () => {
       assert.doesNotThrow(() => instance.endSession("doesn't exist"));
     });
     it("should add a session_duration", () => {
       sandbox.stub(instance, "sendEvent");
       const session = addSession("foo");
       instance.endSession("foo");
@@ -184,17 +214,22 @@ describe("TelemetryFeed", () => {
         assert.propertyVal(ping, "value", 100);
       });
     });
     describe("#createSessionEndEvent", () => {
       it("should create a valid event", () => {
         const ping = instance.createSessionEndEvent({
           session_id: FAKE_UUID,
           page: "about:newtab",
-          session_duration: 12345
+          session_duration: 12345,
+          perf: {
+            load_trigger_ts: 10,
+            load_trigger_type: "menu_plus_or_keyboard",
+            visibility_event_rcvd_ts: 20
+          }
         });
         // Is it valid?
         assert.validate(ping, SessionPing);
         assert.propertyVal(ping, "session_id", FAKE_UUID);
         assert.propertyVal(ping, "page", "about:newtab");
         assert.propertyVal(ping, "session_duration", 12345);
       });
     });
@@ -211,26 +246,40 @@ describe("TelemetryFeed", () => {
   describe("#uninit", () => {
     it("should call .telemetrySender.uninit and remove it", async () => {
       await instance.init();
       const stub = sandbox.stub(instance.telemetrySender, "uninit");
       instance.uninit();
       assert.calledOnce(stub);
       assert.isNull(instance.telemetrySender);
     });
+    it("should make this.browserOpenNewtabStart() stop observing browser-open-newtab-start", async () => {
+      await instance.init();
+      sandbox.spy(Services.obs, "removeObserver");
+      sandbox.stub(instance.telemetrySender, "uninit");
+
+      await instance.uninit();
+
+      assert.calledOnce(Services.obs.removeObserver);
+      assert.calledWithExactly(Services.obs.removeObserver,
+        instance.browserOpenNewtabStart, "browser-open-newtab-start");
+    });
   });
   describe("#onAction", () => {
     it("should call .init() on an INIT action", () => {
       const stub = sandbox.stub(instance, "init");
       instance.onAction({type: at.INIT});
       assert.calledOnce(stub);
     });
     it("should call .addSession() on a NEW_TAB_VISIBLE action", () => {
       const stub = sandbox.stub(instance, "addSession");
-      instance.onAction(ac.SendToMain({type: at.NEW_TAB_VISIBLE}, "port123"));
+      instance.onAction(ac.SendToMain({
+        type: at.NEW_TAB_VISIBLE,
+        data: {absVisibilityChangeTime: 789}
+      }, "port123"));
       assert.calledWith(stub, "port123");
     });
     it("should call .endSession() on a NEW_TAB_UNLOAD action", () => {
       const stub = sandbox.stub(instance, "endSession");
       instance.onAction(ac.SendToMain({type: at.NEW_TAB_UNLOAD}, "port123"));
       assert.calledWith(stub, "port123");
     });
     it("should send an event on an TELEMETRY_UNDESIRED_EVENT action", () => {
--- a/browser/extensions/activity-stream/test/unit/lib/TelemetrySender.test.js
+++ b/browser/extensions/activity-stream/test/unit/lib/TelemetrySender.test.js
@@ -1,23 +1,25 @@
 // Any copyright is dedicated to the Public Domain.
 // http://creativecommons.org/publicdomain/zero/1.0/
 
 const {GlobalOverrider, FakePrefs} = require("test/unit/utils");
 const {TelemetrySender, TelemetrySenderConstants} = require("lib/TelemetrySender.jsm");
-const {ENDPOINT_PREF, TELEMETRY_PREF, LOGGING_PREF} = TelemetrySenderConstants;
+const {ENDPOINT_PREF, FHR_UPLOAD_ENABLED_PREF, TELEMETRY_PREF, LOGGING_PREF} =
+  TelemetrySenderConstants;
 
 /**
  * A reference to the fake preferences object created by the TelemetrySender
  * constructor so that we can use the API.
  */
 let fakePrefs;
 const prefInitHook = function() {
   fakePrefs = this; // eslint-disable-line consistent-this
 };
+
 const tsArgs = {prefInitHook};
 
 describe("TelemetrySender", () => {
   let globals;
   let tSender;
   let sandbox;
   let fetchStub;
   const fakeEndpointUrl = "http://127.0.0.1/stuff";
@@ -43,44 +45,118 @@ describe("TelemetrySender", () => {
   it("should construct the Prefs object", () => {
     globals.sandbox.spy(global, "Preferences");
 
     tSender = new TelemetrySender(tsArgs);
 
     assert.calledOnce(global.Preferences);
   });
 
-  it("should set the enabled prop to false if the pref is false", () => {
-    FakePrefs.prototype.prefs = {};
-    FakePrefs.prototype.prefs[TELEMETRY_PREF] = false;
+  describe("#enabled", () => {
+    let testParams = [
+      {enabledPref: true, fhrPref: true, result: true},
+      {enabledPref: false, fhrPref: true, result: false},
+      {enabledPref: true, fhrPref: false, result: false},
+      {enabledPref: false, fhrPref: false, result: false}
+    ];
+
+    function testEnabled(p) {
+      FakePrefs.prototype.prefs[TELEMETRY_PREF] = p.enabledPref;
+      FakePrefs.prototype.prefs[FHR_UPLOAD_ENABLED_PREF] = p.fhrPref;
+
+      tSender = new TelemetrySender(tsArgs);
+
+      assert.equal(tSender.enabled, p.result);
+    }
 
-    tSender = new TelemetrySender(tsArgs);
+    for (let p of testParams) {
+      it(`should return ${p.result} if the fhrPref is ${p.fhrPref} and telemetry.enabled is ${p.enabledPref}`, () => {
+        testEnabled(p);
+      });
+    }
+
+    describe("telemetry.enabled pref changes from true to false", () => {
+      beforeEach(() => {
+        FakePrefs.prototype.prefs = {};
+        FakePrefs.prototype.prefs[TELEMETRY_PREF] = true;
+        FakePrefs.prototype.prefs[FHR_UPLOAD_ENABLED_PREF] = true;
+        tSender = new TelemetrySender(tsArgs);
+        assert.propertyVal(tSender, "enabled", true);
+      });
+
+      it("should set the enabled property to false", () => {
+        fakePrefs.set(TELEMETRY_PREF, false);
+
+        assert.propertyVal(tSender, "enabled", false);
+      });
+    });
 
-    assert.isFalse(tSender.enabled);
-  });
+    describe("telemetry.enabled pref changes from false to true", () => {
+      beforeEach(() => {
+        FakePrefs.prototype.prefs = {};
+        FakePrefs.prototype.prefs[FHR_UPLOAD_ENABLED_PREF] = true;
+        FakePrefs.prototype.prefs[TELEMETRY_PREF] = false;
+        tSender = new TelemetrySender(tsArgs);
+
+        assert.propertyVal(tSender, "enabled", false);
+      });
+
+      it("should set the enabled property to true", () => {
+        fakePrefs.set(TELEMETRY_PREF, true);
+
+        assert.propertyVal(tSender, "enabled", true);
+      });
+    });
 
-  it("should set the enabled prop to true if the pref is true", () => {
-    FakePrefs.prototype.prefs = {};
-    FakePrefs.prototype.prefs[TELEMETRY_PREF] = true;
+    describe("FHR enabled pref changes from true to false", () => {
+      beforeEach(() => {
+        FakePrefs.prototype.prefs = {};
+        FakePrefs.prototype.prefs[TELEMETRY_PREF] = true;
+        FakePrefs.prototype.prefs[FHR_UPLOAD_ENABLED_PREF] = true;
+        tSender = new TelemetrySender(tsArgs);
+        assert.propertyVal(tSender, "enabled", true);
+      });
+
+      it("should set the enabled property to false", () => {
+        fakePrefs.set(FHR_UPLOAD_ENABLED_PREF, false);
+
+        assert.propertyVal(tSender, "enabled", false);
+      });
+    });
 
-    tSender = new TelemetrySender(tsArgs);
+    describe("FHR enabled pref changes from false to true", () => {
+      beforeEach(() => {
+        FakePrefs.prototype.prefs = {};
+        FakePrefs.prototype.prefs[FHR_UPLOAD_ENABLED_PREF] = false;
+        FakePrefs.prototype.prefs[TELEMETRY_PREF] = true;
+        tSender = new TelemetrySender(tsArgs);
 
-    assert.isTrue(tSender.enabled);
+        assert.propertyVal(tSender, "enabled", false);
+      });
+
+      it("should set the enabled property to true", () => {
+        fakePrefs.set(FHR_UPLOAD_ENABLED_PREF, true);
+
+        assert.propertyVal(tSender, "enabled", true);
+      });
+    });
   });
 
   describe("#sendPing()", () => {
     beforeEach(() => {
       FakePrefs.prototype.prefs = {};
+      FakePrefs.prototype.prefs[FHR_UPLOAD_ENABLED_PREF] = true;
       FakePrefs.prototype.prefs[TELEMETRY_PREF] = true;
       FakePrefs.prototype.prefs[ENDPOINT_PREF] = fakeEndpointUrl;
       tSender = new TelemetrySender(tsArgs);
     });
 
     it("should not send if the TelemetrySender is disabled", async () => {
-      tSender.enabled = false;
+      FakePrefs.prototype.prefs[TELEMETRY_PREF] = false;
+      tSender = new TelemetrySender(tsArgs);
 
       await tSender.sendPing(fakePingJSON);
 
       assert.notCalled(fetchStub);
     });
 
     it("should POST given ping data to telemetry.ping.endpoint pref w/fetch",
     async () => {
@@ -127,16 +203,25 @@ describe("TelemetrySender", () => {
       tSender = new TelemetrySender(tsArgs);
       assert.property(fakePrefs.observers, TELEMETRY_PREF);
 
       tSender.uninit();
 
       assert.notProperty(fakePrefs.observers, TELEMETRY_PREF);
     });
 
+    it("should remove the fhrpref listener", () => {
+      tSender = new TelemetrySender(tsArgs);
+      assert.property(fakePrefs.observers, FHR_UPLOAD_ENABLED_PREF);
+
+      tSender.uninit();
+
+      assert.notProperty(fakePrefs.observers, FHR_UPLOAD_ENABLED_PREF);
+    });
+
     it("should remove the telemetry log listener", () => {
       tSender = new TelemetrySender(tsArgs);
       assert.property(fakePrefs.observers, LOGGING_PREF);
 
       tSender.uninit();
 
       assert.notProperty(fakePrefs.observers, TELEMETRY_PREF);
     });
@@ -147,46 +232,16 @@ describe("TelemetrySender", () => {
 
       tSender.uninit();
 
       assert.called(global.Components.utils.reportError);
     });
   });
 
   describe("Misc pref changes", () => {
-    describe("telemetry changes from true to false", () => {
-      beforeEach(() => {
-        FakePrefs.prototype.prefs = {};
-        FakePrefs.prototype.prefs[TELEMETRY_PREF] = true;
-        tSender = new TelemetrySender(tsArgs);
-        assert.propertyVal(tSender, "enabled", true);
-      });
-
-      it("should set the enabled property to false", () => {
-        fakePrefs.set(TELEMETRY_PREF, false);
-
-        assert.propertyVal(tSender, "enabled", false);
-      });
-    });
-
-    describe("telemetry changes from false to true", () => {
-      beforeEach(() => {
-        FakePrefs.prototype.prefs = {};
-        FakePrefs.prototype.prefs[TELEMETRY_PREF] = false;
-        tSender = new TelemetrySender(tsArgs);
-        assert.propertyVal(tSender, "enabled", false);
-      });
-
-      it("should set the enabled property to true", () => {
-        fakePrefs.set(TELEMETRY_PREF, true);
-
-        assert.propertyVal(tSender, "enabled", true);
-      });
-    });
-
     describe("performance.log changes from false to true", () => {
       it("should change this.logging from false to true", () => {
         FakePrefs.prototype.prefs = {};
         FakePrefs.prototype.prefs[LOGGING_PREF] = false;
         tSender = new TelemetrySender(tsArgs);
         assert.propertyVal(tSender, "logging", false);
 
         fakePrefs.set(LOGGING_PREF, true);
--- a/browser/extensions/activity-stream/test/unit/unit-entry.js
+++ b/browser/extensions/activity-stream/test/unit/unit-entry.js
@@ -1,20 +1,21 @@
-const {GlobalOverrider, FakePrefs} = require("test/unit/utils");
+const {GlobalOverrider, FakePrefs, FakePerformance} = require("test/unit/utils");
 const {chaiAssertions} = require("test/schemas/pings");
 
 const req = require.context(".", true, /\.test\.jsx?$/);
 const files = req.keys();
 
 // This exposes sinon assertions to chai.assert
 sinon.assert.expose(assert, {prefix: ""});
 
 chai.use(chaiAssertions);
 
 let overrider = new GlobalOverrider();
+
 overrider.set({
   Components: {
     interfaces: {},
     utils: {
       import() {},
       importGlobalProperties() {},
       reportError() {},
       now: () => window.performance.now()
@@ -26,16 +27,17 @@ overrider.set({
   fetch() {},
   Preferences: FakePrefs,
   Services: {
     locale: {getRequestedLocale() {}},
     mm: {
       addMessageListener: (msg, cb) => cb(),
       removeMessageListener() {}
     },
+    appShell: {hiddenDOMWindow: {performance: new FakePerformance()}},
     obs: {
       addObserver() {},
       removeObserver() {}
     },
     prefs: {
       getDefaultBranch() {
         return {
           setBoolPref() {},
--- a/browser/extensions/activity-stream/test/unit/utils.js
+++ b/browser/extensions/activity-stream/test/unit/utils.js
@@ -111,16 +111,56 @@ FakePrefs.prototype = {
     this.prefs[prefName] = value;
 
     if (prefName in this.observers) {
       this.observers[prefName](value);
     }
   }
 };
 
+function FakePerformance() {}
+FakePerformance.prototype = {
+  marks: new Map(),
+  now() {
+    return window.performance.now();
+  },
+  timing: {navigationStart: 222222},
+  get timeOrigin() {
+    return 10000;
+  },
+  // XXX assumes type == "mark"
+  getEntriesByName(name, type) {
+    if (this.marks.has(name)) {
+      return this.marks.get(name);
+    }
+    return [];
+  },
+  callsToMark: 0,
+
+  /**
+   * @note The "startTime" for each mark is simply the number of times mark
+   * has been called in this object.
+   */
+  mark(name) {
+    let markObj = {
+      name,
+      "entryType": "mark",
+      "startTime": ++this.callsToMark,
+      "duration": 0
+    };
+
+    if (this.marks.has(name)) {
+      this.marks.get(name).push(markObj);
+      return;
+    }
+
+    this.marks.set(name, [markObj]);
+  }
+};
+
 /**
  * addNumberReducer - a simple dummy reducer for testing that adds a number
  */
 function addNumberReducer(prevState = 0, action) {
   return action.type === "ADD" ? prevState + action.data : prevState;
 }
 
 /**
@@ -137,14 +177,15 @@ function shallowWithIntl(node) {
 function mountWithIntl(node) {
   return mount(nodeWithIntlProp(node), {
     context: {intl},
     childContextTypes: {intl: intlShape}
   });
 }
 
 module.exports = {
+  FakePerformance,
   FakePrefs,
   GlobalOverrider,
   addNumberReducer,
   mountWithIntl,
   shallowWithIntl
 };