Bug 1374388 - Add performance telemetry to activity-stream system add-on draft bug1374388
authork88hudson <khudson@mozilla.com>
Tue, 20 Jun 2017 13:33:13 -0400
changeset 597591 e432f763232925d6baf249ad7fa77d369348bd05
parent 594702 035c25bef7b5e4175006e63eff10c61c2eef73f1
child 634263 405028869d657ccbb1b94496a747e50b3e5781fb
push id64968
push userkhudson@mozilla.com
push dateTue, 20 Jun 2017 17:34:25 +0000
bugs1374388
milestone56.0a1
Bug 1374388 - Add performance telemetry to activity-stream system add-on 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
 };