UNPKG

@segment/analytics.js-integration-segmentio

Version:
748 lines (660 loc) 19.4 kB
'use strict'; /** * Module dependencies. */ var ads = require('./ads'); var clone = require('component-clone'); var cookie = require('component-cookie'); var extend = require('@ndhoule/extend'); var integration = require('@segment/analytics.js-integration'); var json = require('json3'); var keys = require('@ndhoule/keys'); var localstorage = require('yields-store'); var protocol = require('@segment/protocol'); var send = require('@segment/send-json'); var topDomain = require('@segment/top-domain'); var utm = require('./utm'); var uuid = require('uuid').v4; var Queue = require('@segment/localstorage-retry'); /** * Cookie options */ var cookieOptions = { // 1 year maxage: 31536000000, secure: false, path: '/' }; /** * Segment messages can be a maximum of 32kb. */ var MAX_SIZE = 32 * 1000; /** * Queue options * * Attempt with exponential backoff for upto 10 times. * Backoff periods are: 1s, 2s, 4s, 8s, 16s, 32s, 64s, 128s (~2m), 256s (~4m), * 512s (~8.5m) and 1024s (~17m). */ var queueOptions = { maxRetryDelay: 360000, // max interval of 1hr. Added as a guard. minRetryDelay: 1000, // first attempt (1s) backoffFactor: 2, maxAttempts: 10, maxItems: 100 }; /** * Expose `Segment` integration. */ var Segment = (exports = module.exports = integration('Segment.io') .option('apiKey', '') .option('apiHost', 'api.segment.io/v1') .option('crossDomainIdServers', []) .option('deleteCrossDomainId', false) .option('saveCrossDomainIdInLocalStorage', true) .option('retryQueue', true) .option('addBundledMetadata', false) .option('unbundledIntegrations', [])) .option('unbundledConfigIds', []) .option('maybeBundledConfigIds', {}); /** * Get the store. * * @return {Function} */ exports.storage = function() { return protocol() === 'file:' || protocol() === 'chrome-extension:' ? localstorage : cookie; }; /** * Expose global for testing. */ exports.global = window; /** * Send the given `obj` and `headers` to `url` with the specified `timeout` and * `fn(err, req)`. Exported for testing. * * @param {String} url * @param {Object} obj * @param {Object} headers * @param {long} timeout * @param {Function} fn * @api private */ exports.sendJsonWithTimeout = function(url, obj, headers, timeout, fn) { // only proceed with our new code path when cors is supported. this is // unlikely to happen in production, but we're being safe to preserve backward // compatibility. if (send.type !== 'xhr') { send(url, obj, headers, fn); return; } var req = new XMLHttpRequest(); req.onerror = fn; req.onreadystatechange = done; req.open('POST', url, true); req.timeout = timeout; req.ontimeout = fn; // TODO: Remove this eslint disable // eslint-disable-next-line guard-for-in for (var k in headers) { req.setRequestHeader(k, headers[k]); } req.send(json.stringify(obj)); function done() { if (req.readyState === 4) { // Fail on 429 and 5xx HTTP errors if (req.status === 429 || (req.status >= 500 && req.status < 600)) { fn(new Error('HTTP Error ' + req.status + ' (' + req.statusText + ')')); } else { fn(null, req); } } } }; /** * Initialize. * * https://github.com/segmentio/segmentio/blob/master/modules/segmentjs/segment.js/v1/segment.js * * @api public */ Segment.prototype.initialize = function() { var self = this; if (this.options.retryQueue) { this._lsqueue = new Queue('segmentio', queueOptions, function(elem, done) { // apply sentAt at flush time and reset on each retry // so the tracking-api doesn't interpret a time skew var item = elem; item.msg.sentAt = new Date(); // send with 10s timeout Segment.sendJsonWithTimeout( item.url, item.msg, item.headers, 10 * 1000, function(err, res) { self.debug('sent %O, received %O', item.msg, [err, res]); if (err) return done(err); done(null, res); } ); }); this._lsqueue.start(); } this.ready(); this.analytics.on('invoke', function(msg) { var action = msg.action(); var listener = 'on' + msg.action(); self.debug('%s %o', action, msg); if (self[listener]) self[listener](msg); self.ready(); }); // Delete cross domain identifiers. this.deleteCrossDomainIdIfNeeded(); // At this moment we intentionally do not want events to be queued while we retrieve the `crossDomainId` // so `.ready` will get called right away and we'll try to figure out `crossDomainId` // separately if (this.isCrossDomainAnalyticsEnabled()) { this.retrieveCrossDomainId(); } }; /** * Loaded. * * @api private * @return {boolean} */ Segment.prototype.loaded = function() { return true; }; /** * Page. * * @api public * @param {Page} page */ Segment.prototype.onpage = function(page) { this.enqueue('/p', page.json()); }; /** * Identify. * * @api public * @param {Identify} identify */ Segment.prototype.onidentify = function(identify) { this.enqueue('/i', identify.json()); }; /** * Group. * * @api public * @param {Group} group */ Segment.prototype.ongroup = function(group) { this.enqueue('/g', group.json()); }; /** * ontrack. * * TODO: Document this. * * @api private * @param {Track} track */ Segment.prototype.ontrack = function(track) { var json = track.json(); // TODO: figure out why we need traits. delete json.traits; this.enqueue('/t', json); }; /** * Alias. * * @api public * @param {Alias} alias */ Segment.prototype.onalias = function(alias) { var json = alias.json(); var user = this.analytics.user(); json.previousId = json.previousId || json.from || user.id() || user.anonymousId(); json.userId = json.userId || json.to; delete json.from; delete json.to; this.enqueue('/a', json); }; /** * Normalize the given `msg`. * * @api private * @param {Object} msg */ Segment.prototype.normalize = function(message) { var msg = message; this.debug('normalize %o', msg); var user = this.analytics.user(); var global = exports.global; var query = global.location.search; var ctx = (msg.context = msg.context || msg.options || {}); delete msg.options; msg.writeKey = this.options.apiKey; ctx.userAgent = navigator.userAgent; var locale = navigator.userLanguage || navigator.language; if (typeof ctx.locale === 'undefined' && typeof locale !== 'undefined') { ctx.locale = locale; } if (!ctx.library) ctx.library = { name: 'analytics.js', version: this.analytics.VERSION }; if (this.isCrossDomainAnalyticsEnabled()) { var crossDomainId = this.getCachedCrossDomainId(); if (crossDomainId) { if (!ctx.traits) { ctx.traits = { crossDomainId: crossDomainId }; } else if (!ctx.traits.crossDomainId) { ctx.traits.crossDomainId = crossDomainId; } } } // if user provides campaign via context, do not overwrite with UTM qs param if (query && !ctx.campaign) { ctx.campaign = utm(query); } this.referrerId(query, ctx); msg.userId = msg.userId || user.id(); msg.anonymousId = user.anonymousId(); msg.sentAt = new Date(); // Add _metadata. var failedInitializations = this.analytics.failedInitializations || []; if (failedInitializations.length > 0) { msg._metadata = { failedInitializations: failedInitializations }; } if (this.options.addBundledMetadata) { var bundled = keys(this.analytics.Integrations); var maybeBundledConfigIds = this.options.maybeBundledConfigIds // Generate a list of bundled config IDs using the intersection of // bundled destination names and maybe bundled config IDs. var bundledConfigIds = [] for (var i = 0; i < bundled.length; i++) { var name = bundled[i] if (!maybeBundledConfigIds) { break } if (!maybeBundledConfigIds[name]) { continue } for (var j = 0; j < maybeBundledConfigIds[name].length; j++) { var id = maybeBundledConfigIds[name][j] bundledConfigIds.push(id) } } msg._metadata = msg._metadata || {}; msg._metadata.bundled = bundled; msg._metadata.unbundled = this.options.unbundledIntegrations; msg._metadata.bundledIds = bundledConfigIds; } this.debug('normalized %o', msg); this.ampId(ctx); return msg; }; /** * Add amp id if it exists. * * @param {Object} ctx */ Segment.prototype.ampId = function(ctx) { var ampId = this.cookie('_ga'); if (ampId) { if (ampId.slice(0, 3) === 'amp') ctx.amp = { id: ampId }; } }; /** * Send `obj` to `path`. * * @api private * @param {string} path * @param {Object} obj * @param {Function} fn */ Segment.prototype.enqueue = function(path, message, fn) { var url = 'https://' + this.options.apiHost + path; var headers = { 'Content-Type': 'text/plain' }; var msg = this.normalize(message); // Print a log statement when messages exceed the maximum size. In the future, // we may consider dropping this event on the client entirely. if (json.stringify(msg).length > MAX_SIZE) { this.debug('message must be less than 32kb %O', msg); } this.debug('enqueueing %O', msg); var self = this; if (this.options.retryQueue) { this._lsqueue.addItem({ url: url, headers: headers, msg: msg }); } else { send(url, msg, headers, function(err, res) { self.debug('sent %O, received %O', msg, [err, res]); if (fn) { if (err) return fn(err); fn(null, res); } }); } }; /** * Gets/sets cookies on the appropriate domain. * * @api private * @param {string} name * @param {*} val */ Segment.prototype.cookie = function(name, val) { var store = Segment.storage(); if (arguments.length === 1) return store(name); var global = exports.global; var href = global.location.href; var domain = '.' + topDomain(href); if (domain === '.') domain = ''; this.debug('store domain %s -> %s', href, domain); var opts = clone(cookieOptions); opts.domain = domain; this.debug('store %s, %s, %o', name, val, opts); store(name, val, opts); if (store(name)) return; delete opts.domain; this.debug('fallback store %s, %s, %o', name, val, opts); store(name, val, opts); }; /** * Add referrerId to context. * * TODO: remove. * * @api private * @param {Object} query * @param {Object} ctx */ Segment.prototype.referrerId = function(query, ctx) { var stored = this.cookie('s:context.referrer'); var ad; if (stored) stored = json.parse(stored); if (query) ad = ads(query); ad = ad || stored; if (!ad) return; ctx.referrer = extend(ctx.referrer || {}, ad); this.cookie('s:context.referrer', json.stringify(ad)); }; /** * isCrossDomainAnalyticsEnabled returns true if cross domain analytics is enabled. * This field is not directly supplied, so it is inferred by inspecting the * `crossDomainIdServers` array in settings. If this array is null or empty, * it is assumed that cross domain analytics is disabled. * * @api private */ Segment.prototype.isCrossDomainAnalyticsEnabled = function() { if (!this.options.crossDomainIdServers) { return false; } return this.options.crossDomainIdServers.length > 0; }; /** * retrieveCrossDomainId. * * @api private * @param {function) callback => err, {crossDomainId, fromServer, timestamp} */ Segment.prototype.retrieveCrossDomainId = function(callback) { if (!this.isCrossDomainAnalyticsEnabled()) { // Callback is only provided in tests. if (callback) { callback('crossDomainId not enabled', null); } return; } var cachedCrossDomainId = this.getCachedCrossDomainId(); if (cachedCrossDomainId) { // Callback is only provided in tests. if (callback) { callback(null, { crossDomainId: cachedCrossDomainId }); } return; } var self = this; var writeKey = this.options.apiKey; var domains = []; for (var i = 0; i < this.options.crossDomainIdServers.length; i++) { var domain = this.options.crossDomainIdServers[i]; domains.push(domain); } getCrossDomainIdFromServerList(domains, writeKey, function(err, res) { if (err) { // Callback is only provided in tests. if (callback) { callback(err, null); } // We optimize for no conflicting xid as much as possible. So bail out if there is an // error and we cannot be sure that xid does not exist on any other domains. return; } var crossDomainId = null; var fromDomain = null; if (res) { crossDomainId = res.id; fromDomain = res.domain; } else { crossDomainId = uuid(); fromDomain = window.location.hostname; } self.saveCrossDomainId(crossDomainId); self.analytics.identify({ crossDomainId: crossDomainId }); // Callback is only provided in tests. if (callback) { callback(null, { crossDomainId: crossDomainId, fromDomain: fromDomain }); } }); }; /** * getCachedCrossDomainId returns the cross domain identifier stored on the client based on the `saveCrossDomainIdInLocalStorage` flag. * If `saveCrossDomainIdInLocalStorage` is false, it reads it from the `seg_xid` cookie. * If `saveCrossDomainIdInLocalStorage` is true, it reads it from the `seg_xid` key in localStorage. * * @return {string} crossDomainId */ Segment.prototype.getCachedCrossDomainId = function() { if (this.options.saveCrossDomainIdInLocalStorage) { return localstorage('seg_xid'); } return this.cookie('seg_xid'); }; /** * saveCrossDomainId saves the cross domain identifier. The implementation differs based on the `saveCrossDomainIdInLocalStorage` flag. * If `saveCrossDomainIdInLocalStorage` is false, it saves it as the `seg_xid` cookie. * If `saveCrossDomainIdInLocalStorage` is true, it saves it to localStorage (so that it can be accessed on the current domain) * and as a httpOnly cookie (so that can it can be provided to other domains). * * @api private */ Segment.prototype.saveCrossDomainId = function(crossDomainId) { if (!this.options.saveCrossDomainIdInLocalStorage) { this.cookie('seg_xid', crossDomainId); return; } var self = this; // Save the cookie by making a request to the xid server for the current domain. var currentTld = getTld(window.location.hostname); for (var i = 0; i < this.options.crossDomainIdServers.length; i++) { var domain = this.options.crossDomainIdServers[i]; if (getTld(domain) === currentTld) { var writeKey = this.options.apiKey; var url = 'https://' + domain + '/v1/saveId?writeKey=' + writeKey + '&xid=' + crossDomainId; httpGet(url, function(err, res) { if (err) { self.debug('could not save id on %O, received %O', url, [err, res]); return; } localstorage('seg_xid', crossDomainId); }); return; } } }; /** * Deletes any state persisted by cross domain analytics. * * seg_xid (and metadata) from cookies * * seg_xid from localStorage * * crossDomainId from traits in localStorage * * The deletion logic is run only if deletion is enabled for this project, and only * deletes the data that actually exists. * * @api private */ Segment.prototype.deleteCrossDomainIdIfNeeded = function() { // Only continue if deletion is enabled for this project. if (!this.options.deleteCrossDomainId) { return; } // Delete the xid cookie if it exists. We also delete associated metadata. if (this.cookie('seg_xid')) { this.cookie('seg_xid', null); this.cookie('seg_xid_fd', null); this.cookie('seg_xid_ts', null); } // Delete the xid from localStorage if it exists. if (localstorage('seg_xid')) { localstorage('seg_xid', null); } // Delete the crossDomainId trait in localStorage if it exists. if (this.analytics.user().traits().crossDomainId) { // This intentionally uses an internal API, so that // we can avoid interacting with lower level localStorage APIs, and instead // leverage existing functionality inside analytics.js. var traits = this.analytics.user().traits(); delete traits.crossDomainId; this.analytics.user()._setTraits(traits); } }; /** * getCrossDomainIdFromServers * @param {Array} domains * @param {string} writeKey * @param {function} callback => err, {domain, id} */ function getCrossDomainIdFromServerList(domains, writeKey, callback) { // Should not happen but special case if (domains.length === 0) { callback(null, null); } var crossDomainIdFound = false; var finishedRequests = 0; var error = null; for (var i = 0; i < domains.length; i++) { var domain = domains[i]; getCrossDomainIdFromSingleServer(domain, writeKey, function(err, res) { finishedRequests++; if (err) { // if request against a particular domain fails, we won't early exit // but rather wait and see if requests to other domains succeed error = err; } else if (res && res.id && !crossDomainIdFound) { // If we found an xid from any of the servers, we'll just early exit and callback crossDomainIdFound = true; callback(null, res); } if (finishedRequests === domains.length && !crossDomainIdFound) { // Error is non-null if we encountered an issue, otherwise error will be null // meaning that no domains in the list has an xid for current user callback(error, null); } }); } } /** * getCrossDomainId * @param {Array} domain * @param {string} writeKey * @param {function} callback => err, {domain, id} */ function getCrossDomainIdFromSingleServer(domain, writeKey, callback) { var endpoint = 'https://' + domain + '/v1/id/' + writeKey; getJson(endpoint, function(err, res) { if (err) { callback(err, null); } else { callback(null, { domain: domain, id: (res && res.id) || null }); } }); } /** * getJson * @param {string} url * @param {function} callback => err, json */ function getJson(url, callback) { var xhr = new XMLHttpRequest(); xhr.open('GET', url, true); xhr.withCredentials = true; xhr.onreadystatechange = function() { if (xhr.readyState === XMLHttpRequest.DONE) { if (xhr.status >= 200 && xhr.status < 300) { callback(null, xhr.responseText ? json.parse(xhr.responseText) : null); } else { callback(xhr.statusText || 'Unknown Error', null); } } }; xhr.send(); } /** * get makes a get request to the given URL. * @param {string} url * @param {function} callback => err, response */ function httpGet(url, callback) { var xhr = new XMLHttpRequest(); xhr.open('GET', url, true); xhr.withCredentials = true; xhr.onreadystatechange = function() { if (xhr.readyState === XMLHttpRequest.DONE) { if (xhr.status >= 200 && xhr.status < 300) { callback(null, xhr.responseText); } else { callback(xhr.statusText || xhr.responseText || 'Unknown Error', null); } } }; xhr.send(); } /** * getTld * Get domain.com from subdomain.domain.com, etc. * @param {string} domain * @return {string} tld */ function getTld(domain) { return domain .split('.') .splice(-2) .join('.'); }