API Docs for: 0.8.0
Show:

File: include/system/analytics_manager.js

/*
 Copyright (C) 2016  PencilBlue, LLC

 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by
 the Free Software Foundation, either version 3 of the License, or
 (at your option) any later version.

 This program is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 GNU General Public License for more details.

 You should have received a copy of the GNU General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

//dependencies
var async  = require('async');
var domain = require('domain');
var util   = require('../util.js');

module.exports = function AnalyticsManagerModule(pb) {

    //pb dependencies
    var ValidationService = pb.ValidationService;

    /**
     * Provides functionality to render HTML snippets to report metrics back to
     * registered analytics providers.
     * @class AnalyticsManager
     * @constructor
     * @param {Object} options
     * @param {String} [options.site='global']
     * @param {Integer} [options.timeout=25] The amount of time, in
     * milliseconds, that the manager will wait for each provider to render.
     */
    function AnalyticsManager(options) {

        /**
         * The site context for the instance
         * @property site
         * @type {String}
         */
        this.site = options.site || pb.SiteService.GLOBAL_SITE;

        /**
         * The amount of time, in milliseconds, that the manager will wait for
         * each provider to render.
         * @property timeout
         * @type {Integer}
         */
        this.timeout = options.timeout || DEFAULT_TIMEOUT;
    }

    /**
     * Stores the registered analytics providers
     * @private
     * @static
     * @readonly
     * @property PROVIDER_HOOKS
     * @type {Object}
     */
    var PROVIDER_HOOKS = {};

    /**
     * Stores the registered runtime metrics for the providers
     * @private
     * @static
     * @readonly
     * @property STATS
     * @type {Object}
     */
    var STATS = {
        cumulative: {
            count: 0,
            average: 0,
            min: Number.MAX_VALUE,
            max: 0
        },
        providers: {}
    };

    /**
     * The default amount of time, in milliseconds that the manager will wait
     * for each provider to render.
     * @private
     * @static
     * @readonly
     * @property DEFAULT_TIMEOUT
     * @type {Integer}
     */
    var DEFAULT_TIMEOUT = 25;

    /**
     * The value provided as the result of the rendering when there is nothing
     * to perform for a task
     * @private
     * @static
     * @readonly
     * @property DEFAULT_RESULT
     * @type {String}
     */
    var DEFAULT_RESULT = '';

    /**
     * Takes the provided request and session then checks with each of the
     * registered analytics providers to ensure get the HTML snippets to be
     * executed for analytics reporting.
     * @method gatherData
     * @param {Request} req The current incoming request
     * @param {Object} session The current user session
     * @param {Localization} ls An instance of the Localization service
     * @param {Function} cb A callback that provides two parameters.  An error, if
     * occurred, and a TemplateValue representing the HTML snippets for the analytic
     * providers.
     */
    AnalyticsManager.prototype.gatherData = function(req, session, ls, cb) {

        //retrieve keys and ensure there are providers to process
        var providerKeys = AnalyticsManager.getKeys(this.site);
        if (providerKeys.length === 0) {
            return cb(null, DEFAULT_RESULT);
        }

        //build the task list
        var self = this;
        var tasks = util.getTasks(providerKeys, function(keys, i) {
            return self.buildRenderTask(keys[i], req, session, ls);
        });
        async.parallel(tasks, function(err, results) {
            cb(err, new pb.TemplateValue(results.join(''), false));
        });
    };

    /**
     *
     * @static
     * @method buildRenderTask
     * @param {String} key
     * @param {Request} req
     * @param {Object} session
     * @param {Localization} ls
     * @return {Function}
     */
    AnalyticsManager.prototype.buildRenderTask = function(key, req, session, ls) {
        var site = this.site;
        var timeout = this.timeout;

        return function(callback) {
            if (pb.log.isSilly()) {
                pb.log.silly("AnalyticsManager: Rendering provider [%s] for URL [%s:%s]", key, req.method, req.url);
            }

            //build context object for builder functions to have access to params
            var opts = {
                method: req.method,
                url: req.url,
                key: key,
                callback: callback,
                th: null,
                start: null
            };

            //create a domain for the task to operate within
            var d = domain.create();
            d.run(function() {

                //set the execution timeout.  We set it here and not outside of the
                //domain to ensure that it gets a fair shot at running for the entire
                //allotted timeout.  Setting it outside makes it subject to other lines
                //of execution already in queue that will burn through the timeout
                //before execution of this domain begins.
                opts.start = new Date().getTime();
                opts.th = setTimeout(AnalyticsManager.buildTimeoutHandler(opts), timeout);

                var provider = PROVIDER_HOOKS[pb.SiteService.GLOBAL_SITE] || [];
                if (PROVIDER_HOOKS[site] && PROVIDER_HOOKS[site][key]) {
                    provider = PROVIDER_HOOKS[site];
                }
                provider[key](req, session, ls, AnalyticsManager.buildProviderResponseHandler(opts));
            });
            d.once('error', AnalyticsManager.buildDomainErrorHandler(opts));
        };
    };

    /**
     *
     * @static
     * @method buildProviderResponseHandler
     * @param {Object} opts
     * @param {String} opts.key
     * @param {String} opts.method
     * @param {String} opts.url
     * @param {Integer} opts.start The epoch when the provider started execution
     * @param {Object} opts.th The timeout handle for the provider execution
     * @param {Function} opts.callback
     * @return {Function}
     */
    AnalyticsManager.buildProviderResponseHandler = function(opts) {
        return function(err, result) {
            if (util.isError(err)) {
                pb.log.error("AnalyticsManager: Rendering provider [%s] failed for URL [%s:%s]\n%s", opts.key, opts.method, opts.url, err.stack);
            }

            //collect runtime metrics
            AnalyticsManager.updateStats(opts.start, opts.key);

            //we only callback if the timeout handle exists.  When
            //null, it means that the task already timed out and we
            //shouldn't attempt to send back a result.
            if (opts.th !== null) {
                clearTimeout(opts.th);
                opts.th = null;

                //the error is left out on purpose.  It is logged above
                //and we want all other providers to have a chance.
                opts.callback(null, result);
            }
        };
    };

    /**
     *
     * @static
     * @method buildDomainErrorHandler
     * @param {Object} opts
     * @param {String} opts.key
     * @param {String} opts.method
     * @param {String} opts.url
     * @param {Integer} opts.start The epoch when the provider started execution
     * @param {Object} opts.th The timeout handle for the provider execution
     * @param {Function} opts.callback
     * @return {Function}
     */
    AnalyticsManager.buildDomainErrorHandler = function(opts) {
        return function(err) {
            pb.log.error('AnalyticsManager: Rendering provider [%s] failed for URL [%s:%s]\n%s', opts.key, opts.method, opts.url, err.stack);

            //only callback if the timeout handle is still in place
            if (opts.th !== null) {
                clearTimeout(opts.th);
                opts.th = null;

                //collect runtime metrics
                AnalyticsManager.updateStats(opts.start, opts.key);
                opts.callback(null, DEFAULT_RESULT);
            }
        };
    };

    /**
     * Updates the runtime statistics to include
     * @static
     * @method getStats
     * @return {Object}
     */
    AnalyticsManager.updateStats = function(startTime, provider) {
        var runTime = (new Date().getTime()) - startTime;
        updateStatBlock(STATS.cumulative, runTime);
        updateStatBlock(STATS.providers[provider], runTime);
    };

    /**
     * @private
     * @static
     * @method updateStatBlock
     * @param {Integer} runTime The amount of time that the provider ran in
     * milliseconds
     * @param {Object} block The block that contains the stats for the provider
     */
    function updateStatBlock(block, runTime) {
        if (runTime > block.max) {
            block.max = runTime;
        }
        if (runTime < block.min) {
            block.min = runTime;
        }
        block.average = (runTime + (block.count * block.average)) / (++block.count);
    }

    /**
     * Retrieves timing metrics for analytic providers.  Handy for debugging
     * timeouts
     * @static
     * @method getStats
     * @return {Object}
     */
    AnalyticsManager.getStats = function() {
        return util.clone(STATS);
    };

    /**
     * @static
     * @method buildTimeoutHandler
     * @param {Object} opts
     * @return {Function}
     */
    AnalyticsManager.buildTimeoutHandler = function(opts) {
        return function() {
            pb.log.warn("AnalyticsManager: Rendering for provider [%s] timed out", opts.key);

            opts.th = null;
            opts.callback(null, '');
        };
    };

    /**
     * Registers an analytics provider.  When a template is being rendered and
     * encounters the ^analytics^ directive "onPageRender" is called.
     * @static
     * @method registerProvider
     * @param {String} name The provider's name
     * @param {String} [site='global']
     * @param {Function} onPageRendering A function that is called for every
     * requests that intends to execute HTML snippets to track analytics.  The
     * function is expected to take 4 parameters.  The first is the current Request
     * object.  The second is the current user session object. The third is an
     * instance of Localization.  The last is a callback that should be called with
     * two parameters.  The first is an error, if occurred and the second is raw
     * HTML string that represents the snippet to be executed by the analytics
     * plugin.
     */
    AnalyticsManager.registerProvider = function(name, site, onPageRendering) {
        if (util.isFunction(site)) {
            onPageRendering = site;
            site = pb.SiteService.GLOBAL_SITE;
        }

        if (!ValidationService.validateNonEmptyStr(name, true) ||
            !util.isFunction(onPageRendering) || AnalyticsManager.isRegistered(name, site)) {
            return false;
        }
        if (!PROVIDER_HOOKS[site]) {
            PROVIDER_HOOKS[site] = {};
        }

        PROVIDER_HOOKS[site][name] = onPageRendering;

        //setup metrics
        STATS.providers[name] = {
            count: 0,
            average: 0,
            min: Number.MAX_VALUE,
            max: 0
        };
        return true;
    };

    /**
     * Unregisters an analytics provider
     * @static
     * @method unregisterProvider
     * @param {String} name
     * @param {String} [site='global']
     * @return {Boolean} TRUE if was unregistered, FALSE if not found
     */
    AnalyticsManager.unregisterProvider = function(name, site) {
        if (!util.isString(site)) {
            site = pb.SiteService.GLOBAL_SITE;
        }
        if (!AnalyticsManager.isRegistered(name, site)) {
            return false;
        }
        delete PROVIDER_HOOKS[site][name];
        return true;
    };

    /**
     * Indicates if an analytics provider with the specified name has already
     * registered itself.
     * @static
     * @method isRegistered
     * @param {String} name The name of the analytics provider to check registration for
     * @param {String} [site='global']
     * @return {Boolean} TRUE when the provider is registered, FALSE if not
     */
    AnalyticsManager.isRegistered = function(name, site) {
        if (!util.isString(site)) {
            site = pb.SiteService.GLOBAL_SITE;
        }
        return !util.isNullOrUndefined(PROVIDER_HOOKS[site]) &&
            util.isFunction(PROVIDER_HOOKS[site][name]);
    };

    /**
     * Called when a page is rendering.  It creates a new instance of the analytics
     * manager and constructs the javascript snippets (wrappered in TemplateValue)
     * needed for the analytics plugins
     * @static
     * @method onPageRender
     * @param {Request} req
     * @param {Object} session
     * @param {Localization} ls
     * @param {Function} cb A callback that provides two parameters.  An error, if
     * occurred, and a TemplateValue representing the HTML snippets for the analytic
     * providers.
     */
    AnalyticsManager.onPageRender = function(req, session, ls, cb) {
        var context = {
            site: pb.RequestHandler.sites[req.headers.host] ? pb.RequestHandler.sites[req.headers.host].uid : null,
            timeout: pb.config.analytics.timeout
        };
        var managerInstance = new AnalyticsManager(context);
        managerInstance.gatherData(req, session, ls, cb);
    };

    /**
     * @static
     * @method getKeys
     * @param {String} site
     * @return {Array}
     */
    AnalyticsManager.getKeys = function(site) {
        var keyHash = {};
        var keyFunc = keyHandler(keyHash);

        //check for global keys.  They apply to all sites
        if (PROVIDER_HOOKS[pb.SiteService.GLOBAL_SITE]) {
            (Object.keys(PROVIDER_HOOKS[pb.SiteService.GLOBAL_SITE]) || []).forEach(keyFunc);
        }

        //check for non-global site keys
        if(PROVIDER_HOOKS[site] && site !== pb.SiteService.GLOBAL_SITE) {
            Object.keys(PROVIDER_HOOKS[site]).forEach(keyFunc);
        }
        return Object.keys(keyHash);
    };

    /**
     * @private
     * @static
     * @method keyHandler
     * @param {Object} keyHash
     * @return {Function}
     */
    function keyHandler(keyHash) {
        return function(key) {
            keyHash[key] = true;
        };
    }

    //exports
    return AnalyticsManager;
};