boomerangjs
Version:
boomerang always comes back, except when it hits something
1,593 lines (1,353 loc) • 63.3 kB
JavaScript
/**
* Plugin to collect metrics from the W3C [ResourceTiming]{@link http://www.w3.org/TR/resource-timing/}
* API.
*
* For information on how to include this plugin, see the {@tutorial building} tutorial.
*
* ## Beacon Parameters
*
* This plugin adds the following parameters to the beacon for Page Loads:
*
* * `restiming`: Compressed ResourceTiming data
*
* The ResourceTiming plugin adds an object named `restiming` to the beacon data.
*
* `restiming` is an optimized [Trie]{@link http://en.wikipedia.org/wiki/Trie} structure,
* where the keys are the ResourceTiming URLs, and the values correspond to those URLs'
* [PerformanceResourceTiming]{@link http://www.w3.org/TR/resource-timing/#performanceresourcetiming}
* timestamps:
*
* { "[url]": "[data]"}
*
* The Trie structure is used to minimize the data transmitted from the ResourceTimings.
*
* Keys in the Trie are the ResourceTiming URLs. For example, with a root page and three resources:
*
* * http://abc.com/
* * http://abc.com/js/foo.js
* * http://abc.com/css/foo.css
* * http://abc.com/css/foo.png (downloaded twice)
*
* Then the Trie might look like this:
*
* // Example 1
* {
* "http://abc.com/":
* {
* "|": "0,2",
* "js/foo.js": "3a,1",
* "css/": {
* "foo.css": "2b,2",
* "foo.png": "1c,3|1d,a"
* }
* }
* }
*
* If a resource's URL is a prefix of another resource, then it terminates with a
* pipe symbol (`|`). In Example 1, `http://abc.com` (the root page) is a
* prefix of `http://abc.com/js/foo.js`, so it is listed as `http://abc.com|` in
* the Trie.
*
* If there is more than one ResourceTiming entry for a URL, each entry is
* separated by a pipe symbol (`|`) in the `data`. In Example 1 above, `foo.png`
* has been downloaded twice, so it is listed with two separate page loads, `1c,3` and `1d,a`.
*
* The value of each key is a string, which contains the following components:
*
* data = "[initiatorType][timings]"
*
* `initiatorType` is a simple map from the PerformanceResourceTiming
* `initiatorType` (which is a string) to an integer, according to the
* {@link BOOMR.plugins.ResourceTiming.INITAITOR_TYPES} enum.
*
* `timings` is a string of [Base-36]{@link http://en.wikipedia.org/wiki/Base_36}
* encoded timestamps from the PerformanceResourceTiming interface. The values in
* the string are separated by commas:
*
* timings = "[startTime],[responseEnd],[responseStart],[requestStart],[connectEnd]," +
* "[secureConnectionStart],[connectStart],[domainLookupEnd],[domainLookupStart]," +
* "[redirectEnd],[redirectStart]"
*
* `startTime` is a [DOMHighResTimeStamp]{@link http://www.w3.org/TR/hr-time/#domhighrestimestamp}
* from when the resource started (Base 36).
*
* All other timestamps are offsets (rounded to milliseconds) from `startTime`
* (Base 36). For example, `responseEnd` is calculated as:
*
* responseEnd: base36(round(responseEnd - startTime))
*
* If the resulting timestamp is `0`, it is replaced with an empty string (`""`).
*
* All trailing commas are removed from the final string. This compresses the timing
* string from timestamps that are often `0`. For example, here is what a fully-redirected
* resource might look like:
*
* { "http://abc.com/this-resource-was-redirected": "01,1,1,1,1,1,1,1,1,1,1" }
*
* While a resource that was loaded from the cache (and thus only has `startTime`
* and `responseEnd` timestamps) might look like this:
*
* { "http://abc.com/this-resource-was-redirected": "01,1" }
*
* Note that some of the metrics are restricted and will not be provided cross-origin
* unless the Timing-Allow-Origin header permits.
*
* Putting this all together, let's look at `http://abc.com/css/foo.png` in Example 1.
* We find it was downloaded twice `"1c,3|1d,a"`:
*
* * 1c,3:
* * `1`: `initiatorType` = `1` (IMG)
* * `c`: `startTime` = `c` (12ms)
* * `3`: `responseEnd` = `3` (3ms from startTime, or at 15ms)
* * 1d,a:
* * `1`: `initiatorType` = `1` (IMG)
* * `d`: `startTime` = `d` (13ms)
* * `a`: `responseEnd` = `a` (10ms from startTime, or at 23ms)
*
* @see {@link http://www.w3.org/TR/resource-timing/}
* @class BOOMR.plugins.ResourceTiming
*/
(function() {
var impl;
BOOMR = window.BOOMR || {};
BOOMR.plugins = BOOMR.plugins || {};
if (BOOMR.plugins.ResourceTiming) {
return;
}
//
// Constants
//
/**
* @enum {number}
* @memberof BOOMR.plugins.ResourceTiming
*/
var INITIATOR_TYPES = {
/** Unknown type */
"other": 0,
/** IMG element (or IMAGE element inside a SVG for IE, Edge and Firefox) */
"img": 1,
/** LINK element (i.e. CSS) */
"link": 2,
/** SCRIPT element */
"script": 3,
/** Resource referenced in CSS */
"css": 4,
/** XMLHttpRequest */
"xmlhttprequest": 5,
/** The root HTML page itself */
"html": 6,
/** IMAGE element inside a SVG */
"image": 7,
/** [sendBeacon]{@link https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon} */
"beacon": 8,
/** [Fetch API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API} */
"fetch": 9,
/** An IFRAME */
"iframe": "a",
/** IE11 and Edge (some versions) send "subdocument" instead of "iframe" */
"subdocument": "a",
/** BODY element */
"body": "b",
/** INPUT element */
"input": "c",
/** FRAME element */
"frame": "a",
/** OBJECT element */
"object": "d",
/** VIDEO element */
"video": "e",
/** AUDIO element */
"audio": "f",
/** SOURCE element */
"source": "g",
/** TRACK element */
"track": "h",
/** EMBED element */
"embed": "i",
/** EventSource */
"eventsource": "j",
/** The root HTML page itself */
"navigation": 6,
/** Early Hints */
"early-hints": "k",
/** HTML <a> ping Attribute */
"ping": "l",
/** CSS font at-rule */
"font": "m"
};
/**
* These are the only `rel` types that might be reference-able from
* ResourceTiming.
*
* https://html.spec.whatwg.org/multipage/links.html#linkTypes
*
* @enum {number}
* @memberof BOOMR.plugins.ResourceTiming
*/
var REL_TYPES = {
"prefetch": 1,
"preload": 2,
"prerender": 3,
"stylesheet": 4
};
var RT_FIELDS_TIMESTAMPS = [
"startTime",
"redirectStart",
"redirectEnd",
"fetchStart",
"domainLookupStart",
"domainLookupEnd",
"connectStart",
"secureConnectionStart",
"connectEnd",
"requestStart",
"responseStart",
"responseEnd",
"workerStart"
];
// Words that will be broken (by ensuring the optimized trie doesn't contain
// the whole string) in URLs, to ensure NoScript doesn't think this is an XSS attack
var DEFAULT_XSS_BREAK_WORDS = [
/(h)(ref)/gi,
/(s)(rc)/gi,
/(a)(ction)/gi
];
// Delimiter to use to break a XSS word
var XSS_BREAK_DELIM = "\n";
// Maximum number of characters in a URL
var DEFAULT_URL_LIMIT = 500;
// Any ResourceTiming data time that starts with this character is not a time,
// but something else (like dimension data)
var SPECIAL_DATA_PREFIX = "*";
// Dimension data special type
var SPECIAL_DATA_DIMENSION_TYPE = "0";
// Dimension data special type
var SPECIAL_DATA_SIZE_TYPE = "1";
// Script attributes
var SPECIAL_DATA_SCRIPT_ATTR_TYPE = "2";
// The following make up a bitmask
var ASYNC_ATTR = 0x1;
var DEFER_ATTR = 0x2;
// 0 => HEAD, 1 => BODY
var LOCAT_ATTR = 0x4;
// Dimension data special type
var SPECIAL_DATA_SERVERTIMING_TYPE = "3";
// Link attributes
var SPECIAL_DATA_LINK_ATTR_TYPE = "4";
// Namespaced data
var SPECIAL_DATA_NAMESPACED_TYPE = "5";
// Service worker type
var SPECIAL_DATA_SERVICE_WORKER_TYPE = "6";
// Next Hop Protocol
var SPECIAL_DATA_PROTOCOL = "7";
/**
* Converts entries to a Trie (`splitAtPath=true`) or Radix
* Trie (`splitAtPath=false`):
* https://en.wikipedia.org/wiki/Trie
* https://en.wikipedia.org/wiki/Radix_tree
*
* Assumptions:
* 1) All entries have unique keys
* 2) Keys cannot have "|" in their name.
* 3) All key's values are strings
*
* Leaf nodes in the tree are the key's values.
*
* If key A is a prefix to key B, key A will be suffixed with "|".
*
* By default, the Trie is constructed "perfectly" (a Radix Trie) by looking
* at every letter of each URL. If `splitAtPath` is specified, the URL is
* split up into groups based on the path separator. This will create a
* non-optimal Trie but will speed up the Trie construction significantly
* (taking less than a half or third of the time on large data sets).
*
* @param {object} entries Performance entries
* @param {boolean} [splitAtPath] Whether to split at path separator vs every character
*
* @returns {object} A trie
*/
function convertToTrie(entries, splitAtPath) {
var trie = {},
url, urlFixed, i, value, letters, letter, cur, node;
/**
* Builds an array of path components.
*
* @param {number} addSlashUntil Length of path components
*/
function splitUrlPaths(addSlashUntil) {
return function(accumulator, currentValue, currentIndex) {
var parts, j;
// if this component has the XSS_BREAK_DELIM character in it, we need
// to break it up
if (currentValue.indexOf(XSS_BREAK_DELIM) !== -1) {
// break at that character
parts = currentValue.split(XSS_BREAK_DELIM);
// add everything but the last one with special XSS_BREAK_DELIM nodes
for (j = 0; j < parts.length - 1; j++) {
// add this component
accumulator.push(parts[j]);
// add back in the XSS_BREAK_DELIM
accumulator.push(XSS_BREAK_DELIM);
}
// add the last part
currentValue = parts.slice(-1);
}
// add a '/' for everything but the last one
if (typeof addSlashUntil === "number" && currentIndex < addSlashUntil) {
currentValue += "/";
}
return accumulator.concat(currentValue);
};
}
for (url in entries) {
urlFixed = url;
// find any strings to break
for (i = 0; i < impl.xssBreakWords.length; i++) {
// Add a XSS_BREAK_DELIM character after the first letter. optimizeTrie will
// ensure this sequence doesn't get combined.
urlFixed = urlFixed.replace(impl.xssBreakWords[i], "$1" + XSS_BREAK_DELIM + "$2");
}
if (!entries.hasOwnProperty(url)) {
continue;
}
value = entries[url];
if (splitAtPath) {
//
// Split the Trie based on the path (less CPU, less optimial result)
//
letters = urlFixed.split("/");
letters = [
// protocol
letters[0] + "//",
// hostname and optional slash
letters[2] + (letters.length > 3 ? "/" : "")
// all of the path parts
].concat(letters.slice(3).reduce(splitUrlPaths(letters.length - 4), []));
}
else {
//
// Split at every letter (more CPU, perfect result)
//
letters = urlFixed.split("");
}
// start at the top of the Trie
cur = trie;
for (i = 0; i < letters.length; i++) {
letter = letters[i];
node = cur[letter];
if (typeof node === "undefined") {
// nothing exists yet, create either a leaf if this is the end of the word,
// or a branch if there are letters to go
cur = cur[letter] = (i === (letters.length - 1) ? value : {});
}
else if (typeof node === "string") {
// this is a leaf, but we need to go further, so convert it into a branch
cur = cur[letter] = { "|": node };
}
else {
if (i === (letters.length - 1)) {
// this is the end of our key, and we've hit an existing node. Add our timings.
cur[letter]["|"] = value;
}
else {
// continue onwards
cur = cur[letter];
}
}
}
}
return trie;
}
/**
* Optimize the Trie by combining branches with no leaf
*
* @param {object} cur Current Trie branch
* @param {boolean} top Whether or not this is the root node
*
* @returns {object} Optimized Trie
*/
function optimizeTrie(cur, top) {
var num = 0,
node, ret, topNode;
// capture trie keys first as we'll be modifying it
var keys = [];
for (node in cur) {
if (cur.hasOwnProperty(node)) {
keys.push(node);
}
}
for (var i = 0; i < keys.length; i++) {
node = keys[i];
if (typeof cur[node] === "object") {
// optimize children
ret = optimizeTrie(cur[node], false);
if (ret) {
// swap the current leaf with compressed one
delete cur[node];
if (node === XSS_BREAK_DELIM) {
// If this node is a newline, which can't be in a regular URL,
// it's due to the XSS patch. Remove the placeholder character,
// and make sure this node isn't compressed by incrementing
// num to be greater than one.
node = ret.name;
num++;
}
else {
node = node + ret.name;
}
cur[node] = ret.value;
}
}
num++;
}
if (num === 1) {
// compress single leafs
if (top) {
// top node gets special treatment so we're not left with a {node:,value:} at top
topNode = {};
topNode[node] = cur[node];
return topNode;
}
else {
// other nodes we return name and value separately
return { name: node, value: cur[node] };
}
}
else if (top) {
// top node with more than 1 child, return it as-is
return cur;
}
else {
// more than two nodes and not the top, we can't compress any more
return false;
}
}
/**
* Rounds up the timing value
*
* @param {number} time Time
* @returns {number} Rounded up timestamp
*/
function roundUpTiming(time) {
if (typeof time !== "number") {
time = 0;
}
return Math.ceil(time ? time : 0);
}
/**
* Trims the timing, returning an offset from the startTime in ms
*
* @param {number} time Time
* @param {number} startTime Start time
* @returns {number} Number of ms from start time
*/
function trimTiming(time, startTime) {
if (typeof time !== "number") {
time = 0;
}
if (typeof startTime !== "number") {
startTime = 0;
}
// strip from microseconds to milliseconds only
var timeMs = Math.round(time ? time : 0),
startTimeMs = Math.round(startTime ? startTime : 0);
return timeMs === 0 ? 0 : (timeMs - startTimeMs);
}
/**
* Checks if the current execution context can access the specified frame.
*
* Note: In Safari, this will still produce a console error message, even
* though the exception is caught.
* @param {Window} frame The frame to check if access can haz
* @returns {boolean} true if true, false otherwise
*/
function isFrameAccessible(frame) {
var dummy;
try {
// Try to access location.href first to trigger any Cross-Origin
// warnings. There's also a bug in Chrome ~48 that might cause
// the browser to crash if accessing X-O frame.performance.
// https://code.google.com/p/chromium/issues/detail?id=585871
// This variable is not otherwise used.
dummy = frame.location && frame.location.href;
// Try to access frame.document to trigger X-O exceptions with that
dummy = frame.document;
if (("performance" in frame) && frame.performance) {
return true;
}
}
catch (e) {
// empty
}
return false;
}
/**
* Attempts to get the navigationStart time for a frame.
* @returns navigationStart time, or 0 if not accessible
*/
function getNavStartTime(frame) {
var navStart = 0;
if (isFrameAccessible(frame) && frame.performance.timing && frame.performance.timing.navigationStart) {
navStart = frame.performance.timing.navigationStart;
}
return navStart;
}
/**
* Gets all of the performance entries for a frame and its subframes
*
* @param {Frame} frame Frame
* @param {boolean} top This is the top window
* @param {string} offset Offset in timing from root IFRAME
* @param {number} depth Recursion depth
* @param {number[]} [frameDims] position and size of the frame if it is visible as returned by getVisibleEntries
* @returns {PerformanceEntry[]} Performance entries
*/
function findPerformanceEntriesForFrame(frame, isTopWindow, offset, depth, frameDims) {
var entries = [],
i, navEntries, navStart, frameNavStart, frameOffset, subFrames, subFrameDims,
navEntry, t, rtEntry, visibleEntries,
scripts = {},
links = {},
a;
if (typeof isTopWindow === "undefined") {
isTopWindow = true;
}
if (typeof offset === "undefined") {
offset = 0;
}
if (typeof depth === "undefined") {
depth = 0;
}
if (depth > 10) {
return entries;
}
try {
if (!isFrameAccessible(frame)) {
return entries;
}
navStart = getNavStartTime(frame);
// gather visible entries on the page
visibleEntries = getVisibleEntries(frame, frameDims);
a = frame.document.createElement("a");
// get all scripts as an object keyed on script.src
collectResources(a, scripts, "script");
collectResources(a, links, "link");
subFrames = frame.document.getElementsByTagName("iframe");
// get sub-frames' entries first
if (subFrames && subFrames.length) {
for (i = 0; i < subFrames.length; i++) {
frameNavStart = getNavStartTime(subFrames[i].contentWindow);
frameOffset = 0;
if (frameNavStart > navStart) {
frameOffset = offset + (frameNavStart - navStart);
}
// Get canonical URL
a.href = subFrames[i].src;
entries = entries.concat(
findPerformanceEntriesForFrame(
subFrames[i].contentWindow, false, frameOffset, depth + 1, visibleEntries[a.href]));
}
}
if (typeof frame.performance.getEntriesByType !== "function") {
return entries;
}
function readServerTiming(entry) {
return (impl.serverTiming && entry.serverTiming) || [];
}
// add an entry for the top page
if (isTopWindow) {
navEntries = frame.performance.getEntriesByType("navigation");
if (navEntries && navEntries.length === 1) {
navEntry = navEntries[0];
// replace document with the actual URL
entries.push({
name: frame.location.href,
startTime: 0,
initiatorType: "html",
redirectStart: navEntry.redirectStart,
redirectEnd: navEntry.redirectEnd,
fetchStart: navEntry.fetchStart,
domainLookupStart: navEntry.domainLookupStart,
domainLookupEnd: navEntry.domainLookupEnd,
connectStart: navEntry.connectStart,
secureConnectionStart: navEntry.secureConnectionStart,
connectEnd: navEntry.connectEnd,
requestStart: navEntry.requestStart,
responseStart: navEntry.responseStart,
responseEnd: navEntry.responseEnd,
workerStart: navEntry.workerStart,
encodedBodySize: navEntry.encodedBodySize,
decodedBodySize: navEntry.decodedBodySize,
transferSize: navEntry.transferSize,
serverTiming: readServerTiming(navEntry),
nextHopProtocol: navEntry.nextHopProtocol
});
}
else if (frame.performance.timing) {
// add a fake entry from the timing object
t = frame.performance.timing;
//
// Avoid browser bugs:
// 1. navigationStart being 0 in some cases
// 2. responseEnd being ~2x what navigationStart is
// (ensure the end is within 60 minutes of start)
//
if (t.navigationStart !== 0 &&
t.responseEnd <= (t.navigationStart + (60 * 60 * 1000))) {
entries.push({
name: frame.location.href,
startTime: 0,
initiatorType: "html",
redirectStart: t.redirectStart ? (t.redirectStart - t.navigationStart) : 0,
redirectEnd: t.redirectEnd ? (t.redirectEnd - t.navigationStart) : 0,
fetchStart: t.fetchStart ? (t.fetchStart - t.navigationStart) : 0,
domainLookupStart: t.domainLookupStart ? (t.domainLookupStart - t.navigationStart) : 0,
domainLookupEnd: t.domainLookupEnd ? (t.domainLookupEnd - t.navigationStart) : 0,
connectStart: t.connectStart ? (t.connectStart - t.navigationStart) : 0,
secureConnectionStart: t.secureConnectionStart ? (t.secureConnectionStart - t.navigationStart) : 0,
connectEnd: t.connectEnd ? (t.connectEnd - t.navigationStart) : 0,
requestStart: t.requestStart ? (t.requestStart - t.navigationStart) : 0,
responseStart: t.responseStart ? (t.responseStart - t.navigationStart) : 0,
responseEnd: t.responseEnd ? (t.responseEnd - t.navigationStart) : 0
});
}
}
}
// offset all of the entries by the specified offset for this frame
var frameEntries = frame.performance.getEntriesByType("resource"),
frameFixedEntries = [];
if (frame === BOOMR.window && impl.collectedEntries) {
Array.prototype.push.apply(frameEntries, impl.collectedEntries);
impl.collectedEntries = [];
}
for (i = 0; frameEntries && i < frameEntries.length; i++) {
t = frameEntries[i];
rtEntry = {
name: t.name,
initiatorType: t.initiatorType,
encodedBodySize: t.encodedBodySize,
decodedBodySize: t.decodedBodySize,
transferSize: t.transferSize,
serverTiming: readServerTiming(t),
visibleDimensions: visibleEntries[t.name],
nextHopProtocol: t.nextHopProtocol
};
for (var field = 0; field < RT_FIELDS_TIMESTAMPS.length; field++) {
var key = RT_FIELDS_TIMESTAMPS[field];
rtEntry[key] = ((key === "startTime") || t[key]) ? (t[key] + offset) : 0;
}
if (t.hasOwnProperty("_data")) {
rtEntry._data = t._data;
}
// If this is a script, set its flags
if ((t.initiatorType === "script" || t.initiatorType === "link") && scripts[t.name]) {
var s = scripts[t.name];
// Add async & defer based on attribute values
rtEntry.scriptAttrs = (s.async ? ASYNC_ATTR : 0) | (s.defer ? DEFER_ATTR : 0);
while (s.nodeType === 1 && s.nodeName !== "BODY") {
s = s.parentNode;
}
// Add location by traversing up the tree until we either hit BODY or document
rtEntry.scriptAttrs |= (s.nodeName === "BODY" ? LOCAT_ATTR : 0);
}
// If this is a link, set its flags
if (t.initiatorType === "link" && links[t.name]) {
// split on ASCII whitespace
// eslint-disable-next-line no-loop-func
BOOMR.utils.arrayFind(links[t.name].rel.split(/[\u0009\u000A\u000C\u000D\u0020]+/), function(rel) {
// `rel`s are case insensitive
rel = rel.toLowerCase();
// only report the `rel` if it's from the known list
if (REL_TYPES[rel]) {
rtEntry.linkAttrs = REL_TYPES[rel];
return true;
}
});
}
frameFixedEntries.push(rtEntry);
}
entries = entries.concat(frameFixedEntries);
}
catch (e) {
return entries;
}
return entries;
}
/**
* Collect external resources by tagName
*
* @param {Element} a an anchor element
* @param {Object} obj object of resources where the key is the url
* @param {string} tagName tag name to collect
*/
function collectResources(a, obj, tagName) {
Array.prototype
.forEach
.call(a.ownerDocument.getElementsByTagName(tagName), function(r) {
// Get canonical URL
a.href = r.currentSrc ||
r.src ||
(typeof r.getAttribute === "function" && r.getAttribute("xlink:href")) ||
r.href;
// only get external resource
if (a.href.match(/^https?:\/\//)) {
obj[a.href] = r;
}
});
}
/**
* Converts a number to base-36.
*
* If not a number or a string, or === 0, return "". This is to facilitate
* compression in the timing array, where "blanks" or 0s show as a series
* of trailing ",,,," that can be trimmed.
*
* If a string, return a string.
*
* @param {number} n Number
* @returns {string} Base-36 number, empty string, or string
*/
function toBase36(n) {
return (typeof n === "number" && n !== 0) ?
n.toString(36) :
(typeof n === "string" ? n : "");
}
/**
* Finds all remote resources in the selected window that are visible, and returns an object
* keyed by the url with an array of height,width,top,left as the value
*
* @param {Window} win Window to search
* @param {number[]} [winDims] position and size of the window if it is an embedded iframe in the
* format returned by this function
* @returns {object} Object with URLs of visible assets as keys, and Array[height, width, top, left,
* naturalHeight, naturalWidth] as value
*/
function getVisibleEntries(win, winDims) {
// lower-case tag names should be used:
// https://developer.mozilla.org/en-US/docs/Web/API/Element/getElementsByTagName
var els = ["img", "iframe", "image"],
entries = {},
x, y,
doc = win.document,
a = doc.createElement("A");
winDims = winDims || [0, 0, 0, 0];
// https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollX
// https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
x = winDims[3] + (win.pageXOffset !== undefined) ?
win.pageXOffset :
(doc.documentElement || doc.body.parentNode || doc.body).scrollLeft;
y = winDims[2] + (win.pageYOffset !== undefined) ?
win.pageYOffset :
(doc.documentElement || doc.body.parentNode || doc.body).scrollTop;
// look at each IMG and IFRAME
els.forEach(function(elname) {
var elements = doc.getElementsByTagName(elname),
el, i, rect, src, realImg, nH, nW;
for (i = 0; i < elements.length; i++) {
el = elements[i];
if (!el) {
continue;
}
// look at this element if it has a src attribute or xlink:href, and we haven't already looked at it
// currentSrc = IMG inside a PICTURE element or IMG srcset
// src = IMG, IFRAME
// xlink:href = svg:IMAGE
src = el.currentSrc ||
el.src ||
(typeof el.getAttribute === "function" &&
(el.getAttribute("src")) || el.getAttribute("xlink:href"));
// make src absolute
a.href = src;
src = a.href;
if (!src || entries[src]) {
continue;
}
rect = el.getBoundingClientRect();
// Require both height & width to be non-zero
// IE <= 8 does not report rect.height/rect.width so we need offsetHeight & width
if ((rect.height || el.offsetHeight) && (rect.width || el.offsetWidth)) {
entries[src] = [
rect.height || el.offsetHeight,
rect.width || el.offsetWidth,
Math.round(rect.top + y),
Math.round(rect.left + x)
];
// If this is an image, it has a naturalHeight & naturalWidth
// if these are different from its display height and width, we should report that
// because it indicates scaling in HTML
if (!el.naturalHeight && !el.naturalWidth) {
continue;
}
// If the image came from a srcset, then the naturalHeight/Width will be density corrected.
// We get the actual physical dimensions by assigning the image to an uncorrected Image object.
// In most cases, this should load from in-memory cache, so there should be no extra load.
// When the original image's caching is disabled, this will cause the image to be
// re-downloaded which can cause issues with capchas.
if (impl.getSrcsetDimensions &&
el.currentSrc &&
(el.srcset ||
(el.parentNode && el.parentNode.nodeName && el.parentNode.nodeName.toUpperCase() === "PICTURE"))) {
// We need to create this Image in the window that contains the element, and not
// the boomerang window.
realImg = el.isConnected ? el.ownerDocument.createElement("IMG") : new BOOMR.window.Image();
realImg.src = src;
}
else {
realImg = el;
}
nH = realImg.naturalHeight || el.naturalHeight;
nW = realImg.naturalWidth || el.naturalWidth;
if ((nH || nW) && (entries[src][0] !== nH || entries[src][1] !== nW)) {
entries[src].push(nH, nW);
}
}
}
});
return entries;
}
/**
* Gathers a filtered list of performance entries.
*
* @param {number} from Only get timings from
* @param {number} to Only get timings up to
* @param {string[]} initiatorTypes Array of initiator types
*
* @returns {ResourceTiming[]} Matching ResourceTiming entries
* @memberof BOOMR.plugins.ResourceTiming
*/
function getFilteredResourceTiming(from, to, initiatorTypes) {
var entries = findPerformanceEntriesForFrame(BOOMR.window, true, 0, 0),
i, e,
navStart = getNavStartTime(BOOMR.window),
countCollector = {};
if (!entries || !entries.length) {
return {
entries: []
};
}
// sort entries by start time
entries.sort(function(a, b) {
return a.startTime - b.startTime;
});
var filteredEntries = [];
for (i = 0; i < entries.length; i++) {
e = entries[i];
if (typeof e.name !== "string") {
continue;
}
// skip non-resource URLs
if (e.name.indexOf("http:") !== 0 &&
e.name.indexOf("https:") !== 0) {
continue;
}
// skip beacon URLs
if (typeof BOOMR.getBeaconURL === "function" &&
BOOMR.getBeaconURL() &&
e.name.indexOf(BOOMR.getBeaconURL()) > -1) {
continue;
}
// if the user specified a "from" time, skip resources that started before then
if (from && (navStart + e.startTime) < from) {
continue;
}
// if we were given a final timestamp, don't add any resources that started after it
if (to && (navStart + e.startTime) > to) {
// We can also break at this point since the array is time sorted
break;
}
// if given an array of initiatorTypes to include, skip anything else
if (typeof initiatorTypes !== "undefined" && initiatorTypes !== "*" && initiatorTypes.length) {
if (!e.initiatorType || !BOOMR.utils.inArray(e.initiatorType, initiatorTypes)) {
continue;
}
}
accumulateServerTimingEntries(countCollector, e.serverTiming);
filteredEntries.push(e);
}
var lookup = compressServerTiming(countCollector);
return {
entries: filteredEntries,
serverTiming: {
lookup: lookup,
indexed: indexServerTiming(lookup)
}
};
}
/**
* Gets compressed content and transfer size information, if available
*
* @param {ResourceTiming} resource ResourceTiming object
*
* @returns {string} Compressed data (or empty string, if not available)
*/
function compressSize(resource) {
var sTrans, sEnc, sDec, sizes;
// check to see if we can add content sizes
if (resource.encodedBodySize ||
resource.decodedBodySize ||
resource.transferSize) {
//
// transferSize: how many bytes were over the wire. It can be 0 in the case of X-O,
// or if it was fetched from a cache.
//
// encodedBodySize: the size after applying encoding (e.g. gzipped size).
// It is 0 if X-O or no content (eg: beacon).
//
// decodedBodySize: the size after removing encoding (e.g. the original content size).
// It is 0 if X-O or no content (eg: beacon).
//
// Here are the possible combinations of values: [encodedBodySize, transferSize, decodedBodySize]
//
// Cross-Origin resources w/out Timing-Allow-Origin set: [0, 0, 0] -> [0, 0, 0] -> [empty]
// 204: [0, t, 0] -> [0, t, 0] -> [e, t-e] -> [, t]
// 304: [e, t: t <=> e, d: d>=e] -> [e, t-e, d-e]
// 200 non-gzipped: [e, t: t>=e, d: d=e] -> [e, t-e]
// 200 gzipped: [e, t: t>=e, d: d>=e] -> [e, t-e, d-e]
// retrieved from cache non-gzipped: [e, 0, d: d=e] -> [e]
// retrieved from cache gzipped: [e, 0, d: d>=e] -> [e, _, d-e]
//
sTrans = resource.transferSize;
sEnc = resource.encodedBodySize;
sDec = resource.decodedBodySize;
// convert to an array
sizes = [sEnc, sTrans ? sTrans - sEnc : "_", sDec ? sDec - sEnc : 0];
// change everything to base36 and remove any trailing ,s
return sizes.map(toBase36).join(",").replace(/,+$/, "");
}
else {
return "";
}
}
/* BEGIN_DEBUG */
/**
* Decompresses size information back into the specified resource
*
* @param {string} compressed Compressed string
* @param {ResourceTiming} resource ResourceTiming object
*/
function decompressSize(compressed, resource) {
var split, i;
if (typeof resource === "undefined") {
resource = {};
}
split = compressed.split(",");
for (i = 0; i < split.length; i++) {
if (split[i] === "_") {
// special non-delta value
split[i] = 0;
}
else {
// fill in missing numbers
if (split[i] === "") {
split[i] = 0;
}
// convert back from Base36
split[i] = parseInt(split[i], 36);
if (i > 0) {
// delta against first number
split[i] += split[0];
}
}
}
// fill in missing
if (split.length === 1) {
// transferSize is a delta from encodedSize
split.push(split[0]);
}
if (split.length === 2) {
// decodedSize is a delta from encodedSize
split.push(split[0]);
}
// re-add attributes to the resource
resource.encodedBodySize = split[0];
resource.transferSize = split[1];
resource.decodedBodySize = split[2];
return resource;
}
/* END_DEBUG */
/**
* Trims the URL according to the specified URL trim patterns,
* then applies a length limit.
*
* @param {string} url URL to trim
* @param {string} urlsToTrim List of URLs (strings or regexs) to trim
* @returns {string} Trimmed URL
*/
function trimUrl(url, urlsToTrim) {
var i, urlIdx, trim;
if (url && urlsToTrim) {
// trim the payload from any of the specified URLs
for (i = 0; i < urlsToTrim.length; i++) {
trim = urlsToTrim[i];
if (typeof trim === "string") {
urlIdx = url.indexOf(trim);
if (urlIdx !== -1) {
url = url.substr(0, urlIdx + trim.length) + "...";
break;
}
}
else if (trim instanceof RegExp) {
if (trim.test(url)) {
// replace the URL with the first capture group
url = url.replace(trim, "$1") + "...";
}
}
}
}
// apply limits
return BOOMR.utils.cleanupURL(url, impl.urlLimit);
}
/**
* Guesses whether or a not a resource is a cache hit.
*
* We can get this directly from the beacon if it has ResourceTiming2 sizing
* data, and the resource is same-origin or has TAO.
*
* For all other cases, we have to guess based on the timing
*
* @param {PerformanceResourceTiming} entry ResourceTiming entry
*
* @returns {boolean} True if we estimate it was a cache hit.
*/
function isCacheHit(entry) {
// if we transferred bytes, it must not be a cache hit
// (will return false for 304 Not Modified)
if (entry.transferSize > 0) {
return false;
}
// if the body size is non-zero, it must mean this is a
// ResourceTiming2 browser, this was same-origin or TAO,
// and transferSize was 0, so it was in the cache
if (entry.decodedBodySize > 0) {
return true;
}
// fall back to duration checking (non-RT2 or cross-origin)
return entry.duration < 30;
}
/**
* Gathers performance entries and compresses the result.
*
* @param {number} from Only get timings from
* @param {number} to Only get timings up to
*
* @returns {object} An object containing the Optimized performance entries trie and
* the optimized server timing lookup
* @memberof BOOMR.plugins.ResourceTiming
*/
function getCompressedResourceTiming(from, to) {
/* eslint no-script-url:0 */
var i, e,
results = {},
initiatorType, url, data;
var ret = getFilteredResourceTiming(from, to, impl.trackedResourceTypes);
var entries = ret.entries,
serverTiming = ret.serverTiming;
if (!entries || !entries.length) {
return {
restiming: {},
servertiming: []
};
}
for (i = 0; i < entries.length; i++) {
e = entries[i];
//
// Compress the RT data into a string:
//
// 1. Start with the initiator type, which is mapped to a number.
// 2. Put the timestamps into an array in a set order (reverse chronological order),
// which pushes timestamps that are more likely to be zero (duration since
// startTime) towards the end of the array (eg redirect* and domainLookup*).
// 3. Convert these timestamps to Base36, with empty or zero times being an empty string
// 4. Join the array on commas
// 5. Trim all trailing empty commas (eg ",,,")
//
// prefix initiatorType to the string
initiatorType = INITIATOR_TYPES[e.initiatorType];
if (typeof initiatorType === "undefined") {
initiatorType = 0;
}
data = initiatorType + [
trimTiming(e.startTime, 0),
trimTiming(e.responseEnd, e.startTime),
trimTiming(e.responseStart, e.startTime),
trimTiming(e.requestStart, e.startTime),
trimTiming(e.connectEnd, e.startTime),
trimTiming(e.secureConnectionStart, e.startTime),
trimTiming(e.connectStart, e.startTime),
trimTiming(e.domainLookupEnd, e.startTime),
trimTiming(e.domainLookupStart, e.startTime),
trimTiming(e.redirectEnd, e.startTime),
trimTiming(e.redirectStart, e.startTime)
// this `replace()` removes any trailing commas
].map(toBase36).join(",").replace(/,+$/, "");
// add content and transfer size info
var compSize = compressSize(e);
if (compSize !== "") {
data += SPECIAL_DATA_PREFIX + SPECIAL_DATA_SIZE_TYPE + compSize;
}
if (e.hasOwnProperty("scriptAttrs")) {
data += SPECIAL_DATA_PREFIX + SPECIAL_DATA_SCRIPT_ATTR_TYPE + e.scriptAttrs;
}
if (e.serverTiming && e.serverTiming.length) {
data += SPECIAL_DATA_PREFIX + SPECIAL_DATA_SERVERTIMING_TYPE +
e.serverTiming.reduce(function(stData, entry, entryIndex) {
// The numeric of the entry is `value` for Chrome 61, `duration` after that
var duration = String(typeof entry.duration !== "undefined" ? entry.duration : entry.value);
if (duration.substring(0, 2) === "0.") {
// lop off the leading 0
duration = duration.substring(1);
}
// The name of the entry is `metric` for Chrome 61, `name` after that
var name = entry.name || entry.metric;
var lookupKey = identifyServerTimingEntry(serverTiming.indexed[name].index,
serverTiming.indexed[name].descriptions[entry.description]);
stData += (entryIndex > 0 ? "," : "") + duration + lookupKey;
return stData;
}, "");
}
if (e.hasOwnProperty("linkAttrs")) {
data += SPECIAL_DATA_PREFIX + SPECIAL_DATA_LINK_ATTR_TYPE + e.linkAttrs;
}
if (e.workerStart && typeof e.workerStart === "number" && e.workerStart !== 0) {
// Has Service worker timing data that's non zero. Resource request not intercepted
// by Service worker always return 0 as per MDN
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/workerStart
// Lets round it and offset from startTime. We are going to round up the workerStart
// timing specifically. We are doing this to avoid the issue where the case of Service
// worker timestamps being sub-milliseconds more than startTime getting incorrectly
// marked as 0ms (due to round down).
// We feel marking such cases as 0ms, after rounding down, for workerStart would present
// more incorrect indication to the user. Hence the decision to round up.
var wsRoundedUp = roundUpTiming(e.workerStart);
var workerStartOffset = trimTiming(wsRoundedUp, e.startTime);
data += SPECIAL_DATA_PREFIX + SPECIAL_DATA_SERVICE_WORKER_TYPE + toBase36(workerStartOffset);
}
// nextHopProtocol handling
if (e.hasOwnProperty("nextHopProtocol") &&
e.nextHopProtocol &&
!isCacheHit(e)) {
// change http/1.1 to h1.1 to be consistent with h2 & h3.
data += SPECIAL_DATA_PREFIX + SPECIAL_DATA_PROTOCOL + e.nextHopProtocol.replace("http/", "h");
}
url = trimUrl(e.name, impl.trimUrls);
if (!e.hasOwnProperty("_data")) {
// if this entry already exists, add a pipe as a separator
if (results[url] !== undefined) {
results[url] += "|" + data;
}
else if (e.visibleDimensions) {
// We use * as an additional separator to indicate it is not a new resource entry
// The following characters will not be URL encoded:
// *!-.()~_ but - and . are special to number representation so we don't use them
// After the *, the type of special data (ResourceTiming = 0) is added
results[url] =
SPECIAL_DATA_PREFIX +
SPECIAL_DATA_DIMENSION_TYPE +
e.visibleDimensions.map(Math.round).map(toBase36).join(",").replace(/,+$/, "") +
"|" +
data;
}
else {
results[url] = data;
}
}
else {
var namespacedData = "";
for (var key in e._data) {
if (e._data.hasOwnProperty(key)) {
namespacedData += SPECIAL_DATA_PREFIX + SPECIAL_DATA_NAMESPACED_TYPE + key + ":" + e._data[key];
}
}
if (typeof results[url] === "undefined") {
// we haven't seen this resource yet, treat this potential stub as the canonical version
results[url] = data + namespacedData;
}
else {
// we have seen this resource before
// forget the timing data of `e`, just supplement the previous entry with the new `namespacedData`
results[url] += namespacedData;
}
}
}
return {
restiming: optimizeTrie(convertToTrie(results, impl.splitAtPath), true),
servertiming: serverTiming.lookup
};
}
/**
* Compresses an array of ResourceTiming-like objects (those with a fetchStart
* and a responseStart/responseEnd) by reducing multiple objects with the same
* fetchStart down to a single object with the longest duration.
*
* Array must be pre-sorted by fetchStart, then by responseStart||responseEnd
*
* @param {ResourceTiming[]} resources ResourceTiming-like resources, with just
* a fetchStart and responseEnd
*
* @returns Duration, in milliseconds
*/
function reduceFetchStarts(resources) {
var times = [];
if (!resources || !resources.length) {
return times;
}
for (var i = 0; i < resources.length; i++) {
var res = resources[i];
// if there is a subsequent resource with the same fetchStart, use
// its value instead (since pre-sort guarantee is that it's end
// will be >= this one)
if (i !== resources.length - 1 &&
res.fetchStart === resources[i + 1].fetchStart) {
continue;
}
// track just the minimum fetchStart and responseEnd
times.push({
fetchStart: res.fetchStart,
responseEnd: res.responseStart || res.responseEnd
});
}
return times;
}
/**
* Calculates the union of durations of the specified resources. If
* any resources overlap, those timeslices are not double-counted.
*
* @param {ResourceTiming[]} resources Resources
*
* @returns Duration, in milliseconds
* @memberof BOOMR.plugins.ResourceTiming
*/
function calculateResourceTimingUnion(resources) {
var i;
if (!resources || !resources.length) {
return 0;
}
// First, sort by start time, then end time
resources.sort(function(a, b) {
if (a.fetchStart !== b.fetchStart) {
return a.fetchStart - b.fetchStart;
}
else {
var ae = a.responseStart || a.responseEnd;
var be = b.responseStart || b.responseEnd;
return ae - be;
}
});
// Next, find all resources with the same start time, and reduce
// them to the largest end time.
var times = reduceFetchStarts(resources);
// Third, for every resource, if the start is less than the end of
// any previous resource, change its start to the end. If the new start
// time is more than the end time, we can discard this one.
var times2 = [];
var furthestEnd = 0;
for (i = 0; i < times.length; i++) {
var res = times[i];
if (res.fetchStart < furthestEnd) {
res.fetchStart = furthestEnd;
}
// as long as this resource has > 0 duration, add it to our next list
if (res.fetchStart < res.responseEnd) {
times2.push(res);
// keep track of the furthest end point
furthestEnd = res.responseEnd;
}
}
// Reduce down again to same start times again, and now we should
// have no overlapping regions
var times3 = reduceFetchStarts(times2);
// Finally, calculate the overall time from our non-overlapping regions
var totalTime = 0;
for (i = 0; i < times3.length; i++) {
totalTime += times3[i].responseEnd - times3[i].fetchStart;
}
return totalTime;
}
/**
* Adds 'restiming' and 'servertiming' to the beacon
*
* @param {number} from Only get timings from
* @param {number} to Only get timings up to
*
* @memberof BOOMR.plugins.ResourceTiming
*/
function addResourceTimingToBeacon(from, to) {
var r;
// Can't send if we don't support JSON
if (typeof JSON === "undefined") {
return;
}
/* BEGIN_DEBUG */
BOOMR.utils.mark("restiming:build:start");
/* END_DEBUG */
r = getCompressedResourceTiming(from, to);
/* BEGIN_DEBUG */
BOOMR.utils.mark("restiming:build:end");
BOOMR.utils.measure(
"restiming:build",
"restiming:build:start",
"restiming:build:end");
/* END_DEBUG */
if (r) {
BOOMR.info("Client supports Resource Timing API", "restiming");
addToBeacon(r);
}
}
/**
* Given an array of server timing entries (from the resource timing entry),
* [initialize and] increment our count collector of the following format: {
* "metric-one": {
* count: 3,
* counts: {
* "description-one": 2,
* "description-two": 1,
* }
* }
* }
*
* @param {Object} countCollector Per-beacon collection of counts
* @param {Array} serverTimingEntries Server Timing Entries from a Resource Timing Entry
* @returns nothing
*/
function accumulateServerTimingEntries(countCollector, serverTimingEntries) {
(serverTimingEntries || []).forEach(function(entry) {
var name = entry.name || entry.metric;
if (typeof countCollector[name] === "undefined") {
countCollector[name] = {
count: 0,
counts: {}
};
}
var metric = countCollector[name];
metric.counts[entry.description] = metric.counts[entry.description] || 0;
metric.counts[entry.description]++;
metric.count++;
});
}
/**
* Given our count collector of the format: {
* "metric-two": {
* count: 1,
* counts: {
* "description-three": 1,
* }
* },
* "metric-one": {
* count: 3,
* counts: {
* "description-one": 1,
* "description-two": 2,
* }
* }
* }
*
* , return the lookup of the following format: [
* ["metric-one", "description-two", "description-one"],
* ["metric-two", "description-three"],
* ]
*
* Note: The order of these arrays of arrays matters: there are more server timing entries with
* name === "metric-one" than "metric-two", and more "metric-one"/"description-two" than
* "metric-one"/"description-one".
*
* @param {Object} countCollector Per-beacon collection o