boomerangjs
Version:
boomerang always comes back, except when it hits something
1,450 lines (1,296 loc) • 110 kB
JavaScript
/**
* Instrument and measure `XMLHttpRequest` (AJAX) requests.
*
* With this plugin, sites can measure the performance of `XMLHttpRequests`
* (XHRs) and other in-page interactions after the page has been loaded.
*
* This plugin also monitors DOM manipulations following a XHR to filter out
* "background" XHRs.
*
* This plugin provides the backbone for the {@link BOOMR.plugins.SPA} plugin. Single Page App navigations
* use XHR and DOM monitoring to determine when the SPA navigations are complete.
*
* This plugin has a corresponding {@tutorial header-snippets} that helps monitor XHRs prior to Boomerang loading.
*
* For information on how to include this plugin, see the {@tutorial building} tutorial.
*
* ## What is Measured
*
* When `AutoXHR` is enabled, this plugin will monitor several events:
*
* - `XMLHttpRequest` requests
* - `Fetch` API requests
* - Clicks
* - `window.History` changes indirectly through SPA plugins (History, Angular, etc.)
*
* When any of these events occur, `AutoXHR` will start monitoring the page for
* other events, DOM manipulations and other networking activity.
*
* As long as the event isn't determined to be background activity (i.e an XHR
* that didn't change the DOM at all), the event will be measured until all networking
* activity has completed.
*
* This means if your click generated an XHR that triggered an updated view to fetch
* more HTML that added images to the page, the entire event will be measured
* from the click to the last image completing.
*
* ## Usage
*
* To enable AutoXHR, you should set {@link BOOMR.plugins.AutoXHR.init|instrument_xhr} to `true`:
*
* ```
* BOOMR.init({
* instrument_xhr: true
* });
* ```
*
* Once enabled and initialized, the `window.XMLHttpRequest` object will be
* replaced with a "proxy" object that instruments all XHRs.
*
* ## Monitoring XHRs
*
* After `AutoXHR` is enabled, any `XMLHttpRequest.send` will be monitored:
*
* ```
* xhr = new XMLHttpRequest();
* xhr.open("GET", "/api/foo");
* xhr.send(null);
* ```
*
* If this XHR triggers DOM changes, a beacon will eventually be sent.
*
* This beacon will have `http.initiator=xhr` and the beacon parameters will differ
* from a Page Load beacon. See {@link BOOMR.plugins.RT} and
* {@link BOOMR.plugins.NavigationTiming} for details.
*
* ## Combining XHR Beacons
*
* By default `AutoXHR` groups all XHR activity that happens in the same event together.
*
* If you have one XHR that immediately triggers a second XHR, you will get a single
* XHR beacon. The `u` (URL) will be of the first XHR.
*
* If you don't want this behavior, and want to measure *every* XHR on the page, you
* can enable {@link BOOMR.plugins.AutoXHR.init|alwaysSendXhr=true}. When set, every
* distinct XHR will get its own XHR beacon.
*
* ```
* BOOMR.init({
* AutoXHR: {
* alwaysSendXhr: true
* }
* });
* ```
*
* {@link BOOMR.plugins.AutoXHR.init|alwaysSendXhr} can also be a list of strings
* (matching URLs), regular expressions (matching URLs), or a function which returns
* true for URLs to always send XHRs for.
*
* ```
* BOOMR.init({
* AutoXHR: {
* alwaysSendXhr: [
* "http://domain.com/url/to/match",
* /regexmatch/,
* ]
* }
* });
*
* // or
*
* BOOMR.init({
* AutoXHR: {
* alwaysSendXhr: function(url) {
* return url.indexOf("domain.com") !== -1;
* }
* }
* });
* ```
*
*
* ### Compatibility and Browser Support
*
* Currently supported Browsers and platforms that AutoXHR will work on:
*
* - IE 9+ (not in quirks mode)
* - Chrome 38+
* - Firefox 25+
*
* In general we support all browsers that support
* [MutationObserver]{@link https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver}
* and [XMLHttpRequest]{@link https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest}.
*
* We will not use MutationObserver in IE 11 due to several browser bugs.
* See {@link BOOMR.utils.isMutationObserverSupported} for details.
*
* ## Excluding Certain Requests or DOM Elements From Instrumentation
*
* Whenever Boomerang intercepts an `XMLHttpRequest` (or Fetch), it will check if that request
* matches anything in the XHR exclude list. If it does, Boomerang will not
* instrument, time, send a beacon for that request, or include it in the
* {@link BOOMR.plugins.SPA} calculations.
*
* There are two methods of excluding XHRs: Defining the `BOOMR.xhr_excludes` array,
* or using the {@link BOOMR.plugins.AutoXHR.init|excludeFilters} option.
*
* The `BOOMR.xhr_excludes` XHR excludes list is defined by creating a map, and
* adding URL parts that you would like to exclude from instrumentation. You
* can put any of the following in `BOOMR.xhr_excludes`:
*
* 1. A full HREF
* 2. A hostname
* 3. A path
*
* Example:
*
* ```
* BOOMR = window.BOOMR || {};
*
* BOOMR.xhr_excludes = {
* "www.mydomain.com": true,
* "a.anotherdomain.net": true,
* "/api/v1/foobar": true,
* "https://mydomain.com/dashboard/": true
* };
* ```
*
* The {@link BOOMR.plugins.AutoXHR.init|excludeFilters} gives you more control by allowing you
* to specify one or more callbacks that will be run for each XHR/Fetch. If any callback
* returns `true`, the XHR/Fetch will *not* be instrumented.
*
* ```
* BOOMR.init({
* AutoXHR: {
* excludeFilters: [
* function(anchor) {
* return anchor.href.match(/non-trackable/);
* }
* ]
* }
* });
* ```
*
* Finally, the {@link BOOMR.plugins.AutoXHR.init|domExcludeFilters} DOM filters can be used to filter out
* specific DOM elements from being tracked (marked as "uninteresting").
*
* ```
* BOOMR.init({
* AutoXHR: {
* domExcludeFilters: [
* function(elem) {
* return elem.id === "ignore";
* }
* ]
* }
* });
* ```
*
* ## Beacon Parameters
*
* XHR beacons have different parameters in general than Page Load beacons.
*
* - Many of the timestamps will differ, see {@link BOOMR.plugins.RT}
* - All of the `nt_*` parameters are ResourceTiming, see {@link BOOMR.plugins.NavigationTiming}
* - `u`: the URL of the resource that was fetched
* - `pgu`: The URL of the page the resource was fetched on
* - `http.initiator`: `xhr` for both XHR and Fetch requests
*
* ## Interesting Nodes
*
* A `MutationObserver` is used to detect "interesting" nodes. Interesting nodes are
* new IMG/IMAGE/IFRAME/LINK (rel=stylesheet) nodes or existing nodes that are
* changing their source URL.
*
* We consider the following "uninteresting" nodes:
* - Nodes that have either a width or height <= 1px.
* - Nodes with either a width or height of 0px.
* - Nodes that have display:none.
* - Nodes that have visibility:hidden.
* - Nodes with an opacity:0.
* - Nodes that update their source URL to the same value.
* - Nodes that have a blank source URL.
* - Images with a loading="lazy" attribute
* - Nodes that have a source URL starting with `about:`, `javascript:` or `data:`.
* - SCRIPT nodes because there is no consistent way to detect when they have loaded.
* - Existing IFRAME nodes that are changing their source URL because is no consistent
* way to detect when they have loaded.
* - Nodes that have a source URL that matches a AutoXHR exclude filter rule.
* - Nodes that have been manually excluded
*
* ## Algorithm
*
* Here's how the general AutoXHR algorithm works:
*
* - `0.0` SPA hard route change (page navigation)
*
* - Monitor for XHR resource requests and interesting Mutation resource requests
* for 1s or at least until page onload. We extend our 1s timeout after all
* interesting resource requests have completed.
*
* - `0.1` SPA soft route change from a synchronous call (eg. History changes as a
* result of a pushState or replaceState call)
*
* - In this case we get the new URL when the developer calls pushState or
* replaceState.
* - We create a pending event with the start time and the new URL.
* - We do not know if they plan to make an XHR call or use a dynamic script
* node, or do nothing interesting (eg: just make a div visible/invisible).
* - We also do not know if they will do this before or after they've called
* pushState/replaceState.
* - Our best bet is to monitor if either a XHR resource requests or interesting
* Mutation resource requests will happen in the next 1s.
* - When interesting resources are detected, we wait until they complete.
* - We restart our 1s timeout after all interesting resources have completed.
* - If something uninteresting happens, we set the timeout for 1 second if
* it wasn't already started.
* - We'll only do this once per event since we don't want to continuously
* extend the timeout with each uninteresting event.
* - If nothing happens during the additional timeout, we stop watching and fire the
* event. The event end time will be end time of the last tracked resource.
* - If nothing interesting was detected during the first timeout and the URL has not
* changed then we drop the event.
*
* - `0.2` SPA soft route change from an asynchronous call (eg. History changes as a
* result of the user hitting Back/Forward and we get a window.popstate event)
*
* - In this case we get the new URL from location.href when our event listener
* runs.
* - We do not know if this event change will result in some interesting network
* activity or not.
* - We do not know if the developer's event listener has already run before
* ours or if it will run in the future or even if they do have an event listener.
* - Our best bet is the same as 0.1 above.
*
* - `0.3` SPA soft route change from a click that triggers a XHR before the state is
* changed (when {@link BOOMR.plugins.AutoXHR.init|spaStartFromClick} is enabled).
*
* - We store the time of the click
* - If any additional XHRs come next, we track those
* - When a pushState comes after the XHRs (before the timeout), we will "migrate" the
* click to a SPA event
*
* - `1` Click initiated (Only available when no SPA plugins are enabled)
*
* - User clicks on something.
* - We create a pending event with the start time and no URL.
* - We turn on DOM observer, and wait up to 50 milliseconds for activity.
* - If nothing happens during the first timeout, we stop watching and clear the
* event without firing it.
* - Else if something uninteresting happens, we set the timeout for 1s
* if it wasn't already started.
* - We'll only do this once per event since we don't want to continuously
* extend the timeout with each uninteresting event.
* - Else if an interesting node is added, we add load and error listeners
* and turn off the timeout but keep watching.
* - Once all listeners have fired, we start waiting again up to 50ms for activity.
* - If nothing happens during the additional timeout, we stop watching and fire the event.
*
* - `2` XHR/Fetch initiated
*
* - XHR or Fetch request is sent.
* - We create a pending event with the start time and the request URL.
* - We watch for all changes in XHR state (for async requests) and for load (for
* all requests).
* - We turn on DOM observer at XHR Onload or when the Fetch Promise resolves.
* We then wait up to 50 milliseconds for activity.
* - If nothing happens during the first timeout, we stop watching and clear the
* event without firing it.
* - If something uninteresting happens, we set the timeout for 1 second if
* it wasn't already started.
* - We'll only do this once per event since we don't want to continuously
* extend the timeout with each uninteresting event.
* - Else if an interesting node is added, we add load and error listeners
* and turn off the timeout.
* - Once all listeners have fired, we start waiting again up to 50ms for activity.
* - If nothing happens during the additional timeout, we stop watching and fire the event.
*
* What about overlap?
*
* - `3.1` XHR/Fetch initiated while a click event is pending
*
* - If first click watcher has not detected anything interesting or does not
* have a URL, abort it
* - If the click watcher has detected something interesting and has a URL, then
* - Proceed with 2 above.
* - Concurrently, click stops watching for new resources
* - Once all resources click is waiting for have completed then fire the event.
*
* - `3.2` Click initiated while XHR/Fetch event is pending
*
* - Ignore click
*
* - `3.3` Click initiated while a click event is pending
*
* - If first click watcher has not detected anything interesting or does not
* have a URL, abort it.
* - Else proceed with parallel event steps from 3.1 above.
*
* - `3.4` XHR/Fetch initiated while an XHR/Fetch event is pending
*
* - Add the second XHR/Fetch as an interesting resource to be tracked by the
* XHR pending event in progress.
*
* - `3.5` XHR/Fetch initiated while SPA event is pending
*
* - Add the second XHR/Fetch as an interesting resource to be tracked by the
* XHR pending event in progress.
*
* - `3.6` SPA event initiated while an XHR event is pending
* - Proceed with 0 above.
* - Concurrently, XHR event stops watching for new resources. Once all resources
* the XHR event is waiting for have completed, fire the event.
*
* - `3.7` SPA event initiated while a SPA event is pending
* - If the pending SPA event had detected something interesting then send an aborted
* SPA beacon. If not, drop the pending event.
* - Proceed with 0 above.
*
* @class BOOMR.plugins.AutoXHR
*/
(function() {
var w, d, handler, a, impl,
readyStateMap = ["uninitialized", "open", "responseStart", "domInteractive", "responseEnd"];
/**
* Single Page Applications get an additional timeout for all XHR Requests to settle in.
* This is used after collecting resources for a SPA routechange.
* Default is 1000ms, overridable with spaIdleTimeout
* @type {number}
* @constant
* @default
*/
var SPA_TIMEOUT = 1000;
/**
* @constant
* @desc
* For early beacons.
* Single Page Applications get an additional timeout after the resource requests in
* flight reaches 0 for the first time.
* We want to allow some time for the SPA to fill in any custom dimensions that we
* capture on the early beacon.
* @type {number}
* @default
*/
var SPA_EARLY_TIMEOUT = 100;
/**
* Clicks and XHR events get 50ms for an interesting thing to happen before
* being cancelled.
* Default is 50ms, overridable with xhrIdleTimeout
* @type {number}
* @constant
* @default
*/
var CLICK_XHR_TIMEOUT = 50;
/**
* Fetch events that don't read the body of the response get an extra wait time before
* we look for it's corresponding ResourceTiming entry.
* Default is 200ms, overridable with fetchBodyUsedWait
* @type {number}
* @constant
* @default
*/
var FETCH_BODY_USED_WAIT_DEFAULT = 200;
/**
* If we get a Mutation event that doesn't have any interesting nodes after
* a Click or XHR event started, wait up to 1,000ms for an interesting one
* to happen before cancelling the event.
* @type {number}
* @constant
* @default
*/
var UNINTERESTING_MUTATION_TIMEOUT = 1000;
/**
* How long to wait if we're not ready to send a beacon to try again.
* @constant
* @type {number}
* @default
*/
var READY_TO_SEND_WAIT = 500;
/**
* Timeout event fired for XMLHttpRequest resource
* @constant
* @type {number}
* @default
*/
var XHR_STATUS_TIMEOUT = -1001;
/**
* XMLHttpRequest was aborted
* @constant
* @type {number}
* @default
*/
var XHR_STATUS_ABORT = -999;
/**
* An error occured fetching XMLHttpRequest/Fetch resource
* @constant
* @type {number}
* @default
*/
var XHR_STATUS_ERROR = -998;
/**
* An exception occured as we tried to request resource
* @constant
* @type {number}
* @default
*/
var XHR_STATUS_OPEN_EXCEPTION = -997;
/**
* Default resources to count as Back-End during a SPA nav
* @constant
* @type {string[]}
*/
var SPA_RESOURCES_BACK_END = ["xmlhttprequest", "script", "fetch"];
BOOMR = window.BOOMR || {};
BOOMR.plugins = BOOMR.plugins || {};
if (BOOMR.plugins.AutoXHR) {
return;
}
w = BOOMR.window;
// If this browser cannot support XHR, we'll just skip this plugin which will
// save us some execution time.
// XHR not supported or XHR so old that it doesn't support addEventListener
// (IE 6, 7, 8, as well as newer running in quirks mode.)
if (!w || !w.XMLHttpRequest || !(new w.XMLHttpRequest()).addEventListener) {
// Nothing to instrument
return;
}
/* BEGIN_DEBUG */
function debugLog(msg) {
BOOMR.debug(msg, "AutoXHR");
}
/* END_DEBUG */
/**
* Tries to resolve `href` links from relative URLs.
*
* This implementation takes into account a bug in the way IE handles relative
* paths on anchors and resolves this by assigning `a.href` to itself which
* triggers the URL resolution in IE and will fix missing leading slashes if
* necessary.
*
* @param {string} anchor The anchor object to resolve
*
* @returns {string} The unrelativized URL href
* @memberof BOOMR.plugins.AutoXHR
*/
function getPathName(anchor) {
if (!anchor) {
return null;
}
/*
correct relativism in IE
anchor.href = "./path/file";
anchor.pathname == "./path/file"; //should be "/path/file"
*/
anchor.href = anchor.href;
/*
correct missing leading slash in IE
anchor.href = "path/file";
anchor.pathname === "path/file"; //should be "/path/file"
*/
var pathName = anchor.pathname;
if (pathName.charAt(0) !== "/") {
pathName = "/" + pathName;
}
return pathName;
}
/**
* Based on the contents of BOOMR.xhr_excludes check if the URL that we instrumented as XHR request
* matches any of the URLs we are supposed to not send a beacon about.
*
* @param {HTMLAnchorElement} anchor HTML anchor element with URL of the element
* checked against `BOOMR.xhr_excludes`
*
* @returns {boolean} `true` if intended to be excluded, `false` if it is not in the list of excludables
* @memberof BOOMR.plugins.AutoXHR
*/
function shouldExcludeXhr(anchor) {
var urlIdx;
if (anchor.href) {
if (anchor.href.match(/^(about:|javascript:|data:)/i)) {
return true;
}
// don't track our own beacons (allow for protocol-relative URLs)
if (typeof BOOMR.getBeaconURL === "function" && BOOMR.getBeaconURL()) {
urlIdx = anchor.href.indexOf(BOOMR.getBeaconURL());
if (urlIdx === 0 || urlIdx === 5 || urlIdx === 6) {
return true;
}
}
}
return BOOMR.xhr_excludes.hasOwnProperty(anchor.href) ||
BOOMR.xhr_excludes.hasOwnProperty(anchor.hostname) ||
BOOMR.xhr_excludes.hasOwnProperty(getPathName(anchor));
}
/**
* Handles the MutationObserver for {@link BOOMR.plugins.AutoXHR}.
*
* @class MutationHandler
*/
function MutationHandler() {
this.watch = 0;
this.timer = null;
this.timerEarlyBeacon = null;
this.pending_events = [];
this.lastSpaLocation = null;
}
/**
* Disable internal MutationObserver instance. Use this when uninstrumenting the site we're on.
*
* @method
* @memberof MutationHandler
* @static
*/
MutationHandler.stop = function() {
MutationHandler.pause();
MutationHandler.observer = null;
};
/**
* Pauses the MutationObserver. Call [resume]{@link handler#resume} to start it back up.
*
* @method
* @memberof MutationHandler
* @static
*/
MutationHandler.pause = function() {
if (MutationHandler.observer &&
MutationHandler.observer.observer &&
!MutationHandler.isPaused) {
MutationHandler.isPaused = true;
MutationHandler.observer.observer.disconnect();
}
};
/**
* Resumes the MutationObserver after a [pause]{@link handler#pause}.
*
* @method
* @memberof MutationHandler
* @static
*/
MutationHandler.resume = function() {
if (MutationHandler.observer &&
MutationHandler.observer.observer &&
MutationHandler.isPaused) {
MutationHandler.isPaused = false;
MutationHandler.observer.observer.observe(d, MutationHandler.observer.config);
}
};
/**
* Initiate {@link MutationHandler.observer} on the
* [outer parent document]{@link BOOMR.window.document}.
*
* Uses [addObserver]{@link BOOMR.utils.addObserver} to instrument.
*
* [Our internal handler]{@link handler#mutation_cb} will be called if
* something happens.
*
* @method
* @memberof MutationHandler
* @static
*/
MutationHandler.start = function() {
if (MutationHandler.observer) {
// don't start twice
return;
}
var config = {
childList: true,
attributes: true,
subtree: true,
attributeFilter: ["src", "href"]
};
/* eslint-disable no-inline-comments */
// Add a perpetual observer
MutationHandler.observer = BOOMR.utils.addObserver(
d,
config,
null, // no timeout
handler.mutation_cb, // will always return true
null, // no callback data
handler
);
/* eslint-enable no-inline-comments */
if (MutationHandler.observer) {
MutationHandler.observer.config = config;
BOOMR.subscribe("page_unload", MutationHandler.stop, null, MutationHandler);
}
};
/**
* If an event has triggered a resource to be fetched we add it to the list of pending events
* here and wait for it to eventually resolve.
*
* @param {object} resource - [Resource]{@link AutoXHR#Resource} object we are waiting for
*
* @returns {?index} If we are already waiting for an event of this type null
* otherwise index in the [queue]{@link MutationHandler#pending_event}.
* @method
* @memberof MutationHandler
*/
MutationHandler.prototype.addEvent = function(resource) {
/* eslint-disable no-inline-comments */
var ev = {
type: resource.initiator,
resource: resource,
nodes_to_wait: 0, // MO resources + xhrs currently outstanding + wait filter (max: 1)
total_nodes: 0, // total MO resources + xhrs + wait filter (max: 1)
resources: [], // resources reported by MO handler (no xhrs)
xhr_resources: [], // resources reported by xhr monitoring (for debugging only)
complete: false,
aborted: false, // this event was aborted
firedEarlyBeacon: false
},
i,
last_ev,
last_ev_index,
index = this.pending_events.length,
self = this;
/* eslint-enable no-inline-comments */
for (i = index - 1; i >= 0; i--) {
if (this.pending_events[i] && !this.pending_events[i].complete) {
last_ev = this.pending_events[i];
last_ev_index = i;
break;
}
}
if (last_ev) {
if (last_ev.type === "click" &&
impl.singlePageApp &&
impl.spaStartFromClick &&
ev.type === "xhr") {
// spaStartFromClick active, we had a click, and this is an XHR
// mark that this XHR came from a click event
ev.resource.fromClick = true;
// transition XHR start time from the click
ev.resource.timing.click = last_ev.resource.timing.requestStart;
ev.resource.timing.requestStart = last_ev.resource.timing.requestStart;
ev.interesting = last_ev.interesting || 0;
ev.total_nodes += last_ev.total_nodes;
ev.resources = last_ev.resources.concat(ev.resources);
ev.xhr_resources = last_ev.xhr_resources.concat(ev.xhr_resources);
// stop the click event
this.pending_events[last_ev_index] = undefined;
this.watch--;
}
else if ((last_ev.type === "click" || last_ev.resource.fromClick) &&
impl.singlePageApp &&
impl.spaStartFromClick &&
ev.type === "spa") {
// spaStartFromClick active, we have a click (or XHR from click) active,
// and this is a SPA
// transition all XHR times
ev.resource.timing = last_ev.resource.timing;
ev.interesting = last_ev.interesting || 0;
ev.total_nodes += last_ev.total_nodes;
ev.resources = last_ev.resources.concat(ev.resources);
// add previous XHR URL
if (last_ev.resource.url) {
ev.xhr_resources.push(last_ev.resource.url);
}
// add any other XHRs tracked in the previous
ev.xhr_resources = ev.xhr_resources.concat(last_ev.xhr_resources);
// stop the previous event
this.pending_events[last_ev_index] = undefined;
this.watch--;
}
else if (last_ev.type === "click") {
// 3.1 & 3.3
if (last_ev.nodes_to_wait === 0 || !last_ev.resource.url) {
this.pending_events[last_ev_index] = undefined;
this.watch--;
// continue with new event
}
// last_ev will no longer receive watches as ev will receive them
// last_ev will wait for all interesting nodes and then send event
}
else if (last_ev.type === "xhr") {
// 3.2
if (ev.type === "click") {
return null;
}
// 3.4
// add this resource to the current event
else if (ev.type === "xhr") {
handler.add_event_resource(resource);
return null;
}
}
else if (BOOMR.utils.inArray(last_ev.type, BOOMR.constants.BEACON_TYPE_SPAS)) {
// add this resource to the current event
if (ev.type === "xhr") {
handler.add_event_resource(resource);
return null;
}
// if we have a pending SPA event, send an aborted load beacon before
// adding the new SPA event
if (BOOMR.utils.inArray(ev.type, BOOMR.constants.BEACON_TYPE_SPAS)) {
debugLog("Aborting previous SPA navigation");
// mark the end of this navigation as now
last_ev.resource.timing.loadEventEnd = BOOMR.now();
last_ev.aborted = true;
// send the previous SPA
this.sendEvent(last_ev_index);
}
}
}
if (ev.type === "click") {
// if we don't have a MutationObserver then we just abort.
// If an XHR starts later then it will be tracked as its own new event
if (!MutationHandler.observer) {
return null;
}
// give Click events 50ms to see if they resulted
// in DOM mutations (and thus it is an 'interesting event').
this.setTimeout(impl.xhrIdleTimeout, index);
}
else if (ev.type === "xhr") {
// XHR events will not set a timeout yet.
// The XHR's load finished callback will start the timer.
// Increase node count since we are waiting for the XHR that started this event
ev.nodes_to_wait++;
ev.total_nodes++;
// we won't track mutations yet, we'll monitor only when at least one of the
// tracked xhr nodes has had a response
ev.ignoreMO = true;
}
else if (BOOMR.utils.inArray(ev.type, BOOMR.constants.BEACON_TYPE_SPAS)) {
// try to start the MO, in case we haven't had the chance to yet
MutationHandler.start();
if (!resource.wait) {
// give SPAs a bit more time to do something since we know this was
// an interesting event.
this.setTimeout(impl.spaIdleTimeout, index);
}
else {
// a wait filter isn't a node, but we'll use the same logic.
// Increase node count since we are waiting for the waitComplete call.
ev.nodes_to_wait++;
ev.total_nodes++;
// waitComplete() should be called once the held beacon is complete.
// The caller is responsible for clearing the .wait flag
resource.waitComplete = function() {
self.load_finished(index);
resource.waitComplete = undefined;
};
}
}
this.watch++;
this.pending_events.push(ev);
resource.index = index;
return index;
};
/**
* If called with an event in the [pending events list]{@link MutationHandler#pending_events}
* trigger a beacon for this event.
*
* When the beacon is sent for this event is depending on either having a crumb, in which case this
* beacon will be sent immediately. If that is not the case we wait 5 seconds and attempt to send the
* event again.
*
* @param {number} index Index in event list to send
*
* @returns {undefined} Returns early if the event already completed
* @method
* @memberof MutationHandler
*/
MutationHandler.prototype.sendEvent = function(index) {
var ev = this.pending_events[index],
self = this,
now = BOOMR.now();
if (!ev || ev.complete) {
return;
}
this.clearTimeout(index);
if (BOOMR.readyToSend()) {
ev.complete = true;
this.watch--;
// for SPA events, the resource's URL may be set to the previous navigation's URL.
// reset it to the current document URL
if (BOOMR.utils.inArray(ev.type, BOOMR.constants.BEACON_TYPE_SPAS)) {
ev.resource.url = d.URL;
}
// if this was a SPA soft nav with no URL change and did not trigger additional resources
// then we will not send a beacon
if (ev.type === "spa" && ev.total_nodes === 0 && ev.resource.url === self.lastSpaLocation) {
debugLog("SPA beacon cancelled, no URL change or resources triggered");
BOOMR.fireEvent("spa_cancel");
this.pending_events[index] = undefined;
return;
}
// if click event did not trigger additional resources or doesn't have
// a url then do not send a beacon
if (ev.type === "click" && (ev.total_nodes === 0 || !ev.resource.url)) {
debugLog("Click beacon cancelled, no resources triggered or no resource URL");
this.pending_events[index] = undefined;
return;
}
// when in spaStartFromClick mode, if we're tracking clicks, transition them to an 'xhr' event
if (ev.type === "click" && impl.singlePageApp && impl.spaStartFromClick) {
ev.type = ev.resource.initiator = "xhr";
}
// if this was a XHR that did not trigger additional resources then we will not send a beacon,
// note that XHRs start out with total_nodes=1 to account for itself
if (impl.xhrRequireChanges &&
ev.type === "xhr" &&
ev.total_nodes === 1 &&
(typeof ev.interesting === "undefined" || ev.interesting === 0) &&
!matchesAlwaysSendXhr(ev.resource.url, impl.alwaysSendXhr)) {
debugLog("XHR beacon cancelled, no resources triggered");
this.pending_events[index] = undefined;
return;
}
if (BOOMR.utils.inArray(ev.type, BOOMR.constants.BEACON_TYPE_SPAS)) {
// save the last SPA location
self.lastSpaLocation = ev.resource.url;
if (!ev.forced) {
// if this was a SPA soft nav that triggered no additional resources, call it
// a 1ms duration
if (ev.type === "spa" && ev.total_nodes === 0) {
ev.resource.timing.loadEventEnd = ev.resource.timing.requestStart + 1;
}
// if the event wasn't forced then the SPA hard nav should have the page's
// onload end as its minimum load end time
if (ev.type === "spa_hard") {
var p = BOOMR.getPerformance();
if (p && p.timing && p.timing.loadEventEnd && ev.resource.timing.loadEventEnd &&
ev.resource.timing.loadEventEnd < p.timing.loadEventEnd) {
ev.resource.timing.loadEventEnd = p.timing.loadEventEnd;
}
}
}
}
this.sendResource(ev.resource, index);
}
else {
// No crumb, so try again after 500ms seconds
setTimeout(function() {
self.sendEvent(index);
}, READY_TO_SEND_WAIT);
}
};
/**
* Creates and triggers sending a beacon for a Resource that has finished loading.
*
* @param {Resource} resource The Resource to send a beacon on
* @param {number} eventIndex index of the event in the pending_events array
*
* @method
* @memberof MutationHandler
*/
MutationHandler.prototype.sendResource = function(resource, eventIndex) {
var self = this,
ev = self.pending_events[eventIndex];
// Use 'requestStart' as the startTime of the resource, if given
var startTime = resource.timing ? resource.timing.requestStart : undefined;
/**
* Called once the resource can be sent
* @param {boolean} [markEnd] Sets loadEventEnd once the function is run
* @param {number} [endTimestamp] End timestamp
*/
var sendResponseEnd = function(markEnd, endTimestamp) {
if (markEnd) {
resource.timing.loadEventEnd = endTimestamp || BOOMR.now();
}
// send any queued beacons first
BOOMR.real_sendBeacon();
// If the resource has an onComplete event, trigger it.
if (resource.onComplete) {
resource.onComplete(resource);
}
// Add ResourceTiming data to the beacon, starting at when 'requestStart'
// was for this resource.
if (BOOMR.plugins.ResourceTiming &&
BOOMR.plugins.ResourceTiming.is_enabled() &&
resource.timing &&
resource.timing.requestStart) {
// Save resourceTiming data onto beacon as it may not go out right away
resource.restiming = BOOMR.plugins.ResourceTiming.getCompressedResourceTiming(
resource.timing.requestStart,
resource.timing.loadEventEnd
);
}
// For SPAs, calculate Back-End and Front-End timings
if (BOOMR.utils.inArray(resource.initiator, BOOMR.constants.BEACON_TYPE_SPAS)) {
self.calculateSpaTimings(resource);
// If the SPA load was aborted, set the rt.quit and rt.abld flags
if (typeof eventIndex === "number" && self.pending_events[eventIndex].aborted) {
// Save the URL otherwise it might change before we have a chance to put it on the beacon
BOOMR.addVar("pgu", d.URL, true);
BOOMR.addVar("rt.quit", "", true);
BOOMR.addVar("rt.abld", "", true);
}
}
BOOMR.responseEnd(resource, startTime, resource);
if (typeof eventIndex === "number") {
self.pending_events[eventIndex] = undefined;
}
};
// if this is a SPA Hard navigation, make sure it doesn't fire until onload
if (resource.initiator === "spa_hard") {
// don't wait for onload if this was an aborted SPA navigation
if ((!ev || !ev.aborted) && !BOOMR.hasBrowserOnloadFired()) {
BOOMR.utils.addListener(w, "load", function() {
var loadTimestamp = BOOMR.now();
// run after the 'load' event handlers so loadEventEnd is captured
BOOMR.setImmediate(function() {
sendResponseEnd(true, loadTimestamp);
});
});
return;
}
}
sendResponseEnd(false);
};
/**
* Calculates SPA Back-End and Front-End timings for Hard and Soft
* SPA navigations.
*
* @param {object} resource Resouce to calculate for
*
* @method
* @memberof MutationHandler
*/
MutationHandler.prototype.calculateSpaTimings = function(resource) {
var p = BOOMR.getPerformance();
if (!p || !p.timing) {
return;
}
//
// Hard Navigation:
// Use same timers as a traditional navigation, where the root HTML's
// timestamps are used for Back-End calculation.
//
if (resource.initiator === "spa_hard") {
// ensure RT picks up the correct timestamps
resource.timing.responseEnd = p.timing.responseStart;
// use navigationStart instead of fetchStart to ensure Back-End time
// includes any redirects
resource.timing.fetchStart = p.timing.navigationStart;
}
else {
//
// Soft Navigation:
// We need to overwrite two timers: Back-End (t_resp) and Front-End (t_page).
//
// For Single Page Apps, we're defining these as:
// Back-End: Any timeslice where a XHR or JavaScript was outstanding
// Front-End: Total Time - Back-End
//
if (!BOOMR.plugins.ResourceTiming ||
!BOOMR.plugins.ResourceTiming.is_supported()) {
return;
}
// first, gather all Resources that were outstanding during this SPA nav
var resources = BOOMR.plugins.ResourceTiming.getFilteredResourceTiming(
resource.timing.requestStart,
resource.timing.loadEventEnd,
impl.spaBackEndResources).entries;
// determine the total time based on the SPA logic
var totalTime = Math.round(resource.timing.loadEventEnd - resource.timing.requestStart);
if (!resources || !resources.length) {
// If ResourceTiming is supported, but there were no entries,
// this was all Front-End time
resource.timers = {
t_resp: 0,
t_page: totalTime,
t_done: totalTime
};
return;
}
// we currently can't reliably tell when a SCRIPT has loaded
// set an upper bound on responseStart/responseEnd for the resources to the SPA's loadEventEnd
var maxResponseEnd = resource.timing.loadEventEnd - p.timing.navigationStart;
for (var i = 0; i < resources.length; i++) {
if (resources[i].responseStart > maxResponseEnd) {
resources[i].responseStart = maxResponseEnd;
resources[i].responseEnd = maxResponseEnd;
}
else if (resources[i].responseEnd > maxResponseEnd) {
resources[i].responseEnd = maxResponseEnd;
}
}
// calculate the Back-End time based on any time those resources were active
var backEndTime = Math.round(BOOMR.plugins.ResourceTiming.calculateResourceTimingUnion(resources));
// front-end time is anything left over
var frontEndTime = totalTime - backEndTime;
if (backEndTime < 0 || totalTime < 0 || frontEndTime < 0) {
// some sort of error, don't put on the beacon
BOOMR.addError("Incorrect SPA time calculation");
return;
}
// set timers on the resource so RT knows to use them
resource.timers = {
t_resp: backEndTime,
t_page: frontEndTime,
t_done: totalTime
};
}
};
/**
* Will create a new timer waiting for `timeout` milliseconds to wait until a
* resources load time has ended or should have ended. If the timeout expires
* the Resource at `index` will be marked as timedout and result in an error Resource marked with
* [XHR_STATUS_TIMEOUT]{@link AutoXHR#XHR_STATUS_TIMEOUT} as status information.
*
* @param {number} timeout - time ot wait for the resource to be loaded
* @param {number} index - Index of the {@link Resource} in our {@link MutationHandler#pending_events}
*
* @method
* @memberof MutationHandler
*/
MutationHandler.prototype.setTimeout = function(timeout, index) {
var self = this;
// we don't need to check if this is the latest pending event, the check is
// already done by all callers of this function
if (!timeout) {
return;
}
this.clearTimeout(index);
this.timer = setTimeout(function() {
self.timedout(index);
}, timeout);
};
/**
* Sends a Beacon for the [Resource]{@link AutoXHR#Resource} at `index` with the status
* [XHR_STATUS_TIMEOUT]{@link AutoXHR#XHR_STATUS_TIMEOUT} code, If there are multiple resources attached to the
* `pending_events` array at `index`.
*
* @param {number} index - Index of the event in pending_events array
*
* @method
* @memberof MutationHandler
*/
MutationHandler.prototype.timedout = function(index) {
var ev;
this.clearTimeout(index);
ev = this.pending_events[index];
if (ev) {
// SPA page loads
if (BOOMR.utils.inArray(ev.type, BOOMR.constants.BEACON_TYPE_SPAS) && !BOOMR.hasBrowserOnloadFired()) {
// browser onload hasn't fired yet, lets wait because there might be more interesting
// things on the way
this.setTimeout(impl.spaIdleTimeout, index);
return;
}
if (ev.nodes_to_wait === 0) {
// send event if there are no outstanding downloads
this.sendEvent(index);
}
// if there are outstanding downloads left, they will trigger a
// sendEvent for the SPA/XHR pending event once complete
}
};
/**
* If this instance of the {@link MutationHandler} has a `timer` set, clear it
*
* @param {number} index - Index of the event in pending_events array
*
* @memberof MutationHandler
* @method
*/
MutationHandler.prototype.clearTimeout = function(index) {
// only clear timeout if this is the latest pending event.
// If it is not the latest, then allow the timer to timeout. This can
// happen in cases of concurrency, eg. an xhr event is waiting in timeout and
// a spa event is triggered.
if (this.timer && index === (this.pending_events.length - 1)) {
clearTimeout(this.timer);
this.timer = null;
}
};
/**
* Once an asset has been loaded and the resource appeared in the page we
* check if it was part of the interesting events on the page and mark it as finished.
*
* @callback load_cb
* @param {Event} ev - Load event Object
*
* @memberof MutationHandler
*/
MutationHandler.prototype.load_cb = function(ev, resourceNum) {
var target,
index,
now = BOOMR.now();
target = ev.target || ev.srcElement;
if (!target || !target._bmr) {
return;
}
index = target._bmr.idx;
resourceNum = typeof resourceNum !== "undefined" ? resourceNum : (target._bmr.res || 0);
if (target._bmr.end[resourceNum]) {
// If we've already set the end value, don't call load_finished
// again. This might occur on IMGs that are 404s, which fire
// 'error' then 'load' events
return;
}
target._bmr.end[resourceNum] = now;
this.load_finished(index, now);
};
/**
* Clear the flag preventing DOM mutation monitoring
*
* @param {number} index - Index of the event in pending_events array
*
* @memberof MutationHandler
* @method
*/
MutationHandler.prototype.monitorMO = function(index) {
var current_event = this.pending_events[index];
// event aborted
if (!current_event) {
return;
}
delete current_event.ignoreMO;
};
/**
* Decrement the number of [nodes_to_wait]{@link AutoXHR#.PendingEvent} for the
* [PendingEvent Object]{@link AutoXHR#.PendingEvent}.
*
* If the nodes_to_wait is decremented to 0 and the event type was SPA:
*
* When we're finished waiting on the last node,
* the MVC engine (eg AngularJS) might still be doing some processing (eg
* on an XHR) before it adds some additional content (eg IMGs) to the page.
* We should wait a while (1 second) longer to see if this happens. If
* something else is added, we'll continue to wait for that content to
* complete. If nothing else is added, the end event will be the
* timestamp for when this load_finished(), not 1 second from now.
*
* @param {number} index - Index of the event found in the pending_events array
* @param {TimeStamp} loadEventEnd - TimeStamp at which the resource was finished loading
*
* @method
* @memberof MutationHandler
*/
MutationHandler.prototype.load_finished = function(index, loadEventEnd) {
var current_event = this.pending_events[index];
// event aborted
if (!current_event) {
return;
}
current_event.nodes_to_wait--;
if (current_event.nodes_to_wait === 0) {
// mark the end timestamp with what was given to us, or, now
current_event.resource.timing.loadEventEnd = loadEventEnd || BOOMR.now();
// For Single Page Apps, when we're finished waiting on the last node,
// the MVC engine (eg AngularJS) might still be doing some processing (eg
// on an XHR) before it adds some additional content (eg IMGs) to the page.
// We should wait a while (1 second) longer to see if this happens. If
// something else is added, we'll continue to wait for that content to
// complete. If nothing else is added, the end event will be the
// timestamp for when this load_finished(), not 1 second from now.
// We use the same behavior for XHR and click events but with a smaller timeout
if (index === (this.pending_events.length - 1)) {
// if we're the latest pending event then extend the timeout
if (BOOMR.utils.inArray(current_event.type, BOOMR.constants.BEACON_TYPE_SPAS)) {
// if we reached here then at least one resource was fetched.
// Send our SPA early beacon
if (!current_event.firedEarlyBeacon && BOOMR.plugins.Early && BOOMR.plugins.Early.is_supported()) {
if (this.timerEarlyBeacon) {
clearTimeout(this.timerEarlyBeacon);
this.timerEarlyBeacon = null;
}
this.timerEarlyBeacon = setTimeout(function() {
handler.timerEarlyBeacon = null;
// don't send an early beacon now if we are waiting for a new resource that started during our timeout
if (current_event.firedEarlyBeacon || current_event.nodes_to_wait !== 0) {
return;
}
current_event.firedEarlyBeacon = true;
debugLog("Triggering an early beacon");
BOOMR.plugins.Early.sendEarlyBeacon(current_event.resource, current_event.type);
// give the SPA framework a bit of time to load the resource
}, SPA_EARLY_TIMEOUT);
}
this.setTimeout(impl.spaIdleTimeout, index);
}
else {
this.setTimeout(impl.xhrIdleTimeout, index);
}
}
else {
// this should never happen for SPA events, they should always be the latest
// pending event.
this.sendEvent(index);
}
}
};
/**
* Determines if we should wait for resources that would be fetched by the
* specified node.
*
* @param {Element} node DOM node
* @param {number} index Event index
*
* @method
* @memberof MutationHandler
*/
MutationHandler.prototype.wait_for_node = function(node, index) {
var self = this,
current_event,
els,
interesting = false,
i,
l,
url,
exisitingNodeSrcUrlChanged = false,
resourceNum,
domHeight,
domWidth,
listener;
// only images, iframes and links if stylesheet
// nodeName for SVG:IMAGE returns `image` in lowercase
// scripts would be nice to track but their onload event is not reliable
if (node && node.nodeName &&
(node.nodeName.toUpperCase().match(/^(IMG|IFRAME|IMAGE)$/) ||
(node.nodeName.toUpperCase() === "LINK" && node.rel && node.rel.match(/\bstylesheet\b/i)))) {
// if the attribute change affected the src attributes we want to know that
// as that means we need to fetch a new Resource from the server
// We don't look at currentSrc here because that isn't set until after the resource fetch has started,
// which will be after the MO observer completes.
// we put xlink:href before href because node.href works for <SVG:IMAGE> elements,
// but does not return a string
url = node.src ||
(typeof node.getAttribute === "function" && node.getAttribute("xlink:href")) ||
(w.SVGAnimatedString && node.href instanceof w.SVGAnimatedString && node.href.baseVal) ||
node.href;
// we get called from src/href attribute changes but also from nodes being added
// which may or may not have been seen here before.
// Check that if we've seen this node before, that the src/href in this case is
// different which means we need to fetch a new Resource from the server
if (node._bmr && node._bmr.url !== url) {
exisitingNodeSrcUrlChanged = true;
}
if (exisitingNodeSrcUrlChanged) {
if (typeof node._bmr.listener === "function") {
self.load_cb({target: node, type: "changed"});
// remove listeners
node.removeEventListener("load", node._bmr.listener);
node.removeEventListener("error", node._bmr.listener);
delete node._bmr.listener;
}
/* BEGIN_DEBUG */
debugLog("mutation URL changed from " + node._bmr.url +
" to " + url +
" for event idx: " + node._bmr.idx +
" res num:" + node._bmr.res);
/* END_DEBUG */
}
current_event = this.pending_events[index];
if (!current_event) {
return false;
}
// no URL or javascript: or about: or data: URL, so no network activity
if (!url || !url.match || url.match(/^(about:|javascript:|data:)/i)) {
return false;
}
if (node.nodeName === "IMG") {
if (node.naturalWidth && !exisitingNodeSrcUrlChanged) {
// img already loaded
return false;
}
else if (typeof node.getAttribute === "function" && node.getAttribute("src") === "") {
// placeholder IMG
return false;
}
else if (node.loading === "lazy") {
// lazy loading - we can't know when this request will be triggered
return false;
}