Source js/bauplan.tracker.js

define(function(require) {
/**
 * @module bauplan%tracker
 * @description ## Track an event
 * - optionally time how long it took
 * - optionally pass further parameters
 *
 * To load
 *
 *     var Tracker = require("bauplan.tracker");
 *
 * or as part of the Bauplan bundle
 *
 *     var Bauplan = require("bauplan");
 *     var Tracker = Bauplan.Tracker;
 *
 * To track an event named "foo"
 *
 *     Tracker.track("foo");
 *
 * To time an event, call the start method
 *
 *     Tracker.start("foo");
 *
 * If the timer is stil running when track is called, it will be stopped automatically
 *
 * The timer can be stopped manually (but usually stopping automatically should suffice)
 *
 *     Tracker.stop("foo");
 *
 * Three convenience methods exist:
 *
 *     Tracker.completed("foo");
 *     Tracker.aborted("foo");
 *     Tracker.unload("foo");
 *
 * These all call the track method, setting the result parameter to be the name of the method called
 *
 * eg. Tracker.completed => {result: "completed"}
 *
 * All the above methods can be passed an object of optional parameters which will be sent as a JSONified string
 *
 *     Tracker.track("foo", {bar: "baz"});
 *
 * Parameters can be stored at any time before track is called (even before start is called)
 *
 *     Tracker.store("foo", {bar:"baz"});
 *
 * Events can have subevents
 *
 *     Tracker.startSubEvent("foo", "foosub");
 *     Tracker.stopSubEvent("foo", "foosub");
 *
 * Unstopped subevents will be stopped automatically when the stop or track methods are called
 *
 * A key-value pair of the the subevents name and the time elapsed are sent as part of the parameters
 *
 * If the subevent is called more than once during the event, the times are concatenated
 *
 * Additionally, a key-value pair of the order the subevents were triggered in is also sent
 *
 * ### Automatic tracking of events
 * 
 * The following events are tracked:
 * - start of visit
 * - end of visit
 * - all blurs and refocuses of the app window
 * - all clicks
 * - mouseovers on links, inputs of type submit and buttons
 * - unload
 *
 * ### Automatic tracking of untrapped errors
 *
 * Any untrapped errors will be tracked.
 *
 * NB. this cannot track errors that occur before bauplan.tracker is loaded.
 *
 * @return {instance} Tracker
 */
    var _ = require("lodash");
    var jQuery = require("jquery");
    var Backbone = require("backbone");
    var Analytics = require("bauplan.analytics");

    var baseurl = "/api/track";

    /**
     * @access private
     * @function dispatch
     *
     * @description Disptaches Ajax call with sensible defaults
     *
     * @param {object} options
     * @param {string} options.url
     * @param {object|string} [options.data]
     * @param {string} [options.contentType=application/json; charset=utf-8]
     * @param {string} [options.type=PUT]
     * @param {boolean} [options.async=true]
     * @param {function} [options.error]
     */
    var dispatch = function (options) {
        options = _.extend({
            type: "PUT",
            contentType: "application/json; charset=utf-8",
            error:function(){}
        }, options);
        options.url = baseurl + "/" + options.url;
        if (options.contentType.match(/application\/json/)) {
            try {
                options.data = JSON.stringify(options.data || {});
            } catch(e) {
                options.contentType = "text/plain; charset=utf-8";
            }
        }
        if (options.async === undefined) {
            options.async = true;
        }
        Backbone.ajax(options);
    };

    /**
     * @access private
     * @var {object} tracking
     * @property {object} timer Object to store event timers
     * @property {string} data Object to store event data
     */
    var tracking = {
        timer: {},
        data: {}
    };

/* the main function */
    /** @constructor */
    var Tracker = Backbone.Model.extend({
        timefactor: 1000,
        adjustTime: function adjustTime (t) {
            if (this.timefactor) {
                t /= this.timefactor;
            }
            return t;
        },

        /**
         * Send ui event with any parameters passed or previously stored
         *
         * Request is sent to {apiRoot}{baseurl}/{name}
         * Data is an object of key-value pairs serialised as JSON
         *
         * @param {string} name Name of the event for tracking service to use as key
         * @param {object} [params] Parameters to be passed to tracking service
         * @param {boolean} [async=true] Whether tracking call should be asynchronous
         * @instance
         * @memberOf module:bauplan%tracker
         */
        track: function track (name, params, async) {
            if (typeof params === "string") {
                params = {
                    "value": params
                };
            }
            // stop event timer if running
            this.stop(name, params);
            this.store(name, {"%device": this.useragent()});
            var data = this.get(name);

            if (window.less && window.less.env === "development") {
                if (name === "error" || window.tracking !== false) {
                    console.log("TRACK", name, data);
                }
            } else {
                dispatch({url: "client/" + name, data: data, async: async});
            }
        },

        /**
         * Send completed ui event along any parameters passed or previously stored
         *
         * @param {string} name Name of the event for tracking service to use as key
         * @param {object} [params] Parameters to be passed to tracking service
         * @instance
         * @memberOf module:bauplan%tracker
         */
        completed : function trackCompleted (name, params) {
            this.track(name, _.extend(params || {}, {result:"completed"}));
        },

        /**
         * Send aborted ui event along any parameters passed or previously stored
         *
         * @param {string} name Name of the event for tracking service to use as key
         * @param {object} [params] Parameters to be passed to tracking service
         * @instance
         * @memberOf module:bauplan%tracker
         */
        aborted : function trackAborted (name, params) {
            this.track(name, _.extend(params || {}, {result:"aborted"}));
        },

        /**
         * Send ui event interrupted by unload event along any parameters passed or previously stored
         *
         * NB. because of unload event, such events are sent synchronously
         *
         * @param {string} [name] Name of the event for tracking service to use as key
         * @param {object} [params] Parameters to be passed to tracking service
         *
         * If called without name, any running events are stopped and any stored data
         * is sent to the tracking endpoint along with a generic unload tracking event
         * @instance
         * @memberOf module:bauplan%tracker
         */
        unload : function trackUnload (name, params) {
            var url = document.location.pathname;
            if (!name) {
                var pages = this.bauplan.Router.getUrlList();
                this.store("visit", {
                    pages: pages,
                    pagecount: pages.length
                });
                if (pages.length < 2) {
                    this.store("visit", {
                        bounce: true
                    });
                }
                for (var tkey in tracking.timer) {
                    if (tkey.indexOf(":") === -1) {
                        this.unload(tkey);
                    }
                }
                for (var dkey in tracking.data) {
                    if (dkey.indexOf(":") === -1) {
                        this.unload(dkey);
                    }
                }
                this.track("unload", {url:url}, false);
            } else {
                this.track(name, _.extend(params || {}, {result:"unload", url:url}), false);
            }
        },

        /**
         * Store parameters for an event
         *
         * @param {string} name Name of the event to start timer for
         * @param {object} [params] Parameters to be passed to tracking service
         * @instance
         * @memberOf module:bauplan%tracker
         */
        store: function trackStore (name, params) {
            if (tracking.data[name]) {
                params = _.extend({}, tracking.data[name], params);
            }
            tracking.data[name] = params;
        },

        /**
         * Get parameters for an event
         *
         * @param {string} name Name of the event to start timer for
         * @param {boolean} [clear] Whether to clear the stored parameters
         */
        get: function trackGet (name, clear) {
            params = _.extend({}, tracking.data[name]);
            delete tracking.data[name];
            return params;
        },

        /**
         * Start timer for an event
         *
         * @param {string} name Name of the event to start timer for
         * @param {object} [params] Parameters to be passed to tracking service
         * @param {boolean} [reportStarted=false] Whether to report event has started to tracking service
         * @instance
         * @memberOf module:bauplan%tracker
         */
        start: function trackStart (name, params, reportStarted) {
            if (!tracking.timer[name]) {
                tracking.timer[name] = (new Date()).getTime();
            }
            if (params) {
                this.store(name, params);
            }
            if (reportStarted) {
                this.track(name + ".started");
            }

        },

        /**
         * Stop timer for an event
         *
         * @param {string} name Name of the event to stop timer for
         * @param {object} [params] Parameters to be passed to tracking service
         * @instance
         * @memberOf module:bauplan%tracker
         */
        stop: function trackStop (name, params) {
            // stop any subevent currently running and note that it was incomplete
            var currentSubEvent = tracking.data[name+":currentSubEvent"];
            if (currentSubEvent) {
                this.stopSubEvent(name, currentSubEvent);
                this.store(name, {incomplete_subevent: currentSubEvent});
                delete tracking.data[name+":currentSubEvent"];
            }
            // add any subevents to stored parameters
            var subevents = tracking.data[name+":subevents"];
            if (subevents) {
                this.store(name, {subevents: subevents});
                delete tracking.data[name+":subevents"];
            }
            // if timer running for event, work out how long has elapsed
            var started = tracking.timer[name];
            if (started) {
                var stopped = (new Date()).getTime();
                var elapsed = this.adjustTime(stopped - started);
                this.store(name, {time: elapsed});
            }
            delete tracking.timer[name];
            if (params) {
                this.store(name, params);
            }
        },

        /**
         * Start timer for a subevent to be tracked
         *
         * @param {string} eventName Name of the event subevent belongs to
         * @param {string} subEventName Name of the event to start timer for
         * @instance
         * @memberOf module:bauplan%tracker
         */
        startSubEvent: function trackStartSubEvent (eventName, subEventName) {
            tracking.timer[eventName+":"+subEventName] = (new Date()).getTime();
            //NB. as we’re just storing a string, we can currently only have one subevent running
            tracking.data[eventName+":currentSubEvent"] = subEventName;
            var subeventsKey = eventName+":subevents";
            var subevents = tracking.data[subeventsKey] || "";
            tracking.data[subeventsKey] = (subevents ? subevents + "." : "" ) + subEventName;
        },

        /**
         * Stop timer for a subevent
         *
         * @param {string} eventName Name of the event subevent belongs to
         * @param {string} subEventName Name of the event to start timer for
         * @instance
         * @memberOf module:bauplan%tracker
         */
        stopSubEvent: function trackStopSubEvent (eventName, subEventName) {
            // if timer running for subevent, work out how long has elapsed
            var subEventKey = eventName+":"+subEventName;
            var started = tracking.timer[subEventKey];
            if (started) {
                var stopped = (new Date()).getTime();
                var elapsed = this.adjustTime(stopped - started);
                // If subevent is measured more than once, each instance is added 
                // using . as delimiter
                if (tracking.data[eventName] && tracking.data[eventName][subEventName]) {
                    elapsed = tracking.data[eventName][subEventName] + "." + elapsed;
                }
                var subEventParams = {};
                subEventParams[subEventName] = elapsed;
                this.store(eventName, subEventParams);
            }
            delete tracking.timer[subEventKey];
            delete tracking.data[eventName+":currentSubEvent"];
        },


        /**
         * Return details of useragent (viewport dimensions, scroll position, screen, agent, platform)
         *
         * @return object
         */
        useragent: function trackUserAgent () {
            var clientParams = {};
            var docEl = document.documentElement;
            clientParams.viewport = {
                w: docEl.clientWidth,
                h: docEl.clientHeight
            };
            clientParams.scroll = {
                x: docEl.scrollLeft,
                y: docEl.scrollTop
            };
            clientParams.screen = {
                w: (screen.availWidth || screen.width),
                h:(screen.availHeight || screen.height)
            };
            clientParams.agent = navigator.userAgent;
            clientParams.platform = navigator.platform;

            return clientParams;
        },



        /**
         * Send error ui event
         *
         * @param {object} error Error object
         * @instance
         * @memberOf module:bauplan%tracker
         */
        error: function trackError (error) {
            error.url = document.location.pathname;
            error.stack = error.stack;
            this.track("error", { "%error": error });
        },

        /**
         * Send page not found ui event
         *
         * @param {string} [url] The URL that does not exist 
         * @param {string} [referrer] The referrer URL
         * @instance
         * @memberOf module:bauplan%tracker
         */
        notfound: function trackNotFound (url, referrer) {
            if (url.indexOf("/") === -1) {
                url = "/" + url;
            }
            referrer = referrer || window.document.referrer;
            this.track("notfound", {
                url: url,
                referrer: referrer
            });
        },

        /**
         * Send pageview ui event
         *
         * @param {string} [url] URL - if not provided uses the current URL
         * @instance
         * @memberOf module:bauplan%tracker
         */
        pageview: function trackPageView (url) {
            if (!url) {
                url = window.document.location.pathname;
            }
            if (url.indexOf("/") === -1) {
                url = "/" + url;
            }
            if (url === this.currentPageView) {
                // no double-counting
                return;
            }
            this.currentPageView = url;
            Analytics.pageview(url);
            this.track("pageview", { url: url });
        },


        /**
         * Send transaction ui event
         *
         * @param {object} t Transaction details
         *
         * This calls {@link module:bauplan%analytics#transaction}
         * @instance
         * @memberOf module:bauplan%tracker
         */
        transaction: function trackTransaction (t) {
            Analytics.transaction(t);
        },



        /**
         * Send view ui event
         *
         * @param {string} view View name
         * @param {boolean} [append] Alternative tracker URL
         * @instance
         * @memberOf module:bauplan%tracker
         */
        view: function trackView (view, append) {
            var url;
            try {
                url = this.bauplan.Router.reverse(view);
            } catch (e) {
                url = "/track/view/" + view;
            }
            if (append) {
                url += "/" + append;
            }
            this.pageview(url);
        },

        /*!
         * Send click ui event
         *
         * NB. all clicks are registered automatically (or would be if the last line wasn't  commented out)
         *
         * @param {string} node DOM node activated
         * @param {event} e DOM event
         * @instance
         * @memberOf module:bauplan%tracker
         */
        click: function trackClick(node, e) {
            var clickParams = {};
            var nodeTag = node.tagName.toLowerCase();
            if (nodeTag.match(/^(a|button)$/) || jQuery(node).closest("a, button").length) {
                var text = jQuery.trim(jQuery(node).text()).replace(/\s+/g, " ");
                clickParams.text = text;
            }
            var path = jQuery(node).attr("data-csspath") || createSelectorPath(node);
            clickParams.path = path;
            if (nodeTag === "object") {
                if (e) {
                    var objNode = jQuery(node);
                    var objOffset = objNode.offset();
                    clickParams.object = {
                        w: objNode.width(),
                        h: objNode.height(),
                        x: e.pageX - objOffset.left,
                        y: e.pageY - objOffset.top
                    };
                }
            }
            clickParams.body = getNodeSelector(document.getElementsByTagName("body")[0]);
            clickParams.html = getNodeSelector(document.getElementsByTagName("html")[0]);
            if (e) {
                clickParams.coords = {
                    x: e.pageX,
                    y: e.pageY
                };
            }
            //this.track("click", {"%click": clickParams});
        }

});

    /**
     * @function getNodeSelector
     * @access private
     * @description  Get string representing node's CSS selector
     * @param {domnode} node DOM node
     */
    function getNodeSelector (node) {
        var nodeTag = node.tagName.toLowerCase();
        var nodeClass = node.className ? ("." + node.className.replace(/\s+/g, ".")).replace(/\.{2,}/, ".") : "";
        var nodeId = node.id ? "#" + node.id : "";
        var nodeSelector = nodeTag + nodeId + nodeClass;
        return nodeSelector;
    }

    /**
     * @function createSelectorPath
     * @access private
     * @description  Get string representing node's CSS selector path
     *
     * @param {domnode} node DOM node
     */
    function createSelectorPath (node) {
        var path = "";
        var body = document.getElementsByTagName("body")[0];
        while (node.parentNode) {
            if (node === body) {
                node = null;
                break;
            }
            var nodeSelector = getNodeSelector(node);
            if (path) {
                path = " > " + path;
            }
            path = nodeSelector + path;
            node = node.parentNode;
        }
        return path;
    }

    // Create tracker singleton
    var trackerInstance = new Tracker();

    // Attempt to catch unhandled errors and exceptions
    /*(function(win){
        var callback = null, handler = win.onerror;

        win.tryCatch = function (tryFn, catchFn) {
            callback = catchFn;
            tryFn();
            callback = null;
        };

        win.onerror = function (msg, file, line) {
            var error = new Error(), suppress;
            error.message = msg;
            error.fileName = file;
            error.lineNumber = line;
            if (callback) {
                suppress = callback(error);
                callback = null;
                return suppress === false ? false : true;
            }
            trackerInstance.error(error);
            return handler ? handler.apply(win, arguments) : true;
        };

    })(window);*/

    // Register events to track automagickally
    jQuery(document).on("mouseover", "a, input[type=submit], button", function () {
        var node = this;
        if (!jQuery(node).attr("data-csspath")) {
            jQuery(node).attr("data-csspath", createSelectorPath(node));
        }
    });
    jQuery(document).on("click", "*", function (e){
        if (this === e.target) {
            trackerInstance.click(this, e);
        }
    });
    jQuery(window).on("beforeunload", function () {
        trackerInstance.unload();
        //return false;
    });

    // Treat blur and refocus events as end and start of subvisits
    var subvisitcount = 0;
    function startVisitSubEvent () {
        subvisitcount++;
        var sub = "visit-"+subvisitcount;
        trackerInstance.startSubEvent("visit", sub);
        trackerInstance.start("subvisit");
    }
    function stopVisitSubEvent () {
        var sub = "visit-"+subvisitcount;
        trackerInstance.stopSubEvent("visit", sub);
        trackerInstance.track("subvisit");
    }
    jQuery(window).on("focus", function () {
        startVisitSubEvent();
    });
    jQuery(window).on("blur", function () {
        stopVisitSubEvent();
    });

    // Track this visit
    trackerInstance.start("visit");
    startVisitSubEvent();


    return trackerInstance;

});