boomerangjs
Version:
boomerang always comes back, except when it hits something
762 lines (659 loc) • 24.8 kB
JavaScript
/**
* Enables Single Page App (SPA) performance monitoring.
*
* **Note**: The `SPA` plugin requires the {@link BOOMR.plugins.AutoXHR} plugin
* to be loaded before `SPA`, and one of the following SPA plugins to work:
*
* * {@link BOOMR.plugins.Angular}
* * {@link BOOMR.plugins.Backbone}
* * {@link BOOMR.plugins.Ember}
* * {@link BOOMR.plugins.History} (React and all other SPA support)
*
* You also need to disable {@link BOOMR.init|autorun} when enabling SPA support.
*
* If you are not using a SPA framework but rely mostly on `XMLHttpRequests` to
* build your site, you might be able to skip the SPA plugins and just enable
* the {@link BOOMR.plugins.AutoXHR} plugin to measure your site.
*
* For information on how to include this plugin, see the {@tutorial building} tutorial.
*
* ## Approach
*
* Boomerang monitors Single Page App (SPA) navigations differently than how it
* monitors navigations on traditional websites.
*
* On traditional websites, the browser completes a full navigation for every page.
* During this navigation, the browser requests the page's HTML, JavaScript,
* CSS, etc., from the server, and builds the page from these components. Boomerang
* monitors this entire process.
*
* On SPA websites, only the first page that the visitor loads is a full
* navigation. All subsequent navigations are handled by the SPA framework
* itself (i.e. AngularJS), where they dynamically pull in the content they
* need to render the new page. This is done without executing a full navigation
* from the browser's point of view.
*
* Boomerang was designed for traditional websites, where a full navigation
* occurs on each page load. During the navigation, Boomerang tracks the
* performance characteristics of the entire page load experience. However, for
* SPA websites, only the first page triggers a full navigation. Thus, without
* any additional help, Boomerang will not track any subsequent interactions
* on SPA websites.
*
* To give visibility into SPA website navigations, there are several Boomerang
* plugins available for SPA frameworks, such as AngularJS, Ember.js and Backbone.js.
* When these plugins are enabled, Boomerang is able to track all of the SPA
* navigations beyond the first, initial navigation.
*
* To do so, the Boomerang SPA plugins listen for several life cycle events from
* the framework, such as AngularJS's `$routeChangeStart`. Once it gets notified
* of these events, the Boomerang SPA plugins start monitoring the page's markup
* (DOM) for changes. If any of these changes trigger a download, such as a
* XHR, image, CSS, or JavaScript, then the Boomerang SPA plugins monitor those
* resources as well. Only once all of these new resources have been fetched do
* the Boomerang SPA plugins consider the SPA navigation complete.
*
* For a further explanation of the challenges of measuring SPAs, see our
* {@link https://www.slideshare.net/nicjansma/measuring-the-performance-of-single-page-applications|slides}
* or our {@link https://www.youtube.com/watch?v=CYEYtQPofhQ&t=10s|talk}.
*
* ## Hard and Soft Navigations
*
* * A **SPA Hard Navigation** is always the first navigation to the site, plus
* any of the work required to build the initial view.
* * The Hard Navigation will track at least the length of `onload`, but may also include the additional
* time required to load the framework (for example, Angular) and the first view.
* * A SPA site will only have a single SPA Hard Navigation, no "Page Load" beacons.
* * The `http.initiator` type is `spa_hard`
* * A **SPA Soft Navigation** is any navigation after the Hard Navigation.
* * A soft navigation is an "in-page" navigation where the view changes, but
* the browser does not actually fully navigate.
* * A SPA site could have zero through many Soft Navigations
* * The `http.initiator` type is `spa`
*
* ## Navigation Timestamps
*
* ### Hard Navigations
*
* The length of a Hard Navigation is calculated from the beginning of the browser
* navigation (e.g. `navigationStart` from NavigationTiming) through when the
* last critical resource has been fetched for the page.
*
* Critical resources include Images, IFRAMEs, CSS and Scripts.
*
* ### Soft Navigations
*
* The length of a Soft Navigation is calculated from the beginning of the route
* change event (e.g. when the user clicked somewhere to change the view) through
* when the last critical resource has been fetched for the page.
*
* ## Front-End vs. Back-End Time
*
* For SPA navigations, the _Back End_ time (`t_resp`) is calculated as any period
* where a XHR or Script tag was being fetched.
*
* The _Front End_ time (`t_page`) is calculated by taking the total SPA Page
* Load time (`t_done`) minus _Back End_ time (`t_resp`).
*
* ## Beacon Parameters
*
* * `http.initiator`
* * `spa_hard` for Hard Navigations
* * `spa` for Soft Navigations
* * `spa.snh.s`: When Soft Navigation Heuristics is active, the latest start time (Unix Epoch)
* * `spa.snh.n`: When Soft Navigation Heuristics is active, the number of Soft Navs detected for this beacon
*
* @class BOOMR.plugins.SPA
*/
(function() {
var hooked = false,
initialized = false,
initialRouteChangeStarted = false,
initialRouteChangeCompleted = false,
autoXhrEnabled = false,
firstSpaNav = true,
routeFilter = false,
routeChangeWaitFilter = false,
routeChangeWaitFilterHardNavs = false,
disableHardNav = false,
supported = [],
latestResource,
waitingOnHardMissedComplete = false,
//
// Soft Navigation Heuristics
//
/* BEGIN_DEBUG */
softNavEntries = [],
/* END_DEBUG */
lastSoftNavStart = 0,
softNavsSeen = 0;
BOOMR = window.BOOMR || {};
BOOMR.plugins = BOOMR.plugins || {};
if (BOOMR.plugins.SPA || !BOOMR.plugins.AutoXHR) {
return;
}
/* BEGIN_DEBUG */
/**
* Debug logging for this plugin
*
* @param {string} msg Message
*/
function debugLog(msg) {
BOOMR.debug(msg, "SPA");
}
/* END_DEBUG */
var impl = {
//
// Variables
//
/**
* Whether or not to use Soft Nav Heuristic's startTime for SPA navigation start
*/
useSoftNavStart: false,
/**
* Called after a SPA Hard navigation that missed the route change
* completes.
*
* We may want to fix-up the timings of the SPA navigation if there was
* any other activity after onload.
*
* If there was not activity after onload, using the timings for
* onload from NavigationTiming.
*
* If there was activity after onload, use the end time of the latest
* resource.
*
* @param {BOOMR.plugins.AutoXHR.Resource} resource Resource
*/
spaHardMissedOnComplete: function(resource) {
var p,
navigationStart = (BOOMR.plugins.RT && BOOMR.plugins.RT.navigationStart()),
ev,
mh = BOOMR.plugins.AutoXHR.getMutationHandler();
waitingOnHardMissedComplete = false;
// note that we missed the route change on the beacon for debugging
BOOMR.addVar("spa.missed", "1", true);
// ensure t_done is the time we've specified
if (BOOMR.plugins.RT) {
BOOMR.plugins.RT.clearTimer("t_done");
}
// always use the start time of navigationStart
resource.timing.requestStart = navigationStart;
ev = mh.pending_events[resource.index];
if (!ev || ev.total_nodes === 0) {
// No other resources (xhrs or mutations) were detected, so set the end time
// to NavigationTiming's page loadEventEnd if available (instead of 'now')
p = BOOMR.getPerformance();
if (p &&
p.timing &&
p.timing.navigationStart &&
p.timing.loadEventEnd &&
// loadEventEnd may have been set by a wait filter
typeof resource.timing.loadEventEnd === "undefined") {
resource.timing.loadEventEnd = p.timing.loadEventEnd;
}
}
},
/**
* Fired on a non-spa page load
*/
pageReady: function() {
// a non-spa page load fired, disableHardNav might be enabled
initialRouteChangeCompleted = true;
},
/**
* Soft Navigation Observer
*
* Example entry:
*
* ```json
* {
* "name": "https://boomerang-test.local:4002/pages/35-spa/nav1",
* "entryType":"soft-navigation",
* "startTime":781746.3000000119,
* "duration":0,
* "navigationId":"3c27699b-af3c-4750-823b-7c63b99571b0"
* }
* ```
*
* @param {PerformanceEntry[]} list Entries
*/
onSoftNavObserver: function(list) {
var entries = list.getEntries();
if (entries.length === 0) {
return;
}
/* BEGIN_DEBUG */
softNavEntries = softNavEntries.concat(entries);
/* END_DEBUG */
// Track the latest Soft Nav start
lastSoftNavStart = entries[entries.length - 1].startTime;
softNavsSeen += entries.length;
},
/**
* beforebeacon: Soft Navigation data
*
* @param {object} data Baecon data
*/
onSoftNavObserverBeforeBeacon: function(data) {
// only apply to soft navs
if (data["http.initiator"] !== "spa") {
return;
}
if (lastSoftNavStart) {
var startTime = Math.floor(
(BOOMR.plugins.RT.navigationStart() || BOOMR.t_lstart || BOOMR.t_start) +
lastSoftNavStart);
BOOMR.addVar("spa.snh.s", startTime, true);
lastSoftNavStart = 0;
}
// Number of Soft Navs Seen
BOOMR.addVar("spa.snh.n", softNavsSeen, true);
softNavsSeen = 0;
}
};
//
// Exports
//
BOOMR.plugins.SPA = {
/**
* Determines if the plugin is complete
*
* @returns {boolean} True if the plugin is complete
*
* @memberof BOOMR.plugins.SPA
*/
is_complete: function(vars) {
// allow error and early beacons to go through even if we're not complete
return !waitingOnHardMissedComplete ||
(vars && (vars["http.initiator"] === "error" || typeof vars.early !== "undefined"));
},
/**
* Called to initialize the plugin via BOOMR.init()
*
* @param {object} [config] Configuration
* @param {boolean} [config.useSoftNavStart] Use the Soft Navigation Heuristics' Start Time for SPA Soft Navigations
*
* @memberof BOOMR.plugins.SPA
*/
init: function(config) {
BOOMR.utils.pluginConfig(impl, config, "SPA",
["useSoftNavStart"]);
if (config && config.instrument_xhr) {
autoXhrEnabled = config.instrument_xhr;
// if AutoXHR is enabled, and we've already had
// a route change, make sure to turn AutoXHR back on
if (initialRouteChangeStarted && autoXhrEnabled) {
BOOMR.plugins.AutoXHR.enableAutoXhr();
}
}
if (initialized) {
return;
}
BOOMR.subscribe("page_ready", impl.pageReady, null, impl);
// Soft Navigation Heuristics
if (typeof BOOMR.window.PerformanceObserver === "function" &&
typeof BOOMR.window.SoftNavigationEntry === "function") {
// add an observer
impl.observer = new BOOMR.window.PerformanceObserver(impl.onSoftNavObserver);
impl.observer.observe({ type: "soft-navigation", buffered: true });
// add SPA Soft Heuristic parameters to the beacon
BOOMR.subscribe("before_beacon", impl.onSoftNavObserverBeforeBeacon, null, impl);
}
initialized = true;
},
/**
* Registers a framework with the SPA plugin
*
* @param {string} pluginName Plugin name
*
* @memberof BOOMR.plugins.SPA
*/
register: function(pluginName) {
supported.push(pluginName);
},
/**
* Gets a list of supported SPA frameworks
*
* @returns {string[]} List of supported frameworks
*
* @memberof BOOMR.plugins.SPA
*/
supported_frameworks: function() {
return supported;
},
/**
* Fired when onload happens (or immediately if onload has already fired)
* to monitor for additional resources for a SPA Hard navigation
*
* @memberof BOOMR.plugins.SPA
*/
onLoadSpaHardMissed: function() {
if (initialRouteChangeStarted) {
// we were told the History event was missed, but it happened anyways
// before onload
return;
}
// We missed the initial route change (we loaded too slowly), so we're too
// late to monitor for new DOM elements. Don't hold the initial page load beacon.
initialRouteChangeCompleted = true;
if (autoXhrEnabled) {
// re-enable AutoXHR if it's enabled
BOOMR.plugins.AutoXHR.enableAutoXhr();
}
// ensure the beacon is held until this SPA hard beacon is ready
waitingOnHardMissedComplete = true;
if (!disableHardNav) {
// Trigger a route change
BOOMR.plugins.SPA.route_change(impl.spaHardMissedOnComplete);
}
else {
waitingOnHardMissedComplete = false;
}
},
/**
* Callback to let the SPA plugin know whether or not to monitor the current
* SPA soft route.
*
* Any time a route is changed, if set, this callback will be executed
* with the current framework's route data.
*
* If the callback returns `false`, the route will not be monitored.
*
* @callback spaRouteFilter
* @param {object} data Route data
*
* @returns {boolean} `true` to monitor the current route
* @memberof BOOMR.plugins.SPA
*/
/**
* Callback to let the SPA plugin know whether or not the end of monitoring
* of the current SPA soft route should be delayed until {@link BOOMR.plugins.SPA.wait_complete}
* is called.
*
* If the callback returns `false`, the route will be monitored as normal.
*
* @callback spaRouteChangeWaitFilter
* @param {object} data Route data
*
* @returns {boolean} `true` to wait until {@link BOOMR.plugins.SPA.wait_complete} is called.
* @memberof BOOMR.plugins.SPA
*/
/**
* Called by a framework when it has hooked into the target SPA
*
* @param {boolean} hadRouteChange True if a route change has already fired
* @param {object} [options] Additional options
* @param {BOOMR.plugins.SPA.spaRouteFilter} [options.routeFilter] Route filter
* @param {BOOMR.plugins.SPA.spaRouteChangeWaitFilter} [options.routeChangeWaitFilter] Route change wait filter
* @param {boolean} [options.routeChangeWaitFilterHardNavs] Whether to apply wait filter on hard navs
* @param {boolean} [options.disableHardNav] Disable sending SPA hard beacons
*
* @returns {@link BOOMR.plugins.SPA} The SPA plugin for chaining
* @memberof BOOMR.plugins.SPA
*/
hook: function(hadRouteChange, options) {
options = options || {};
debugLog("Hooked");
// allow to set options each call in case they change
if (typeof options.routeFilter === "function") {
routeFilter = options.routeFilter;
}
if (typeof options.routeChangeWaitFilter === "function") {
routeChangeWaitFilter = options.routeChangeWaitFilter;
}
if (typeof options.routeChangeWaitFilterHardNavs === "boolean") {
routeChangeWaitFilterHardNavs = options.routeChangeWaitFilterHardNavs;
}
if (options.disableHardNav) {
disableHardNav = options.disableHardNav;
}
if (hooked) {
return this;
}
if (hadRouteChange) {
// kick off onLoadSpaHardMissed once onload has fired, or immediately
// if onload has already fired
BOOMR.attach_page_ready(this.onLoadSpaHardMissed);
}
hooked = true;
return this;
},
/**
* Called by a framework when a route change has started. The SPA plugin will
* begin monitoring downloadable resources to measure the SPA soft navigation.
*
* @param {function} onComplete Called on completion
* @param {object[]} routeFilterArgs Route Filter arguments array
*
* @memberof BOOMR.plugins.SPA
*/
route_change: function(onComplete, routeFilterArgs) {
var now = BOOMR.now();
debugLog("Route Change");
var firedEvent = false;
var initiator = firstSpaNav && !disableHardNav ? "spa_hard" : "spa";
if (latestResource && latestResource.wait) {
debugLog("Route change wait filter not complete; not tracking this route");
return;
}
// if we have a routeFilter, see if we want to track this SPA soft route
if (initiator === "spa" && routeFilter) {
try {
if (!routeFilter.apply(null, routeFilterArgs)) {
debugLog("Route filter returned false; not tracking this route");
return;
}
else {
debugLog("Route filter returned true; tracking this route");
}
}
catch (e) {
BOOMR.addError(e, "SPA.route_change.routeFilter");
}
}
// note we've had at least one route change
initialRouteChangeStarted = true;
// If this was the first request, use navStart as the begin timestamp. Otherwise, use
// "now" as the begin timestamp.
var navigationStart = (BOOMR.plugins.RT && BOOMR.plugins.RT.navigationStart());
var requestStart = (firstSpaNav && !initialRouteChangeCompleted) ? navigationStart : now;
if (firstSpaNav && waitingOnHardMissedComplete) {
requestStart = navigationStart;
}
// use the document.URL even though it may be the URL of the previous nav. We will updated
// it in AutoXHR sendEvent
var url = BOOMR.window.document.URL;
// construct the resource we'll be waiting for
var resource = {
timing: {
requestStart: requestStart
},
initiator: initiator,
url: url
};
// if route_change was called, we're not the initial SPA nav anymore
firstSpaNav = false;
// a new route has started, so the initial one must be completed
initialRouteChangeCompleted = true;
// if we haven't completed our initial SPA navigation yet (this is a hard nav), wait
// for all of the resources to be downloaded
resource.onComplete = function(onCompleteResource) {
if (!firedEvent) {
firedEvent = true;
// For debugging differences with Soft Nav Heuristics
/* BEGIN_DEBUG */
if (impl.useSoftNavStart && lastSoftNavStart) {
this.timing.requestStart = Math.floor(window.performance.timing.navigationStart + lastSoftNavStart);
BOOMR.debug("Soft Nav @ " + lastSoftNavStart +
" now @ " + now +
" diff @ " + (now - lastSoftNavStart),
"spa");
}
/* END_DEBUG */
// fire a SPA navigation completed event so that other plugins can act on it
BOOMR.fireEvent("spa_navigation", [resource.timing]);
}
if (typeof onComplete === "function") {
onComplete(onCompleteResource);
}
};
// if we have a routeChangeWaitFilter, make sure AutoXHR waits on the custom event
// for this SPA soft route
if ((initiator === "spa" || routeChangeWaitFilterHardNavs) && routeChangeWaitFilter) {
debugLog("Running route change wait filter");
try {
if (routeChangeWaitFilter.apply(null, arguments)) {
debugLog("Route filter returned true; waiting for complete call");
resource.wait = true;
latestResource = resource;
}
else {
debugLog("Route wait filter returned false; not waiting for complete call");
}
}
catch (e) {
BOOMR.addError(e, "SPA.route_change.routeChangeWaitFilter");
}
}
// start listening for changes
resource.index = BOOMR.plugins.AutoXHR.getMutationHandler().addEvent(resource);
// Fire spa_init event listeners
BOOMR.fireEvent("spa_init", [
BOOMR.plugins.SPA.current_spa_nav(),
url,
resource.timing]);
// re-enable AutoXHR if it's enabled
if (autoXhrEnabled) {
BOOMR.plugins.AutoXHR.enableAutoXhr();
}
},
/**
* Called by a framework when the location has changed to the specified URL.
* This should be called prior to route_change() to use the specified URL.
*
* @param {string} url URL
*
* @memberof BOOMR.plugins.SPA
*/
last_location: function(url) {
lastLocationChange = url;
},
/**
* Determine the current SPA navigation type (`spa` or `spa_hard`)
*
* @returns {string} SPA beacon type
* @memberof BOOMR.plugins.SPA
*/
current_spa_nav: function() {
return !initialRouteChangeCompleted ? "spa_hard" : "spa";
},
/**
* Called by the SPA consumer if we have a `routeChangeWaitFilter` and are manually
* waiting for a custom event. The spa soft navigation will continue waiting for
* other nodes in progress
*
* @memberof BOOMR.plugins.SPA
*/
wait_complete: function() {
debugLog("Route change wait filter completed");
if (latestResource) {
latestResource.wait = false;
if (latestResource.waitComplete) {
debugLog("Route wait filter complete");
latestResource.waitComplete();
}
latestResource = null;
}
},
/**
* Marks the current navigation as complete and sends a beacon.
* The spa soft navigation will not wait for other nodes in progress
*
* @memberof BOOMR.plugins.SPA
*/
markNavigationComplete: function() {
var i, ev, waiting,
mh = BOOMR.plugins.AutoXHR.getMutationHandler();
// if we're waiting due to a `routeChangeWaitFilter` then mark it complete
if (latestResource && latestResource.wait) {
BOOMR.plugins.SPA.wait_complete();
}
if (mh && mh.pending_events.length > 0) {
for (i = mh.pending_events.length - 1; i >= 0; i--) {
ev = mh.pending_events[i];
if (ev && BOOMR.utils.inArray(ev.type, BOOMR.constants.BEACON_TYPE_SPAS)) {
if (ev.complete) {
// latest spa is not in progress
break;
}
waiting = mh.nodesWaitingFor(i);
debugLog("SPA Navigation being marked complete; nodes waiting for: " + waiting);
// note that the navigation was forced complete
BOOMR.addVar("spa.forced", "1", true);
// add the count of nodes we were waiting for
BOOMR.addVar("spa.waiting", mh.nodesWaitingFor(), true);
// finalize this navigation
mh.completeEvent(i);
return;
}
}
}
debugLog("No SPA navigation in progress to mark as complete");
},
/**
* Determines if a SPA navigation is in progress
*
* @memberof BOOMR.plugins.SPA
*/
isSpaNavInProgress: function() {
var i, ev, waiting,
mh = BOOMR.plugins.AutoXHR.getMutationHandler();
if (mh && mh.pending_events.length > 0) {
for (i = mh.pending_events.length - 1; i >= 0; i--) {
ev = mh.pending_events[i];
if (ev && BOOMR.utils.inArray(ev.type, BOOMR.constants.BEACON_TYPE_SPAS)) {
// true if the latest spa nav is not complete
return !ev.complete;
}
}
}
return false;
},
/**
* Check to see if any of the SPAs are enabled.
* Takes a config object so that it can be called from other plugins' init without
* worrying about plugin order
*
* @param {object} config
*
* @returns {boolean} true if one of the SPA frameworks is enabled
*/
isSinglePageApp: function(config) {
var singlePageApp = false,
frameworks = this.supported_frameworks();
for (var i = 0; i < frameworks.length; i++) {
var spa = frameworks[i];
if (config[spa] && config[spa].enabled) {
singlePageApp = true;
break;
}
}
return singlePageApp;
}
/* BEGIN_DEBUG */
,
test: {
/**
* Gets the list of seen Soft Nav Observer entries
*
* @param {PerformanceEntry[]} list Entries
*/
getSoftNavEntries: function() {
return impl.softNavEntries;
}
}
/* END_DEBUG */
};
BOOMR.plugins.SPA.waitComplete = BOOMR.plugins.SPA.wait_complete;
}());