/* * Copyright (c) 2011, Yahoo! Inc. All rights reserved. * Copyrights licensed under the BSD License. See the accompanying LICENSE.txt file for terms. */ /** \file boomerang.js boomerang measures various performance characteristics of your user's browsing experience and beacons it back to your server. \details To use this you'll need a web site, lots of users and the ability to do something with the data you collect. How you collect the data is up to you, but we have a few ideas. */ // Measure the time the script started // This has to be global so that we don't wait for the entire // BOOMR function to download and execute before measuring the // time. We also declare it without `var` so that we can later // `delete` it. This is the only way that works on Internet Explorer BOOMR_start = new Date().getTime(); // beaconing section // the parameter is the window (function(w) { var impl, boomr, k, d=w.document; // Short namespace because I don't want to keep typing BOOMERANG if(typeof BOOMR === "undefined") { BOOMR = {}; } // don't allow this code to be included twice if(BOOMR.version) { return; } BOOMR.version = "0.9"; // impl is a private object not reachable from outside the BOOMR object // users can set properties by passing in to the init() method impl = { // properties beacon_url: "", // strip out everything except last two parts of hostname. // This doesn't work well for domains that end with a country tld, // but we allow the developer to override site_domain for that. site_domain: w.location.hostname. replace(/.*?([^.]+\.[^.]+)\.?$/, '$1'). toLowerCase(), //! User's ip address determined on the server. Used for the BA cookie user_ip: '', events: { "page_ready": [], "page_unload": [], "visibility_changed": [], "before_beacon": [] }, vars: {}, disabled_plugins: {}, fireEvent: function(e_name, data) { var i, h, e; if(!this.events.hasOwnProperty(e_name)) { return false; } e = this.events[e_name]; for(i=0; i= 0 ) { i += name.length; cookies = cookies.substring(i, cookies.indexOf(';', i)); return cookies; } return null; }, setCookie: function(name, subcookies, max_age, path, domain, sec) { var value = "", k, nameval, c, exp = ""; if(!name) { return false; } for(k in subcookies) { if(subcookies.hasOwnProperty(k)) { value += '&' + encodeURIComponent(k) + '=' + encodeURIComponent(subcookies[k]); } } value = value.replace(/^&/, ''); if(max_age) { exp = new Date(); exp.setTime(exp.getTime() + max_age*1000); exp = exp.toGMTString(); } nameval = name + '=' + value; c = nameval + ((max_age) ? "; expires=" + exp : "" ) + ((path) ? "; path=" + path : "") + ((typeof domain !== "undefined") ? "; domain=" + (domain !== null ? domain : impl.site_domain ) : "") + ((sec) ? "; secure" : ""); if ( nameval.length < 4000 ) { d.cookie = c; // confirm cookie was set (could be blocked by user's settings, etc.) return ( value === this.getCookie(name) ); } return false; }, getSubCookies: function(cookie) { var cookies_a, i, l, kv, cookies={}; if(!cookie) { return null; } cookies_a = cookie.split('&'); if(cookies_a.length === 0) { return null; } for(i=0, l=cookies_a.length; i0); } }, init: function(config) { var i, k, properties = ["beacon_url", "site_domain", "user_ip"]; if(!config) { config = {}; } for(i=0; i -1)?'&':'?') + 'v=' + encodeURIComponent(BOOMR.version) + '&u=' + encodeURIComponent(d.URL.replace(/#.*/, '')); // use d.URL instead of location.href because of a safari bug for(k in impl.vars) { if(impl.vars.hasOwnProperty(k)) { nparams++; url += "&" + encodeURIComponent(k) + "=" + ( impl.vars[k]===undefined || impl.vars[k]===null ? '' : encodeURIComponent(impl.vars[k]) ); } } // only send beacon if we actually have something to beacon back if(nparams) { img = new Image(); img.src=url; } return this; } }; delete BOOMR_start; var make_logger = function(l) { return function(m, s) { this.log(m, l, "boomerang" + (s?"."+s:"")); return this; }; }; boomr.debug = make_logger("debug"); boomr.info = make_logger("info"); boomr.warn = make_logger("warn"); boomr.error = make_logger("error"); if(w.YAHOO && w.YAHOO.widget && w.YAHOO.widget.Logger) { boomr.log = w.YAHOO.log; } else if(typeof w.Y !== "undefined" && typeof w.Y.log !== "undefined") { boomr.log = w.Y.log; } else if(typeof console !== "undefined" && typeof console.log !== "undefined") { boomr.log = function(m,l,s) { console.log(s + ": [" + l + "] ", m); }; } for(k in boomr) { if(boomr.hasOwnProperty(k)) { BOOMR[k] = boomr[k]; } } BOOMR.plugins = BOOMR.plugins || {}; }(window)); // end of boomerang beaconing section // Now we start built in plugins. // This is the Round Trip Time plugin. Abbreviated to RT // the parameter is the window (function(w) { var d=w.document; BOOMR = BOOMR || {}; BOOMR.plugins = BOOMR.plugins || {}; // private object var impl = { complete: false, //! Set when this plugin has completed timers: {}, //! Custom timers that the developer can use // Format for each timer is { start: XXX, end: YYY, delta: YYY-XXX } cookie: 'RT', //! Name of the cookie that stores the start time and referrer cookie_exp:600, //! Cookie expiry in seconds strict_referrer: true, //! By default, don't beacon if referrers don't match. // If set to false, beacon both referrer values and let // the back end decide navigationType: 0, navigationStart: undefined, responseStart: undefined, // The start method is fired on page unload. It is called with the scope // of the BOOMR.plugins.RT object start: function() { var t_end, t_start = new Date().getTime(); // Disable use of RT cookie by setting its name to a falsy value if(!this.cookie) { return this; } // We use document.URL instead of location.href because of a bug in safari 4 // where location.href is URL decoded if(!BOOMR.utils.setCookie(this.cookie, { s: t_start, r: d.URL.replace(/#.*/, '') }, this.cookie_exp, "/", null) ) { BOOMR.error("cannot set start cookie", "rt"); return this; } t_end = new Date().getTime(); if(t_end - t_start > 50) { // It took > 50ms to set the cookie // The user Most likely has cookie prompting turned on so // t_start won't be the actual unload time // We bail at this point since we can't reliably tell t_done BOOMR.utils.removeCookie(this.cookie); // at some point we may want to log this info on the server side BOOMR.error("took more than 50ms to set cookie... aborting: " + t_start + " -> " + t_end, "rt"); } return this; }, initNavTiming: function() { var ti, p, source; if(this.navigationStart) { return; } // Get start time from WebTiming API see: // https://dvcs.w3.org/hg/webperf/raw-file/tip/specs/NavigationTiming/Overview.html // http://blogs.msdn.com/b/ie/archive/2010/06/28/measuring-web-page-performance.aspx // http://blog.chromium.org/2010/07/do-you-know-how-slow-your-web-page-is.html p = w.performance || w.msPerformance || w.webkitPerformance || w.mozPerformance; if(p && p.navigation) { this.navigationType = p.navigation.type; } if(p && p.timing) { ti = p.timing; } else if(w.chrome && w.chrome.csi && w.chrome.csi().startE) { // Older versions of chrome also have a timing API that's sort of documented here: // http://ecmanaut.blogspot.com/2010/06/google-bom-feature-ms-since-pageload.html // source here: // http://src.chromium.org/viewvc/chrome/trunk/src/chrome/renderer/loadtimes_extension_bindings.cc?view=markup ti = { navigationStart: w.chrome.csi().startE }; source = "csi"; } else if(w.gtbExternal && w.gtbExternal.startE()) { // The Google Toolbar exposes navigation start time similar to old versions of chrome // This would work for any browser that has the google toolbar installed ti = { navigationStart: w.gtbExternal.startE() }; source = 'gtb'; } if(ti) { // Always use navigationStart since it falls back to fetchStart // If not set, we leave t_start alone so that timers that depend // on it don't get sent back. Never use requestStart since if // the first request fails and the browser retries, it will contain // the value for the new request. BOOMR.addVar("rt.start", source || "navigation"); this.navigationStart = ti.navigationStart || undefined; this.responseStart = ti.responseStart || undefined; // bug in Firefox 7 & 8 https://bugzilla.mozilla.org/show_bug.cgi?id=691547 if(navigator.userAgent.match(/Firefox\/[78]\./)) { this.navigationStart = ti.unloadEventStart || ti.fetchStart || undefined; } } else { BOOMR.warn("This browser doesn't support the WebTiming API", "rt"); } return; } }; BOOMR.plugins.RT = { // Methods init: function(config) { impl.complete = false; impl.timers = {}; BOOMR.utils.pluginConfig(impl, config, "RT", ["cookie", "cookie_exp", "strict_referrer"]); BOOMR.subscribe("page_ready", this.done, null, this); BOOMR.subscribe("page_unload", impl.start, null, impl); if(BOOMR.t_start) { // How long does it take Boomerang to load up and execute this.startTimer('boomerang', BOOMR.t_start); this.endTimer('boomerang', BOOMR.t_end); // t_end === null defaults to current time // How long did it take till Boomerang started this.endTimer('boomr_fb', BOOMR.t_start); } return this; }, startTimer: function(timer_name, time_value) { if(timer_name) { if (timer_name === 't_page') { this.endTimer('t_resp', time_value); } impl.timers[timer_name] = {start: (typeof time_value === "number" ? time_value : new Date().getTime())}; impl.complete = false; } return this; }, endTimer: function(timer_name, time_value) { if(timer_name) { impl.timers[timer_name] = impl.timers[timer_name] || {}; if(!("end" in impl.timers[timer_name])) { impl.timers[timer_name].end = (typeof time_value === "number" ? time_value : new Date().getTime()); } } return this; }, setTimer: function(timer_name, time_delta) { if(timer_name) { impl.timers[timer_name] = { delta: time_delta }; } return this; }, // Called when the page has reached a "usable" state. This may be when the // onload event fires, or it could be at some other moment during/after page // load when the page is usable by the user done: function() { var t_start, r, r2, subcookies, basic_timers = { t_done: 1, t_resp: 1, t_page: 1}, ntimers = 0, t_name, timer, t_other=[]; if(impl.complete) { return this; } impl.initNavTiming(); if( (d.webkitVisibilityState && d.webkitVisibilityState === "prerender") || (d.msVisibilityState && d.msVisibilityState === 3) ) { // This means that onload fired through a pre-render. We'll capture this // time, but wait for t_done until after the page has become either visible // or hidden (ie, it moved out of the pre-render state) // http://code.google.com/chrome/whitepapers/pagevisibility.html // http://www.w3.org/TR/2011/WD-page-visibility-20110602/ // http://code.google.com/chrome/whitepapers/prerender.html this.startTimer("t_load", impl.navigationStart); this.endTimer("t_load"); // this will measure actual onload time for a prerendered page this.startTimer("t_prerender", impl.navigationStart); this.startTimer("t_postrender"); // time from prerender to visible or hidden BOOMR.subscribe("visibility_changed", this.done, null, this); return this; } // If the dev has already called endTimer, then this call will do nothing // else, it will stop the page load timer this.endTimer("t_done"); if(impl.responseStart) { // Use NavTiming API to figure out resp latency and page time // t_resp will use the cookie if available or fallback to NavTiming this.endTimer("t_resp", impl.responseStart); if(impl.timers.t_load) { this.setTimer("t_page", impl.timers.t_load.end - impl.responseStart); } else { this.setTimer("t_page", new Date().getTime() - impl.responseStart); } } else if(impl.timers.hasOwnProperty('t_page')) { // If the dev has already started t_page timer, we can end it now as well this.endTimer("t_page"); } // If a prerender timer was started, we can end it now as well if(impl.timers.hasOwnProperty('t_postrender')) { this.endTimer("t_postrender"); this.endTimer("t_prerender"); } // A beacon may be fired automatically on page load or if the page dev fires // it manually with their own timers. It may not always contain a referrer // (eg: XHR calls). We set default values for these cases r = r2 = d.referrer.replace(/#.*/, ''); // If impl.cookie is not set, the dev does not want to use cookie time if(impl.cookie) { subcookies = BOOMR.utils.getSubCookies(BOOMR.utils.getCookie(impl.cookie)); BOOMR.utils.removeCookie(impl.cookie); if(subcookies && subcookies.s && subcookies.r) { r = subcookies.r; if(!impl.strict_referrer || r === r2) { t_start = parseInt(subcookies.s, 10); } } } if(t_start && impl.navigationType != 2) { // 2 is TYPE_BACK_FORWARD but the constant may not be defined across browsers BOOMR.addVar("rt.start", "cookie"); // if the user hit the back button, referrer will match, and cookie will match } // but will have time of previous page start, so t_done will be wrong else { t_start = impl.navigationStart; } // make sure old variables don't stick around BOOMR.removeVar('t_done', 't_page', 't_resp', 'r', 'r2', 'rt.bstart', 'rt.end'); BOOMR.addVar('rt.bstart', BOOMR.t_start); BOOMR.addVar('rt.end', impl.timers.t_done.end); for(t_name in impl.timers) { if(!impl.timers.hasOwnProperty(t_name)) { continue; } timer = impl.timers[t_name]; // if delta is a number, then it was set using setTimer // if not, then we have to calculate it using start & end if(typeof timer.delta !== "number") { if(typeof timer.start !== "number") { timer.start = t_start; } timer.delta = timer.end - timer.start; } // If the caller did not set a start time, and if there was no start cookie // then timer.delta will be NaN, in which case we discard it. if(isNaN(timer.delta)) { continue; } if(basic_timers.hasOwnProperty(t_name)) { BOOMR.addVar(t_name, timer.delta); } else { t_other.push(t_name + '|' + timer.delta); } ntimers++; } if(ntimers) { BOOMR.addVar("r", r); if(r2 !== r) { BOOMR.addVar("r2", r2); } if(t_other.length) { BOOMR.addVar("t_other", t_other.join(',')); } } impl.timers = {}; impl.complete = true; BOOMR.sendBeacon(); // we call sendBeacon() anyway because some other plugin // may have blocked waiting for RT to complete return this; }, is_complete: function() { return impl.complete; } }; }(window)); // End of RT plugin // This is the Bandwidth & Latency plugin abbreviated to BW // the parameter is the window (function(w) { var d=w.document; BOOMR = BOOMR || {}; BOOMR.plugins = BOOMR.plugins || {}; // We choose image sizes so that we can narrow down on a bandwidth range as // soon as possible the sizes chosen correspond to bandwidth values of // 14-64kbps, 64-256kbps, 256-1024kbps, 1-2Mbps, 2-8Mbps, 8-30Mbps & 30Mbps+ // Anything below 14kbps will probably timeout before the test completes // Anything over 60Mbps will probably be unreliable since latency will make up // the largest part of download time. If you want to extend this further to // cover 100Mbps & 1Gbps networks, use image sizes of 19,200,000 & 153,600,000 // bytes respectively // See https://spreadsheets.google.com/ccc?key=0AplxPyCzmQi6dDRBN2JEd190N1hhV1N5cHQtUVdBMUE&hl=en_GB // for a spreadsheet with the details var images=[ { name: "image-0.png", size: 11483, timeout: 1400 }, { name: "image-1.png", size: 40658, timeout: 1200 }, { name: "image-2.png", size: 164897, timeout: 1300 }, { name: "image-3.png", size: 381756, timeout: 1500 }, { name: "image-4.png", size: 1234664, timeout: 1200 }, { name: "image-5.png", size: 4509613, timeout: 1200 }, { name: "image-6.png", size: 9084559, timeout: 1200 } ]; images.end = images.length; images.start = 0; // abuse arrays to do the latency test simply because it avoids a bunch of // branches in the rest of the code. // I'm sorry Douglas images.l = { name: "image-l.gif", size: 35, timeout: 1000 }; // private object var impl = { // properties base_url: 'images/', timeout: 15000, nruns: 5, latency_runs: 10, user_ip: '', cookie_exp: 7*86400, cookie: 'BA', // state results: [], latencies: [], latency: null, runs_left: 0, aborted: false, complete: false, running: false, // methods // numeric comparator. Returns negative number if a < b, positive if a > b and 0 if they're equal // used to sort an array numerically ncmp: function(a, b) { return (a-b); }, // Calculate the interquartile range of an array of data points iqr: function(a) { var l = a.length-1, q1, q3, fw, b = [], i; q1 = (a[Math.floor(l*0.25)] + a[Math.ceil(l*0.25)])/2; q3 = (a[Math.floor(l*0.75)] + a[Math.ceil(l*0.75)])/2; fw = (q3-q1)*1.5; l++; for(i=0; i q1-fw) { b.push(a[i]); } } return b; }, calc_latency: function() { var i, n, sum=0, sumsq=0, amean, median, std_dev, std_err, lat_filtered; // We first do IQR filtering and use the resulting data set // for all calculations lat_filtered = this.iqr(this.latencies.sort(this.ncmp)); n = lat_filtered.length; BOOMR.debug(lat_filtered, "bw"); // First we get the arithmetic mean, standard deviation and standard error // We ignore the first since it paid the price of DNS lookup, TCP connect // and slow start for(i=1; i=0 && nimgs<3; j--) { // if we hit an undefined image time, we skipped everything before this if(!r[j]) { break; } if(r[j].t === null) { continue; } n++; nimgs++; // multiply by 1000 since t is in milliseconds and not seconds bw = images[j].size*1000/r[j].t; bandwidths.push(bw); bw_c = images[j].size*1000/(r[j].t - this.latency.mean); bandwidths_corrected.push(bw_c); } } BOOMR.debug('got ' + n + ' readings', "bw"); BOOMR.debug('bandwidths: ' + bandwidths, "bw"); BOOMR.debug('corrected: ' + bandwidths_corrected, "bw"); // First do IQR filtering since we use the median here // and should use the stddev after filtering. if(bandwidths.length > 3) { bandwidths = this.iqr(bandwidths.sort(this.ncmp)); bandwidths_corrected = this.iqr(bandwidths_corrected.sort(this.ncmp)); } else { bandwidths = bandwidths.sort(this.ncmp); bandwidths_corrected = bandwidths_corrected.sort(this.ncmp); } BOOMR.debug('after iqr: ' + bandwidths, "bw"); BOOMR.debug('corrected: ' + bandwidths_corrected, "bw"); // Now get the mean & median. // Also get corrected values that eliminate latency n = Math.max(bandwidths.length, bandwidths_corrected.length); for(i=0; i= images.end-1 || typeof this.results[this.nruns-run].r[i+1] !== "undefined" ) { BOOMR.debug(this.results[this.nruns-run], "bw"); // First run is a pilot test to decide what the largest image // that we can download is. All following runs only try to // download this image if(run === this.nruns) { images.start = i; } this.defer(this.iterate); } else { this.load_img(i+1, run, this.img_loaded); } }, finish: function() { if(!this.latency) { this.latency = this.calc_latency(); } var bw = this.calc_bw(), o = { bw: bw.median_corrected, bw_err: parseFloat(bw.stderr_corrected, 10), lat: this.latency.mean, lat_err: parseFloat(this.latency.stderr, 10), bw_time: Math.round(new Date().getTime()/1000) }; BOOMR.addVar(o); // If we have an IP address we can make the BA cookie persistent for a while // because we'll recalculate it if necessary (when the user's IP changes). if(!isNaN(o.bw)) { BOOMR.utils.setCookie(this.cookie, { ba: Math.round(o.bw), be: o.bw_err, l: o.lat, le: o.lat_err, ip: this.user_ip, t: o.bw_time }, (this.user_ip ? this.cookie_exp : 0), "/", null ); } this.complete = true; BOOMR.sendBeacon(); this.running = false; }, iterate: function() { if(this.aborted) { return false; } if(!this.runs_left) { this.finish(); } else if(this.latency_runs) { this.load_img('l', this.latency_runs--, this.lat_loaded); } else { this.results.push({r:[]}); this.load_img(images.start, this.runs_left--, this.img_loaded); } }, setVarsFromCookie: function(cookies) { var ba = parseInt(cookies.ba, 10), bw_e = parseFloat(cookies.be, 10), lat = parseInt(cookies.l, 10) || 0, lat_e = parseFloat(cookies.le, 10) || 0, c_sn = cookies.ip.replace(/\.\d+$/, '0'), // Note this is IPv4 only t = parseInt(cookies.t, 10), p_sn = this.user_ip.replace(/\.\d+$/, '0'), // We use the subnet instead of the IP address because some people // on DHCP with the same ISP may get different IPs on the same subnet // every time they log in t_now = Math.round((new Date().getTime())/1000); // seconds // If the subnet changes or the cookie is more than 7 days old, // then we recheck the bandwidth, else we just use what's in the cookie if(c_sn === p_sn && t >= t_now - this.cookie_exp) { this.complete = true; BOOMR.addVar({ 'bw': ba, 'lat': lat, 'bw_err': bw_e, 'lat_err': lat_e }); return true; } return false; } }; BOOMR.plugins.BW = { init: function(config) { var cookies; BOOMR.utils.pluginConfig(impl, config, "BW", ["base_url", "timeout", "nruns", "cookie", "cookie_exp"]); if(config && config.user_ip) { impl.user_ip = config.user_ip; } images.start = 0; impl.runs_left = impl.nruns; impl.latency_runs = 10; impl.results = []; impl.latencies = []; impl.latency = null; impl.complete = false; impl.aborted = false; BOOMR.removeVar('ba', 'ba_err', 'lat', 'lat_err'); cookies = BOOMR.utils.getSubCookies(BOOMR.utils.getCookie(impl.cookie)); if(!cookies || !cookies.ba || !impl.setVarsFromCookie(cookies)) { BOOMR.subscribe("page_ready", this.run, null, this); } return this; }, run: function() { if(impl.running || impl.complete) { return this; } if(w.location.protocol === 'https:') { // we don't run the test for https because SSL stuff will mess up b/w // calculations we could run the test itself over HTTP, but then IE // will complain about insecure resources, so the best is to just bail // and hope that the user gets the cookie from some other page BOOMR.info("HTTPS detected, skipping bandwidth test", "bw"); impl.complete = true; BOOMR.sendBeacon(); return this; } impl.running = true; setTimeout(this.abort, impl.timeout); impl.defer(impl.iterate); return this; }, abort: function() { impl.aborted = true; if (impl.running) { impl.finish(); // we don't defer this call because it might be called from // onunload and we want the entire chain to complete // before we return } return this; }, is_complete: function() { return impl.complete; } }; }(window)); // End of BW plugin /*jslint white: false, devel: true, onevar: true, browser: true, undef: true, nomen: true, regexp: false, continue: true, plusplus: false, bitwise: false, newcap: true, maxerr: 50, indent: 4 */