UNPKG

@clearbit-dcp/clearbit.js-core

Version:

The hassle-free way to integrate analytics into any web application.

696 lines (586 loc) 17.4 kB
'use strict'; var _clearbit = global.clearbit; /* * Module dependencies. */ var Alias = require('segmentio-facade').Alias; var Emitter = require('component-emitter'); var Group = require('segmentio-facade').Group; var Identify = require('segmentio-facade').Identify; var Page = require('segmentio-facade').Page; var Track = require('segmentio-facade').Track; var after = require('@ndhoule/after'); var bindAll = require('bind-all'); var clone = require('@ndhoule/clone'); var cookie = require('./cookie'); var debug = require('debug'); var defaults = require('@ndhoule/defaults'); var each = require('@ndhoule/each'); var foldl = require('@ndhoule/foldl'); var group = require('./group'); var is = require('is'); var isMeta = require('@segment/is-meta'); var keys = require('@ndhoule/keys'); var memory = require('./memory'); var nextTick = require('next-tick'); var normalize = require('./normalize'); var on = require('component-event').bind; var pageDefaults = require('./pageDefaults'); var pick = require('@ndhoule/pick'); var prevent = require('@segment/prevent-default'); var querystring = require('component-querystring'); var store = require('./store'); var user = require('./user'); var type = require('component-type'); /** * Initialize a new `Analytics` instance. */ function Analytics() { this._options({}); this.Integrations = {}; this._integrations = {}; this._readied = false; this._timeout = 300; // XXX: BACKWARDS COMPATIBILITY this._user = user; this.log = debug('clearbit.js'); bindAll(this); var self = this; this.on('initialize', function(settings, options) { if (options.initialPageview) self.page(); self._parseQuery(window.location.search); }); } /** * Mix in event emitter. */ Emitter(Analytics.prototype); /** * Use a `plugin`. * * @param {Function} plugin * @return {Analytics} */ Analytics.prototype.use = function(plugin) { plugin(this); return this; }; /** * Define a new `Integration`. * * @param {Function} Integration * @return {Analytics} */ Analytics.prototype.addIntegration = function(Integration) { var name = Integration.prototype.name; if (!name) throw new TypeError('attempted to add an invalid integration'); this.Integrations[name] = Integration; return this; }; /** * Initialize with the given integration `settings` and `options`. * * Aliased to `init` for convenience. * * @param {Object} [settings={}] * @param {Object} [options={}] * @return {Analytics} */ Analytics.prototype.init = Analytics.prototype.initialize = function(settings, options) { settings = settings || {}; options = options || {}; this._options(options); this._readied = false; // clean unknown integrations from settings var self = this; each(function(opts, name) { var Integration = self.Integrations[name]; if (!Integration) delete settings[name]; }, settings); // add integrations each(function(opts, name) { var Integration = self.Integrations[name]; var integration = new Integration(clone(opts)); self.log('initialize %o - %o', name, opts); self.add(integration); }, settings); var integrations = this._integrations; // load user now that options are set user.load(); group.load(); // make ready callback var integrationCount = keys(integrations).length; var ready = after(integrationCount, function() { self._readied = true; self.emit('ready'); }); // init if no integrations if (integrationCount <= 0) { ready(); } // initialize integrations, passing ready each(function(integration) { if (options.initialPageview && integration.options.initialPageview === false) { integration.page = after(2, integration.page); } integration.analytics = self; integration.once('ready', ready); integration.initialize(); }, integrations); // backwards compat with angular plugin. // TODO: remove this.initialized = true; this.emit('initialize', settings, options); return this; }; /** * Set the user's `id`. * * @param {Mixed} id */ Analytics.prototype.setAnonymousId = function(id) { this.user().anonymousId(id); return this; }; /** * Add an integration. * * @param {Integration} integration */ Analytics.prototype.add = function(integration) { this._integrations[integration.name] = integration; return this; }; /** * Identify a user by optional `id` and `traits`. * * @param {string} [id=user.id()] User ID. * @param {Object} [traits=null] User traits. * @param {Object} [options=null] * @param {Function} [fn] * @return {Analytics} */ Analytics.prototype.identify = function(id, traits, options, fn) { // Argument reshuffling. /* eslint-disable no-unused-expressions, no-sequences */ if (is.fn(options)) fn = options, options = null; if (is.fn(traits)) fn = traits, options = null, traits = null; if (is.object(id)) options = traits, traits = id, id = user.id(); /* eslint-enable no-unused-expressions, no-sequences */ // clone traits before we manipulate so we don't do anything uncouth, and take // from `user` so that we carryover anonymous traits user.identify(id, traits); var msg = this.normalize({ options: options, traits: user.traits(), userId: user.id() }); this._invoke('identify', new Identify(msg)); // emit this.emit('identify', id, traits, options); this._callback(fn); return this; }; /** * Return the current user. * * @return {Object} */ Analytics.prototype.user = function() { return user; }; /** * Identify a group by optional `id` and `traits`. Or, if no arguments are * supplied, return the current group. * * @param {string} [id=group.id()] Group ID. * @param {Object} [traits=null] Group traits. * @param {Object} [options=null] * @param {Function} [fn] * @return {Analytics|Object} */ Analytics.prototype.group = function(id, traits, options, fn) { /* eslint-disable no-unused-expressions, no-sequences */ if (!arguments.length) return group; if (is.fn(options)) fn = options, options = null; if (is.fn(traits)) fn = traits, options = null, traits = null; if (is.object(id)) options = traits, traits = id, id = group.id(); /* eslint-enable no-unused-expressions, no-sequences */ // grab from group again to make sure we're taking from the source group.identify(id, traits); var msg = this.normalize({ options: options, traits: group.traits(), groupId: group.id() }); this._invoke('group', new Group(msg)); this.emit('group', id, traits, options); this._callback(fn); return this; }; /** * Track an `event` that a user has triggered with optional `properties`. * * @param {string} event * @param {Object} [properties=null] * @param {Object} [options=null] * @param {Function} [fn] * @return {Analytics} */ Analytics.prototype.track = function(event, properties, options, fn) { // Argument reshuffling. /* eslint-disable no-unused-expressions, no-sequences */ if (is.fn(options)) fn = options, options = null; if (is.fn(properties)) fn = properties, options = null, properties = null; /* eslint-enable no-unused-expressions, no-sequences */ // figure out if the event is archived. var plan = this.options.plan || {}; var events = plan.track || {}; // normalize var msg = this.normalize({ properties: properties, options: options, event: event }); // plan. plan = events[event]; if (plan) { this.log('plan %o - %o', event, plan); if (plan.enabled === false) return this._callback(fn); defaults(msg.integrations, plan.integrations || {}); } this._invoke('track', new Track(msg)); this.emit('track', event, properties, options); this._callback(fn); return this; }; /** * Helper method to track an outbound link that would normally navigate away * from the page before the analytics calls were sent. * * BACKWARDS COMPATIBILITY: aliased to `trackClick`. * * @param {Element|Array} links * @param {string|Function} event * @param {Object|Function} properties (optional) * @return {Analytics} */ Analytics.prototype.trackClick = Analytics.prototype.trackLink = function(links, event, properties) { if (!links) return this; // always arrays, handles jquery if (type(links) === 'element') links = [links]; var self = this; each(function(el) { if (type(el) !== 'element') { throw new TypeError('Must pass HTMLElement to `clearbit.trackLink`.'); } on(el, 'click', function(e) { var ev = is.fn(event) ? event(el) : event; var props = is.fn(properties) ? properties(el) : properties; var href = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || el.getAttribute('xlink:href'); self.track(ev, props); if (href && el.target !== '_blank' && !isMeta(e)) { prevent(e); self._callback(function() { window.location.href = href; }); } }); }, links); return this; }; /** * Helper method to track an outbound form that would normally navigate away * from the page before the analytics calls were sent. * * BACKWARDS COMPATIBILITY: aliased to `trackSubmit`. * * @param {Element|Array} forms * @param {string|Function} event * @param {Object|Function} properties (optional) * @return {Analytics} */ Analytics.prototype.trackSubmit = Analytics.prototype.trackForm = function(forms, event, properties) { if (!forms) return this; // always arrays, handles jquery if (type(forms) === 'element') forms = [forms]; var self = this; each(function(el) { if (type(el) !== 'element') throw new TypeError('Must pass HTMLElement to `clearbit.trackForm`.'); function handler(e) { prevent(e); var ev = is.fn(event) ? event(el) : event; var props = is.fn(properties) ? properties(el) : properties; self.track(ev, props); self._callback(function() { el.submit(); }); } // Support the events happening through jQuery or Zepto instead of through // the normal DOM API, because `el.submit` doesn't bubble up events... var $ = window.jQuery || window.Zepto; if ($) { $(el).submit(handler); } else { on(el, 'submit', handler); } }, forms); return this; }; /** * Trigger a pageview, labeling the current page with an optional `category`, * `name` and `properties`. * * @param {string} [category] * @param {string} [name] * @param {Object|string} [properties] (or path) * @param {Object} [options] * @param {Function} [fn] * @return {Analytics} */ Analytics.prototype.page = function(category, name, properties, options, fn) { // Argument reshuffling. /* eslint-disable no-unused-expressions, no-sequences */ if (is.fn(options)) fn = options, options = null; if (is.fn(properties)) fn = properties, options = properties = null; if (is.fn(name)) fn = name, options = properties = name = null; if (type(category) === 'object') options = name, properties = category, name = category = null; if (type(name) === 'object') options = properties, properties = name, name = null; if (type(category) === 'string' && type(name) !== 'string') name = category, category = null; /* eslint-enable no-unused-expressions, no-sequences */ properties = clone(properties) || {}; if (name) properties.name = name; if (category) properties.category = category; // Ensure properties has baseline spec properties. // TODO: Eventually move these entirely to `options.context.page` var defs = pageDefaults(); defaults(properties, defs); // Mirror user overrides to `options.context.page` (but exclude custom properties) // (Any page defaults get applied in `this.normalize` for consistency.) // Weird, yeah--moving special props to `context.page` will fix this in the long term. var overrides = pick(keys(defs), properties); if (!is.empty(overrides)) { options = options || {}; options.context = options.context || {}; options.context.page = overrides; } var msg = this.normalize({ properties: properties, category: category, options: options, name: name }); this._invoke('page', new Page(msg)); this.emit('page', category, name, properties, options); this._callback(fn); return this; }; /** * FIXME: BACKWARDS COMPATIBILITY: convert an old `pageview` to a `page` call. * * @param {string} [url] * @return {Analytics} * @api private */ Analytics.prototype.pageview = function(url) { var properties = {}; if (url) properties.path = url; this.page(properties); return this; }; /** * Merge two previously unassociated user identities. * * @param {string} to * @param {string} from (optional) * @param {Object} options (optional) * @param {Function} fn (optional) * @return {Analytics} */ Analytics.prototype.alias = function(to, from, options, fn) { // Argument reshuffling. /* eslint-disable no-unused-expressions, no-sequences */ if (is.fn(options)) fn = options, options = null; if (is.fn(from)) fn = from, options = null, from = null; if (is.object(from)) options = from, from = null; /* eslint-enable no-unused-expressions, no-sequences */ var msg = this.normalize({ options: options, previousId: from, userId: to }); this._invoke('alias', new Alias(msg)); this.emit('alias', to, from, options); this._callback(fn); return this; }; /** * Register a `fn` to be fired when all the analytics services are ready. * * @param {Function} fn * @return {Analytics} */ Analytics.prototype.ready = function(fn) { if (is.fn(fn)) { if (this._readied) { nextTick(fn); } else { this.once('ready', fn); } } return this; }; /** * Set the `timeout` (in milliseconds) used for callbacks. * * @param {Number} timeout */ Analytics.prototype.timeout = function(timeout) { this._timeout = timeout; }; /** * Enable or disable debug. * * @param {string|boolean} str */ Analytics.prototype.debug = function(str) { if (!arguments.length || str) { debug.enable('clearbit:' + (str || '*')); } else { debug.disable(); } }; /** * Apply options. * * @param {Object} options * @return {Analytics} * @api private */ Analytics.prototype._options = function(options) { options = options || {}; this.options = options; cookie.options(options.cookie); store.options(options.localStorage); user.options(options.user); group.options(options.group); return this; }; /** * Callback a `fn` after our defined timeout period. * * @param {Function} fn * @return {Analytics} * @api private */ Analytics.prototype._callback = function(fn) { if (is.fn(fn)) { this._timeout ? setTimeout(fn, this._timeout) : nextTick(fn); } return this; }; /** * Call `method` with `facade` on all enabled integrations. * * @param {string} method * @param {Facade} facade * @return {Analytics} * @api private */ Analytics.prototype._invoke = function(method, facade) { this.emit('invoke', facade); each(function(integration, name) { if (!facade.enabled(name)) return; integration.invoke.call(integration, method, facade); }, this._integrations); return this; }; /** * Push `args`. * * @param {Array} args * @api private */ Analytics.prototype.push = function(args) { var method = args.shift(); if (!this[method]) return; this[method].apply(this, args); }; /** * Reset group and user traits and id's. * * @api public */ Analytics.prototype.reset = function() { this.user().logout(); this.group().logout(); }; /** * Parse the query string for callable methods. * * @param {String} query * @return {Analytics} * @api private */ Analytics.prototype._parseQuery = function(query) { // Parse querystring to an object var q = querystring.parse(query); // Create traits and properties objects, populate from querysting params var traits = pickPrefix('cb_trait_', q); var props = pickPrefix('cb_prop_', q); // Trigger based on callable parameters in the URL if (q.cb_uid) this.identify(q.cb_uid, traits); if (q.cb_event) this.track(q.cb_event, props); if (q.cb_aid) user.anonymousId(q.cb_aid); return this; /** * Create a shallow copy of an input object containing only the properties * whose keys are specified by a prefix, stripped of that prefix * * @param {String} prefix * @param {Object} object * @return {Object} * @api private */ function pickPrefix(prefix, object) { var length = prefix.length; var sub; return foldl(function(acc, val, key) { if (key.substr(0, length) === prefix) { sub = key.substr(length); acc[sub] = val; } return acc; }, {}, object); } }; /** * Normalize the given `msg`. * * @param {Object} msg * @return {Object} */ Analytics.prototype.normalize = function(msg) { msg = normalize(msg, keys(this._integrations)); if (msg.anonymousId) user.anonymousId(msg.anonymousId); msg.anonymousId = user.anonymousId(); // Ensure all outgoing requests include page data in their contexts. msg.context.page = defaults(msg.context.page || {}, pageDefaults()); return msg; }; /** * No conflict support. */ Analytics.prototype.noConflict = function() { window.clearbit = _clearbit; return this; }; /* * Exports. */ module.exports = Analytics; module.exports.cookie = cookie; module.exports.memory = memory; module.exports.store = store;