UNPKG

@segment/analytics.js-core

Version:

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

817 lines (816 loc) 28.4 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); var _analytics = global.analytics; /* * Module dependencies. */ var Alias = require('segmentio-facade').Alias; var Emitter = require('component-emitter'); var Facade = require('segmentio-facade'); var Group = require('segmentio-facade').Group; var Identify = require('segmentio-facade').Identify; var SourceMiddlewareChain = require('./middleware').SourceMiddlewareChain; var IntegrationMiddlewareChain = require('./middleware') .IntegrationMiddlewareChain; var DestinationMiddlewareChain = require('./middleware') .DestinationMiddlewareChain; var Page = require('segmentio-facade').Page; var Track = require('segmentio-facade').Track; var bindAll = require('bind-all'); var clone = require('./utils/clone'); var extend = require('extend'); var cookie = require('./cookie'); var metrics = require('./metrics'); var debug = require('debug'); var defaults = require('@ndhoule/defaults'); var each = require('./utils/each'); 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 url = require('component-url'); 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._sourceMiddlewares = new SourceMiddlewareChain(); this._integrationMiddlewares = new IntegrationMiddlewareChain(); this._destinationMiddlewares = {}; this._integrations = {}; this._readied = false; this._timeout = 300; // XXX: BACKWARDS COMPATIBILITY this._user = user; this.log = debug('analytics.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`. */ Analytics.prototype.use = function (plugin) { plugin(this); return this; }; /** * Define a new `Integration`. */ 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; }; /** * Define a new `SourceMiddleware` */ Analytics.prototype.addSourceMiddleware = function (middleware) { this._sourceMiddlewares.add(middleware); return this; }; /** * Define a new `IntegrationMiddleware` * @deprecated */ Analytics.prototype.addIntegrationMiddleware = function (middleware) { this._integrationMiddlewares.add(middleware); return this; }; /** * Define a new `DestinationMiddleware` * Destination Middleware is chained after integration middleware */ Analytics.prototype.addDestinationMiddleware = function (integrationName, middlewares) { var self = this; middlewares.forEach(function (middleware) { if (!self._destinationMiddlewares[integrationName]) { self._destinationMiddlewares[integrationName] = new DestinationMiddlewareChain(); } self._destinationMiddlewares[integrationName].add(middleware); }); return self; }; /** * Initialize with the given integration `settings` and `options`. * * Aliased to `init` for convenience. */ 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) { // Don't load disabled integrations if (options.integrations) { if (options.integrations[name] === false || (options.integrations.All === false && !options.integrations[name])) { return; } } var Integration = self.Integrations[name]; var clonedOpts = {}; extend(true, clonedOpts, opts); // deep clone opts var integration = new Integration(clonedOpts); 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 readyCallCount = 0; var integrationCount = keys(integrations).length; var ready = function () { readyCallCount++; if (readyCallCount >= integrationCount) { self._readied = true; self.emit('ready'); } }; // init if no integrations if (integrationCount <= 0) { ready(); } // initialize integrations, passing ready // create a list of any integrations that did not initialize - this will be passed with all events for replay support: this.failedInitializations = []; var initialPageSkipped = false; each(function (integration) { if (options.initialPageview && integration.options.initialPageview === false) { // We've assumed one initial pageview, so make sure we don't count the first page call. var page = integration.page; integration.page = function () { if (initialPageSkipped) { return page.apply(this, arguments); } initialPageSkipped = true; return; }; } integration.analytics = self; integration.once('ready', ready); try { metrics.increment('analytics_js.integration.invoke', { method: 'initialize', integration_name: integration.name }); integration.initialize(); } catch (e) { var integrationName = integration.name; metrics.increment('analytics_js.integration.invoke.error', { method: 'initialize', integration_name: integration.name }); self.failedInitializations.push(integrationName); self.log('Error initializing %s integration: %o', integrationName, e); // Mark integration as ready to prevent blocking of anyone listening to analytics.ready() integration.ready(); } }, integrations); // backwards compat with angular plugin and used for init logic checks this.initialized = true; this.emit('initialize', settings, options); return this; }; /** * Set the user's `id`. */ Analytics.prototype.setAnonymousId = function (id) { this.user().anonymousId(id); return this; }; /** * Add an 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() }); // Add the initialize integrations so the server-side ones can be disabled too if (this.options.integrations) { defaults(msg.integrations, this.options.integrations); } 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() }); // Add the initialize integrations so the server-side ones can be disabled too if (this.options.integrations) { defaults(msg.integrations, this.options.integrations); } 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 || {}; var planIntegrationOptions = {}; // 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) { // Disabled events should always be sent to Segment. planIntegrationOptions = { All: false, 'Segment.io': true }; } else { planIntegrationOptions = plan.integrations || {}; } } else { var defaultPlan = events.__default || { enabled: true }; if (!defaultPlan.enabled) { // Disabled events should always be sent to Segment. planIntegrationOptions = { All: false, 'Segment.io': true }; } } // Add the initialize integrations so the server-side ones can be disabled too defaults(msg.integrations, this._mergeInitializeAndPlanIntegrations(planIntegrationOptions)); 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 `analytics.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 `analytics.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 }); // Add the initialize integrations so the server-side ones can be disabled too if (this.options.integrations) { defaults(msg.integrations, this.options.integrations); } 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. * @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 }); // Add the initialize integrations so the server-side ones can be disabled too if (this.options.integrations) { defaults(msg.integrations, this.options.integrations); } 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. */ 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. */ Analytics.prototype.timeout = function (timeout) { this._timeout = timeout; }; /** * Enable or disable debug. */ Analytics.prototype.debug = function (str) { if (!arguments.length || str) { debug.enable('analytics:' + (str || '*')); } else { debug.disable(); } }; /** * Apply options. * @api private */ Analytics.prototype._options = function (options) { options = options || {}; this.options = options; cookie.options(options.cookie); metrics.options(options.metrics); store.options(options.localStorage); user.options(options.user); group.options(options.group); return this; }; /** * Callback a `fn` after our defined timeout period. * @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) { var self = this; try { this._sourceMiddlewares.applyMiddlewares(extend(true, new Facade({}), facade), this._integrations, function (result) { // A nullified payload should not be sent. if (result === null) { self.log('Payload with method "%s" was null and dropped by source a middleware.', method); return; } // Check if the payload is still a Facade. If not, convert it to one. if (!(result instanceof Facade)) { result = new Facade(result); } self.emit('invoke', result); metrics.increment('analytics_js.invoke', { method: method }); applyIntegrationMiddlewares(result); }); } catch (e) { metrics.increment('analytics_js.invoke.error', { method: method }); self.log('Error invoking .%s method of %s integration: %o', method, name, e); } return this; function applyIntegrationMiddlewares(facade) { var failedInitializations = self.failedInitializations || []; each(function (integration, name) { var facadeCopy = extend(true, new Facade({}), facade); if (!facadeCopy.enabled(name)) return; // Check if an integration failed to initialize. // If so, do not process the message as the integration is in an unstable state. if (failedInitializations.indexOf(name) >= 0) { self.log('Skipping invocation of .%s method of %s integration. Integration failed to initialize properly.', method, name); } else { try { // Apply any integration middlewares that exist, then invoke the integration with the result. self._integrationMiddlewares.applyMiddlewares(facadeCopy, integration.name, function (result) { // A nullified payload should not be sent to an integration. if (result === null) { self.log('Payload to integration "%s" was null and dropped by a middleware.', name); return; } // Check if the payload is still a Facade. If not, convert it to one. if (!(result instanceof Facade)) { result = new Facade(result); } // apply destination middlewares // Apply any integration middlewares that exist, then invoke the integration with the result. if (self._destinationMiddlewares[integration.name]) { self._destinationMiddlewares[integration.name].applyMiddlewares(facadeCopy, integration.name, function (result) { // A nullified payload should not be sent to an integration. if (result === null) { self.log('Payload to destination "%s" was null and dropped by a middleware.', name); return; } // Check if the payload is still a Facade. If not, convert it to one. if (!(result instanceof Facade)) { result = new Facade(result); } metrics.increment('analytics_js.integration.invoke', { method: method, integration_name: integration.name }); integration.invoke.call(integration, method, result); }); } else { metrics.increment('analytics_js.integration.invoke', { method: method, integration_name: integration.name }); integration.invoke.call(integration, method, result); } }); } catch (e) { metrics.increment('analytics_js.integration.invoke.error', { method: method, integration_name: integration.name }); self.log('Error invoking .%s method of %s integration: %o', method, name, e); } } }, self._integrations); } }; /** * 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. * * @api private */ Analytics.prototype._parseQuery = function (query) { // Parse querystring to an object var parsed = url.parse(query); var q = parsed.query.split('&').reduce(function (acc, str) { var _a = str.split('='), k = _a[0], v = _a[1]; acc[k] = decodeURI(v).replace('+', ' '); return acc; }, {}); // Create traits and properties objects, populate from querysting params var traits = pickPrefix('ajs_trait_', q); var props = pickPrefix('ajs_prop_', q); // Trigger based on callable parameters in the URL if (q.ajs_uid) this.identify(q.ajs_uid, traits); if (q.ajs_event) this.track(q.ajs_event, props); if (q.ajs_aid) user.anonymousId(q.ajs_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 * * @return {Object} * @api private */ function pickPrefix(prefix, object) { var length = prefix.length; var sub; return Object.keys(object).reduce(function (acc, key) { if (key.substr(0, length) === prefix) { sub = key.substr(length); acc[sub] = object[key]; } return acc; }, {}); } }; /** * Normalize the given `msg`. */ 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; }; /** * Merges the tracking plan and initialization integration options. * * @param {Object} planIntegrations Tracking plan integrations. * @return {Object} The merged integrations. */ Analytics.prototype._mergeInitializeAndPlanIntegrations = function (planIntegrations) { // Do nothing if there are no initialization integrations if (!this.options.integrations) { return planIntegrations; } // Clone the initialization integrations var integrations = extend({}, this.options.integrations); var integrationName; // Allow the tracking plan to disable integrations that were explicitly // enabled on initialization if (planIntegrations.All === false) { integrations = { All: false }; } for (integrationName in planIntegrations) { if (planIntegrations.hasOwnProperty(integrationName)) { // Don't allow the tracking plan to re-enable disabled integrations if (this.options.integrations[integrationName] !== false) { integrations[integrationName] = planIntegrations[integrationName]; } } } return integrations; }; /** * No conflict support. */ Analytics.prototype.noConflict = function () { window.analytics = _analytics; return this; }; /* * Exports. */ module.exports = Analytics; module.exports.cookie = cookie; module.exports.memory = memory; module.exports.store = store; module.exports.metrics = metrics;