Source: surfnperf.js

/*!
 * @license
 * Copyright 2015-2022 Comcast Cable Communications Management, LLC
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

(function(root, factory) {
  if(typeof define === 'function' && define.amd) {
    // For Require.js
    define('surfnperf', factory);
  } else if(typeof exports === 'object') {
    // For Browserify
    module.exports = factory();
  } else {
    // Browser global if not using Require.js or Browserify
    root.surfnperf = factory();
  }
}(this, function() {

  /**
   * Date.now() shim for older browsers
   */
  if(!Date.now) {
    Date.now = function now() {
      return new Date().getTime();
    };
  }

  var defaults = function(o, d) {
      for(var prop in d) {
        if(!o.hasOwnProperty(prop)) {
          o[prop] = d[prop];
        }
      }
    },
    contains = function(array, value) {
      if(Array.prototype.indexOf) {
        return array.indexOf(value) != -1;
      } else {
        var i, length = array.length;
        for(i = 0; i < length; i++) {
          if(array[i] === value) {
            return true;
          }
        }
        return false;
      }
    };

  /**
   * Frontend Web Performance Data Gathering
   *
   * @class SurfNPerf
   */
  var SurfNPerf = function() {
      this._data = {
        custom: {},
        marks: {},
        highResMarks: {},
        events: {}
      };

      this._navigationTiming = null;
      this._highResTime = null;
      this._userTiming = null;

      this._navigationTimingEvents = {
        a: ["navigationStart", "unloadEventEnd", "unloadEventStart", "redirectStart", "redirectEnd", "fetchStart", "domainLookupStart", "domainLookupEnd", "connectStart", "secureConnectionStart", "connectEnd", "requestStart", "responseStart", "responseEnd", "domLoading"],
        b: ["domInteractive", "domContentLoadedEventStart", "domContentLoadedEventEnd", "domComplete", "loadEventStart", "loadEventEnd"]
      };

      this.initialize();
    },
    SNPProto = SurfNPerf.prototype;

  SNPProto._setPerformanceApis = function() {
    if(window.performance) {
      this._navigationTiming = !!(window.performance.timing);
      this._highResTime = !!(window.performance.now);
      this._userTiming = !!(window.performance.mark && window.performance.measure && window.performance.getEntriesByName);
    } else {
      this._navigationTiming = false;
      this._highResTime = false;
      this._userTiming = false;
    }
  };

  SNPProto._setPerfProperties = function() {
    if(!window.SURF_N_PERF || !window.SURF_N_PERF.marks) {
      window.SURF_N_PERF = {
        marks: {},
        highResMarks: {}
      };
    }
  };

  SNPProto._setInitialUrl = function() {
    this.setCustom('initialUrl', window.location.pathname);
  };

  SNPProto.initialize = function() {
    this._setPerformanceApis();
    this._setPerfProperties();
    this._setInitialUrl();
  };

  /**
   * Returns the timing data for a particular eventKey
   *
   * @param {String} timeType 'highRes' (to return a DOMHighResTimeStamp, if available) or 'DOM' (to return a DOMTimeStamp's value) - optional. Defaults to 'highRes'
   * @returns {DOMHighResTimeStamp | integer} time value
   * @memberOf SurfNPerf
   */
  SNPProto.now = function(timeType) {
    timeType = timeType || 'highRes';
    if(this._highResTime && timeType === 'highRes') {
      return window.performance.now();
    } else {
      return Date.now();
    }
  };

  SNPProto.performanceTiming = function() {
    return this._navigationTiming ? window.performance.timing : {};
  };

  SNPProto._performanceTimingL2 = function(eventKey) {
    var delta = this.getTimingMark('loadEventEnd', 'DOM') - this.getTimingMark(eventKey, 'DOM'),
      value = window.SURF_N_PERF.highResMarks.loadEventEnd - delta;
    return (value < 0) ? 0 : this._round(value, {
      decimalPlaces: 10
    });
  };

  /**
   * Returns the timing data for a particular eventKey
   *
   * @param {String} eventKey name of the timing event
   * @param {String} timeType 'highRes' (to return a DOMHighResTimeStamp, if available) or 'DOM' (to return a DOMTimeStamp's value) - optional. Defaults to 'DOM'
   * @returns {DOMHighResTimeStamp | integer} time value
   * @memberOf SurfNPerf
   */
  SNPProto.getTimingMark = function(eventKey, timeType) {
    timeType = timeType || 'DOM';

    if(this._navigationTiming) {
      if(timeType === 'DOM' || this._highResTime === false) {
        return this.performanceTiming()[eventKey];
      } else { // timeType === 'HighRes'
        return this._performanceTimingL2(eventKey);
      }
    } else {
      if(contains(this._navigationTimingEvents.a, eventKey)) {
        return this.getMark('pageStart', 'DOM');
      } else {
        return this.getMark('loadEventEnd', 'DOM');
      }
    }
  };

  SNPProto.userTiming = function() {
    return this._userTiming ? window.performance : {};
  };

  SNPProto.mark = function(eventKey) {
    if(this._highResTime) {
      this._data.highResMarks[eventKey] = this.now();
    }
    if(this._userTiming) {
      this.userTiming().mark(eventKey);
    }
    this._data.marks[eventKey] = this.now('DOM');
  };

  SNPProto.getMark = function(eventKey, timeType) {
    var mark;

    timeType = timeType || 'highRes';

    if(timeType === 'highRes' && this._highResTime === true) {
      mark = this._data.highResMarks[eventKey] || window.SURF_N_PERF.highResMarks[eventKey];
    }
    return mark || this._data.marks[eventKey] || window.SURF_N_PERF.marks[eventKey];
  };

  /**
   * Removes all user-set marks (or just the specified one) and their associated time values. Will not remove the loadEventEnd mark.
   *
   * @param {String} [eventKey] removes all time values for the given eventKey
   * @memberOf SurfNPerf
   */
  SNPProto.clearMarks = function(eventKey) {
    var loadEventEndHighRes = window.SURF_N_PERF.highResMarks.loadEventEnd,
      loadEventEnd = window.SURF_N_PERF.marks.loadEventEnd;

    if(this._userTiming) {
      this.userTiming().clearMarks(eventKey);
    }
    if(eventKey) {
      if(eventKey !== 'loadEventEnd') {
        delete this._data.highResMarks[eventKey];
        delete this._data.marks[eventKey];
        delete window.SURF_N_PERF.highResMarks[eventKey];
        delete window.SURF_N_PERF.marks[eventKey];
      }
    } else {
      this._data.highResMarks = {};
      this._data.marks = {};
      if(loadEventEndHighRes) {
        window.SURF_N_PERF.highResMarks = {
          loadEventEnd: loadEventEndHighRes
        };
      } else {
        window.SURF_N_PERF.highResMarks = {};
      }
      if(loadEventEnd) {
        window.SURF_N_PERF.marks = {
          loadEventEnd: loadEventEnd
        };
      } else {
        window.SURF_N_PERF.marks = {};
      }
    }
  };

  SNPProto._isTimingMark = function(eventKey) {
    return contains(this._navigationTimingEvents.a.concat(this._navigationTimingEvents.b), eventKey);
  };

  SNPProto._getDurationMark = function(eventKey) {
    if(this._isTimingMark(eventKey)) {
      return this.getTimingMark(eventKey, 'highRes');
    } else {
      return this.getMark(eventKey);
    }
  };

  SNPProto._round = function(n, options) {
    options = options || {};
    var dp = options.decimalPlaces || 0;
    n = +(n);
    return n.toFixed ? +(n).toFixed(dp) : n;
  };

  SNPProto._roundedDuration = function(a, b, options) {
    return this._round(b - a, options);
  };

  SNPProto._measureName = function(startMark, endMark) {
    return '_SNP_' + startMark + '_TO_' + endMark;
  };

  SNPProto._setMeasure = function(startMark, endMark) {
    try {
      this.userTiming().measure(this._measureName(startMark, endMark), startMark, endMark);
    } catch (e) {
      if(window.console && window.console.error) {
        if(e && e.message) {
          console.error("Surf-N-Perf Exception:", e.message);
        } else {
          console.error("Surf-N-Perf Exception: at least one of these events/marks is not available yet", startMark, endMark);
        }
      }
    }
  };

  SNPProto._getMeasureDuration = function(startMark, endMark) {
    var measure = this.userTiming().getEntriesByName(this._measureName(startMark, endMark))[0] || {};
    return measure.duration;
  };

  /**
   * Marks the current time
   *
   * @returns {String} name generated for the mark
   * @memberOf SurfNPerf
   */
  SNPProto.markNow = function() {
    var eventKey = '_NOW_' + this.now('DOM');
    this.mark(eventKey);
    return eventKey;
  };

  /**
   * Returns the duration between two marks
   *
   * @param {String} startMark name of the first mark
   * @param {String} [endMark] optional name of the second mark
   * @returns {Number} duration between the two specified marks. If the endMark is not specified, returns the duration between the startMark and the current time
   * @memberOf SurfNPerf
   */
  SNPProto.duration = function(startMark, endMark, options) {
    endMark = endMark || this.markNow();

    if(this._userTiming) {
      this._setMeasure(startMark, endMark);
      return this._round(this._getMeasureDuration(startMark, endMark), options);
    } else if(this._highResTime && startMark === 'navigationStart' && !this._isTimingMark(endMark)) {
      return this._round(this.getMark(endMark), options);
    } else {
      return this._roundedDuration(this._getDurationMark(startMark), this._getDurationMark(endMark), options);
    }
  };

  SNPProto.updateEvent = function(eventKey, key, value) {
    var obj = {};
    obj[eventKey] = {};

    defaults(this._data.events, obj);

    this._data.events[eventKey][key] = value;
  };

  SNPProto.resetEvent = function(eventKey, key, value) {
    this._data.events[eventKey] = {};
    this._data.events[eventKey][key] = value;
  };

  SNPProto.eventStart = function(eventKey) {
    this.resetEvent(eventKey, 'start', this.now());
  };

  SNPProto.eventEnd = function(eventKey, options) {
    var now = this.now(),
      key;

    options = options || {};

    for(key in options) {
      this.updateEvent(eventKey, key, options[key]);
    }

    this.updateEvent(eventKey, 'end', now);
  };

  SNPProto.getEventData = function(eventKey, key) {
    var eventObj = this._data.events[eventKey];
    if(eventObj) {
      return eventObj[key];
    }
  };

  SNPProto.eventDuration = function(eventKey, options) {
    return this._roundedDuration(this.getEventData(eventKey, 'start'), this.getEventData(eventKey, 'end'), options);
  };

  SNPProto.setCustom = function(key, value) {
    this._data.custom[key] = value;
  };

  SNPProto.getCustom = function(key) {
    return this._data.custom[key];
  };

  /**
   * Total time for App Cache, DNS & TCP
   *
   * @returns {integer} time in ms
   * @memberOf SurfNPerf
   */
  SNPProto.getNetworkTime = function() {
    return this.duration('fetchStart', 'connectEnd');
  };

  /**
   * Total time for Request & Response
   *
   * @returns {integer} time in ms
   * @memberOf SurfNPerf
   */
  SNPProto.getServerTime = function() {
    return this.duration('requestStart', 'responseEnd');
  };

  /**
   * Total time for App Cache, DNS, TCP, Request & Response
   *
   * @returns {integer} time in ms
   * @memberOf SurfNPerf
   */
  SNPProto.getNetworkLatency = function() {
    return this.duration('fetchStart', 'responseEnd');
  };

  /**
   * Total time to process the Response & fire the onLoad event
   *
   * @returns {integer} time in ms
   * @memberOf SurfNPerf
   */
  SNPProto.getProcessingLoadTime = function() {
    return this.duration('responseEnd', 'loadEventEnd');
  };

  /**
   * Total time for a page to load from the time the user initiates the access of the page to the firing of the onLoad event
   *
   * @returns {integer} time in ms
   * @memberOf SurfNPerf
   */
  SNPProto.getFullRequestLoadTime = function() {
    return this.duration('navigationStart', 'loadEventEnd');
  };

  /**
   * Time to first paint (browser-reported time for the first pixels to be rendered on the page)
   *
   * @returns {integer} time in ms
   * @memberOf SurfNPerf
   */
  SNPProto.getFirstPaint = function(options) {
    if(this._supportsPaintAPI()) {
      var firstPaint = window.performance.getEntriesByName('first-paint')[0] || {};
      return this._roundedDuration(this.getTimingMark('navigationStart', 'DOM'), firstPaint.startTime, options);
    } else if(this.msFirstPaint()) {
      return this._roundedDuration(this.getTimingMark('navigationStart', 'DOM'), this.msFirstPaint(), options);
    } else {
      return null;
    }
  };

  /**
   * Time to first contentful paint (browser-reported time for the render of any content from the DOM)
   *
   * @returns {integer} time in ms
   * @memberOf SurfNPerf
   */
  SNPProto.getFirstContentfulPaint = function(options) {
    if(this._supportsPaintAPI()) {
      var firstPaint = window.performance.getEntriesByName('first-contentful-paint')[0] || {};
      return this._roundedDuration(this.getTimingMark('navigationStart', 'DOM'), firstPaint.startTime, options);
    } else if(this.msFirstPaint()) {
      return this._roundedDuration(this.getTimingMark('navigationStart', 'DOM'), this.msFirstPaint(), options);
    } else {
      return null;
    }
  };

  SNPProto._supportsRequestAnimationFrame = function() {
    return !!window.requestAnimationFrame;
  };

  /**
   * Time to first paint (Time calculated based on window.requestAnimationFrame() for the first pixels to be rendered on the page)
   *
   * @returns {integer} time in ms
   * @memberOf SurfNPerf
   */
  SNPProto.getFirstPaintFrame = function(options) {
    if(this._supportsRequestAnimationFrame()) {
      return this.duration('navigationStart', 'firstPaintFrame', options);
    } else {
      return null;
    }
  };

  SNPProto._supportsPaintAPI = function() {
    return window.performance && window.performance.getEntriesByName && 'PerformancePaintTiming' in window;
  };

  SNPProto.msFirstPaint = function() {
    if(window.performance && window.performance.timing && window.performance.timing.msFirstPaint) {
      return window.performance.timing.msFirstPaint;
    } else {
      return undefined;
    }
  };

  /**
   * Is the page hidden or not, as reported by the Page Visibility API
   *
   * @returns {boolean} true if the page is in a state considered to be hidden to the user, and false otherwise.
   * @memberOf SurfNPerf
   */
  SNPProto.isHidden = function() {
    if(SURF_N_PERF.visibility && SURF_N_PERF.visibility.hiddenProperty) {
      return document[SURF_N_PERF.visibility.hiddenProperty];
    } else {
      return null;
    }
  };

  /**
   * Total time a page is not visible, as reported by the Page Visibility API
   *
   * @returns {integer} time in ms
   * @memberOf SurfNPerf
   */
  SNPProto.getHiddenTime = function(options) {
    var total = 0,
      states = [],
      i,
      length,
      getMarkName = function(index) {
        return index === 0 ? 'navigationStart' : 'visibility' + (index - 1);
      };

    if(SURF_N_PERF.visibility && SURF_N_PERF.visibility.initialState && SURF_N_PERF.visibility.stateUpdates) {
      states = states.concat(SURF_N_PERF.visibility.initialState, SURF_N_PERF.visibility.stateUpdates);
      length = states.length;
      for(i = 1; i < length; i++) {
        if(states[i - 1] !== 'visible' && states[i] === 'visible') {
          total += this.duration(getMarkName(i - 1), getMarkName(i), {
            decimalPlaces: 10
          });
        }
      }
      if(states[length - 1] !== 'visible') {
        total += this.duration(getMarkName(length - 1), null, {
          decimalPlaces: 10
        });
      }
      return this._round(total, options);
    } else {
      return null;
    }
  };

  return new SurfNPerf();

}));