UNPKG

boomerangjs

Version:

boomerang always comes back, except when it hits something

1,450 lines (1,296 loc) 110 kB
/** * 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; }