diff --git a/integrations/segmentio/HISTORY.md b/integrations/segmentio/HISTORY.md new file mode 100644 index 000000000..3ae0eb2d2 --- /dev/null +++ b/integrations/segmentio/HISTORY.md @@ -0,0 +1,186 @@ + +4.0.0 / 2019-03-08 +================== + + * [New](https://github.com/segment-integrations/analytics.js-integration-segmentio/pull/55): Stop Generating MessageId. + +3.9.0 / 2019-01-14 +================== + + * [New](https://github.com/segment-integrations/analytics.js-integration-segmentio/pull/54): Add flag and logic to delete cross domain identifiers. + +3.8.1 / 2018-12-09 +================== + + * [Fix](https://github.com/segment-integrations/analytics.js-integration-segmentio/pull/52): Don't send xid when cross domain analytics is disabled. + +3.8.0 / 2018-10-05 +================== + + * [Improvement](https://github.com/segment-integrations/analytics.js-integration-segmentio/pull/49): Enable retryQueue by default. + +3.7.0 / 2018-28-08 +================== + + * [Improvement](https://github.com/segment-integrations/analytics.js-integration-segmentio/pull/48): Handle 429 and 5xx HTTP errors + +3.6.5 / 2018-17-08 +================== + + * [Fix](https://github.com/segment-integrations/analytics.js-integration-segmentio/pull/47): Update localstorage-retry version with fix limiting the inProgress queue + +3.6.4 / 2018-11-07 +================== + + * [Fix](https://github.com/segment-integrations/analytics.js-integration-segmentio/pull/45): Update localstorage-retry version with fix for adding multiple items to the queue. + +3.6.3 / 2018-28-06 +================== + + * Warn when messages exceed limits. + +3.6.2 / 2018-17-04 +================== + + * Add timeout for requests that will be retried. + +3.6.1 / 2018-15-04 +================== + + * Retry messages only upto 10 times. + +3.6.0 / 2017-11-01 +================== + + * add lookup for failedInitializations and pass as _metadata + +3.5.4 / 2017-08-24 +================== + + * cap retryQueue to 100 items, tune backoff strategy + +3.5.3 / 2017-08-02 +================== + + * retryQueue falls back to inMemory if localStorage is full + +3.5.2 / 2017-08-02 +================== + + * Bump localstorage-retry version (again ;) + +3.5.1 / 2017-08-02 +================== + + * Bump localstorage-retry version (#32) + +3.5.0 / 2017-07-31 +================== + + * Enqueue All Requests to LocalStorage for Durability (#23) + +3.4.2 / 2017-04-03 +================== + + * Revert "use top-domain module instead of hand rolled function (#24)" + * Revert "Address comments. (#25)" + * Revert "Fix TLD implementation and add tests. (#28)" + +3.4.1 / 2017-03-30 +================== + + * Address general XID comments. (#25) + * use top-domain module instead of hand rolled function (#24) + * fix(normalize): Allow override context.campaign (#26) + * Improve cookie behavior via using shorter cookie names (#22) + +3.4.0 / 2017-01-25 +================== + + * Add localStorage queueing for durability + +3.3.0 / 2017-01-17 +================== + + * Add cross domain id capability (#20) + +3.2.2 / 2017-01-02 +================== + + * Add beacon support (#19) + +3.2.1 / 2016-11-03 +================== + + * Always send requests over HTTPS + +3.2.0 / 2016-09-01 +================== + + * Add unbundled metadata (#17) + * Add bundled integrations metadata to every request (#16) + +3.1.1 / 2016-07-22 +================== + + * Add `apiHost` as full integration option + +3.1.0 / 2016-07-22 +================== + + * Allow configuration of API endpoint (#14) + +3.0.0 / 2016-07-18 +================== + + * revert context-traits auto-sending (#13) + +2.0.0 / 2016-06-21 +================== + + * Remove Duo compatibility + * Add CI setup (coverage, linting, cross-browser compatibility, etc.) + * Update eslint configuration + + +1.0.7 / 2016-06-17 +================== + + * add .context.amp and pull segment_amp_id + + +1.0.6 / 2016-05-24 +================== + + * fix this forsaken dependency hell + * add traits to context + +1.0.5 / 2016-05-07 +================== + + * Bump Analytics.js core, tester, integration to use Facade 2.x + +1.0.4 / 2015-09-15 +================== + + * Update send-json dependency + +1.0.3 / 2015-09-14 +================== + + * increasing `messageId` randomness + +1.0.2 / 2015-06-30 +================== + + * Replace analytics.js dependency with analytics.js-core + +1.0.1 / 2015-06-24 +================== + + * Bump analytics.js-integration version + +1.0.0 / 2015-06-09 +================== + + * Initial commit :sparkles: diff --git a/integrations/segmentio/README.md b/integrations/segmentio/README.md new file mode 100644 index 000000000..7c8083202 --- /dev/null +++ b/integrations/segmentio/README.md @@ -0,0 +1,12 @@ +# analytics.js-integration-segmentio [![Build Status][ci-badge]][ci-link] + +Segmentio integration for [Analytics.js][]. + +## License + +Released under the [MIT license](LICENSE). + + +[Analytics.js]: https://segment.com/docs/libraries/analytics.js/ +[ci-link]: https://circleci.com/gh/segment-integrations/analytics.js-integration-segmentio +[ci-badge]: https://circleci.com/gh/segment-integrations/analytics.js-integration-segmentio.svg?style=svg diff --git a/integrations/segmentio/karma.conf-ci.js b/integrations/segmentio/karma.conf-ci.js new file mode 100644 index 000000000..2ba15b707 --- /dev/null +++ b/integrations/segmentio/karma.conf-ci.js @@ -0,0 +1 @@ +module.exports = require('../../karma.conf-ci.js'); diff --git a/integrations/segmentio/karma.conf.js b/integrations/segmentio/karma.conf.js new file mode 100644 index 000000000..8605180af --- /dev/null +++ b/integrations/segmentio/karma.conf.js @@ -0,0 +1 @@ +module.exports = require('../../karma.conf'); diff --git a/integrations/segmentio/lib/index.js b/integrations/segmentio/lib/index.js new file mode 100644 index 000000000..86271d949 --- /dev/null +++ b/integrations/segmentio/lib/index.js @@ -0,0 +1,721 @@ +'use strict'; + +/** + * Module dependencies. + */ + +var ads = require('@segment/ad-params'); +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('@segment/utm-params'); +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', [])); + +/** + * 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; + 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); + msg._metadata = msg._metadata || {}; + msg._metadata.bundled = bundled; + msg._metadata.unbundled = this.options.unbundledIntegrations; + } + 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('segment_amp_id'); + if (ampId) 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; + + // Exclude the current domain from the list of servers we're querying + var currentTld = getTld(window.location.hostname); + var domains = []; + for (var i = 0; i < this.options.crossDomainIdServers.length; i++) { + var domain = this.options.crossDomainIdServers[i]; + if (getTld(domain) !== currentTld) { + 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('.'); +} diff --git a/integrations/segmentio/package.json b/integrations/segmentio/package.json new file mode 100644 index 000000000..bd6aed0d8 --- /dev/null +++ b/integrations/segmentio/package.json @@ -0,0 +1,63 @@ +{ + "name": "@segment/analytics.js-integration-segmentio", + "description": "The Segmentio analytics.js integration.", + "version": "4.2.1", + "keywords": [ + "analytics.js", + "analytics.js-integration", + "segment", + "segmentio" + ], + "main": "lib/index.js", + "scripts": { + "test": "karma start", + "test:ci": "karma start karma.conf-ci.js" + }, + "author": "Segment ", + "license": "SEE LICENSE IN LICENSE", + "homepage": "https://github.com/segmentio/analytics.js-integrations/blob/master/integrations/segmentio#readme", + "bugs": { + "url": "https://github.com/segmentio/analytics.js-integrations/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/segmentio/analytics.js-integrations.git" + }, + "dependencies": { + "@ndhoule/extend": "^2.0.0", + "@ndhoule/keys": "^2.0.0", + "@segment/ad-params": "^1.0.0", + "@segment/analytics.js-integration": "^2.1.0", + "@segment/localstorage-retry": "^1.2.2", + "@segment/protocol": "^1.0.0", + "@segment/send-json": "^3.0.0", + "@segment/top-domain": "^3.0.0", + "@segment/utm-params": "^2.0.0", + "component-clone": "^0.2.2", + "component-cookie": "^1.1.2", + "component-type": "^1.2.1", + "json3": "^3.3.2", + "uuid": "^2.0.2", + "yields-store": "^1.0.2" + }, + "devDependencies": { + "@segment/analytics.js-core": "^3.8.0", + "@segment/analytics.js-integration-tester": "^2.0.0", + "@segment/clear-env": "^2.0.0", + "@segment/eslint-config": "^3.1.1", + "browserify": "^16.2.3", + "eslint": "^5.16.0", + "karma": "^4.1.0", + "karma-browserify": "^6.0.0", + "karma-chrome-launcher": "^2.2.0", + "karma-mocha": "^1.3.0", + "karma-mocha-reporter": "^2.2.5", + "karma-sauce-launcher": "^2.0.2", + "karma-spec-reporter": "^0.0.32", + "karma-summary-reporter": "^1.6.0", + "mocha": "^6.1.4", + "watchify": "^3.11.1", + "proclaim": "^3.4.1", + "sinon": "^1.17.4" + } +} diff --git a/integrations/segmentio/test/index.test.js b/integrations/segmentio/test/index.test.js new file mode 100644 index 000000000..e8a1e92e2 --- /dev/null +++ b/integrations/segmentio/test/index.test.js @@ -0,0 +1,1580 @@ +'use strict'; + +var Analytics = require('@segment/analytics.js-core').constructor; +var JSON = require('json3'); +var Segment = require('../lib/'); +var assert = require('proclaim'); +var cookie = require('component-cookie'); +var integration = require('@segment/analytics.js-integration'); +var protocol = require('@segment/protocol'); +var sandbox = require('@segment/clear-env'); +var store = require('yields-store'); +var tester = require('@segment/analytics.js-integration-tester'); +var type = require('component-type'); +var sinon = require('sinon'); +var send = require('@segment/send-json'); +var Schedule = require('@segment/localstorage-retry/lib/schedule'); +var lolex = require('lolex'); + +// FIXME(ndhoule): clear-env's AJAX request clearing interferes with PhantomJS 2 +// Detect Phantom env and use it to disable affected tests. We should use a +// better/more robust way of intercepting and canceling AJAX requests to avoid +// this hackery +var isPhantomJS = /PhantomJS/.test(window.navigator.userAgent); + +describe('Segment.io', function() { + var segment; + var analytics; + var options; + + before(function() { + // Just to make sure that `cookie()` + // doesn't throw URIError we add a cookie + // that will cause `decodeURIComponent()` to throw. + document.cookie = 'bad=%'; + }); + + beforeEach(function() { + options = { apiKey: 'oq0vdlg7yi' }; + protocol.reset(); + analytics = new Analytics(); + segment = new Segment(options); + analytics.use(Segment); + analytics.use(tester); + analytics.add(segment); + analytics.assert(Segment.global === window); + resetCookies(); + if (window.localStorage) { + window.localStorage.clear(); + } + }); + + afterEach(function() { + analytics.restore(); + analytics.reset(); + resetCookies(); + if (window.localStorage) { + window.localStorage.clear(); + } + segment.reset(); + sandbox(); + }); + + function resetCookies() { + store('s:context.referrer', null); + cookie('s:context.referrer', null, { maxage: -1, path: '/' }); + store('segment_amp_id', null); + cookie('segment_amp_id', null, { maxage: -1, path: '/' }); + store('seg_xid', null); + cookie('seg_xid', null, { maxage: -1, path: '/' }); + store('seg_xid_fd', null); + cookie('seg_xid_fd', null, { maxage: -1, path: '/' }); + store('seg_xid_ts', null); + cookie('seg_xid_ts', null, { maxage: -1, path: '/' }); + } + + it('should have the right settings', function() { + analytics.compare( + Segment, + integration('Segment.io') + .option('apiKey', '') + .option('retryQueue', true) + ); + }); + + it('should always be turned on', function(done) { + var Analytics = analytics.constructor; + var ajs = new Analytics(); + ajs.use(Segment); + ajs.initialize({ 'Segment.io': options }); + ajs.ready(function() { + var segment = ajs._integrations['Segment.io']; + segment.ontrack = sinon.spy(); + ajs.track('event', {}, { All: false }); + assert(segment.ontrack.calledOnce); + done(); + }); + }); + + describe('Segment.storage()', function() { + it('should return cookie() when the protocol isnt file://', function() { + analytics.assert(Segment.storage(), cookie); + }); + + it('should return store() when the protocol is file://', function() { + analytics.assert(Segment.storage(), cookie); + protocol('file:'); + analytics.assert(Segment.storage(), store); + }); + + it('should return store() when the protocol is chrome-extension://', function() { + analytics.assert(Segment.storage(), cookie); + protocol('chrome-extension:'); + analytics.assert(Segment.storage(), store); + }); + }); + + describe('before loading', function() { + beforeEach(function() { + analytics.stub(segment, 'load'); + }); + + describe('#normalize', function() { + var object; + + beforeEach(function() { + segment.cookie('s:context.referrer', null); + analytics.initialize(); + object = {}; + }); + + it('should add .anonymousId', function() { + analytics.user().anonymousId('anon-id'); + segment.normalize(object); + analytics.assert(object.anonymousId === 'anon-id'); + }); + + it('should add .sentAt', function() { + segment.normalize(object); + analytics.assert(object.sentAt); + analytics.assert(type(object.sentAt) === 'date'); + }); + + it('should add .userId', function() { + analytics.user().id('user-id'); + segment.normalize(object); + analytics.assert(object.userId === 'user-id'); + }); + + it('should not replace the .userId', function() { + analytics.user().id('user-id'); + object.userId = 'existing-id'; + segment.normalize(object); + analytics.assert(object.userId === 'existing-id'); + }); + + it('should always add .anonymousId even if .userId is given', function() { + var object = { userId: 'baz' }; + segment.normalize(object); + analytics.assert(object.anonymousId.length === 36); + }); + + it('should add .context', function() { + segment.normalize(object); + analytics.assert(object.context); + }); + + it('should not rewrite context if provided', function() { + var ctx = {}; + var object = { context: ctx }; + segment.normalize(object); + analytics.assert(object.context === ctx); + }); + + it('should copy .options to .context', function() { + var opts = {}; + var object = { options: opts }; + segment.normalize(object); + analytics.assert(object.context === opts); + analytics.assert(object.options == null); + }); + + it('should add .writeKey', function() { + segment.normalize(object); + analytics.assert(object.writeKey === segment.options.apiKey); + }); + + it('should add .library', function() { + segment.normalize(object); + analytics.assert(object.context.library); + analytics.assert(object.context.library.name === 'analytics.js'); + analytics.assert(object.context.library.version === analytics.VERSION); + }); + + it('should allow override of .library', function() { + var ctx = { + library: { + name: 'analytics-wordpress', + version: '1.0.3' + } + }; + var object = { context: ctx }; + segment.normalize(object); + analytics.assert(object.context.library); + analytics.assert(object.context.library.name === 'analytics-wordpress'); + analytics.assert(object.context.library.version === '1.0.3'); + }); + + it('should add .userAgent', function() { + segment.normalize(object); + analytics.assert(object.context.userAgent === navigator.userAgent); + }); + + it('should add .campaign', function() { + Segment.global = { navigator: {}, location: {} }; + Segment.global.location.search = + '?utm_source=source&utm_medium=medium&utm_term=term&utm_content=content&utm_campaign=name'; + Segment.global.location.hostname = 'localhost'; + segment.normalize(object); + analytics.assert(object); + analytics.assert(object.context); + analytics.assert(object.context.campaign); + analytics.assert(object.context.campaign.source === 'source'); + analytics.assert(object.context.campaign.medium === 'medium'); + analytics.assert(object.context.campaign.term === 'term'); + analytics.assert(object.context.campaign.content === 'content'); + analytics.assert(object.context.campaign.name === 'name'); + Segment.global = window; + }); + + it('should allow override of .campaign', function() { + Segment.global = { navigator: {}, location: {} }; + Segment.global.location.search = + '?utm_source=source&utm_medium=medium&utm_term=term&utm_content=content&utm_campaign=name'; + Segment.global.location.hostname = 'localhost'; + var object = { + context: { + campaign: { + source: 'overrideSource', + medium: 'overrideMedium', + term: 'overrideTerm', + content: 'overrideContent', + name: 'overrideName' + } + } + }; + segment.normalize(object); + analytics.assert(object); + analytics.assert(object.context); + analytics.assert(object.context.campaign); + analytics.assert(object.context.campaign.source === 'overrideSource'); + analytics.assert(object.context.campaign.medium === 'overrideMedium'); + analytics.assert(object.context.campaign.term === 'overrideTerm'); + analytics.assert(object.context.campaign.content === 'overrideContent'); + analytics.assert(object.context.campaign.name === 'overrideName'); + Segment.global = window; + }); + + it('should add .referrer.id and .referrer.type', function() { + Segment.global = { navigator: {}, location: {} }; + Segment.global.location.search = '?utm_source=source&urid=medium'; + Segment.global.location.hostname = 'localhost'; + segment.normalize(object); + analytics.assert(object); + analytics.assert(object.context); + analytics.assert(object.context.referrer); + analytics.assert(object.context.referrer.id === 'medium'); + analytics.assert(object.context.referrer.type === 'millennial-media'); + Segment.global = window; + }); + + it('should add .referrer.id and .referrer.type from cookie', function() { + segment.cookie( + 's:context.referrer', + '{"id":"baz","type":"millennial-media"}' + ); + Segment.global = { navigator: {}, location: {} }; + Segment.global.location.search = '?utm_source=source'; + Segment.global.location.hostname = 'localhost'; + segment.normalize(object); + analytics.assert(object); + analytics.assert(object.context); + analytics.assert(object.context.referrer); + analytics.assert(object.context.referrer.id === 'baz'); + analytics.assert(object.context.referrer.type === 'millennial-media'); + Segment.global = window; + }); + + it('should add .referrer.id and .referrer.type from cookie when no query is given', function() { + segment.cookie( + 's:context.referrer', + '{"id":"medium","type":"millennial-media"}' + ); + Segment.global = { navigator: {}, location: {} }; + Segment.global.location.search = ''; + Segment.global.location.hostname = 'localhost'; + segment.normalize(object); + analytics.assert(object); + analytics.assert(object.context); + analytics.assert(object.context.referrer); + analytics.assert(object.context.referrer.id === 'medium'); + analytics.assert(object.context.referrer.type === 'millennial-media'); + Segment.global = window; + }); + + it('should add .amp.id from store', function() { + segment.cookie('segment_amp_id', 'some-amp-id'); + segment.normalize(object); + analytics.assert(object); + analytics.assert(object.context); + analytics.assert(object.context.amp); + analytics.assert(object.context.amp.id === 'some-amp-id'); + }); + + it('should not add .amp if theres no segment_amp_id', function() { + segment.normalize(object); + analytics.assert(object); + analytics.assert(object.context); + analytics.assert(!object.context.amp); + }); + + it('should set xid from cookie, and context.traits is not defined', function() { + segment.cookie('seg_xid', 'test_id'); + segment.options.crossDomainIdServers = [ + 'userdata.example1.com', + 'xid.domain2.com', + 'localhost' + ]; + segment.options.saveCrossDomainIdInLocalStorage = false; + + segment.normalize(object); + assert.equal(object.context.traits.crossDomainId, 'test_id'); + }); + + it('should set xid from cookie, and context.traits is defined', function() { + segment.cookie('seg_xid', 'test_id'); + segment.options.crossDomainIdServers = [ + 'userdata.example1.com', + 'xid.domain2.com', + 'localhost' + ]; + segment.options.saveCrossDomainIdInLocalStorage = false; + + var msg = { context: { traits: { email: 'prateek@segment.com' } } }; + segment.normalize(msg); + assert.equal(msg.context.traits.crossDomainId, 'test_id'); + }); + + it('should set xid from localStorage, and context.traits is not defined', function() { + window.localStorage.setItem('seg_xid', 'test_id'); + segment.options.crossDomainIdServers = [ + 'userdata.example1.com', + 'xid.domain2.com', + 'localhost' + ]; + segment.options.saveCrossDomainIdInLocalStorage = true; + + segment.normalize(object); + assert.equal(object.context.traits.crossDomainId, 'test_id'); + }); + + it('should set xid from localStorage, is enabled, and context.traits is defined', function() { + window.localStorage.setItem('seg_xid', 'test_id'); + segment.options.crossDomainIdServers = [ + 'userdata.example1.com', + 'xid.domain2.com', + 'localhost' + ]; + segment.options.saveCrossDomainIdInLocalStorage = true; + + var msg = { context: { traits: { email: 'prateek@segment.com' } } }; + segment.normalize(msg); + assert.equal(msg.context.traits.crossDomainId, 'test_id'); + }); + + it('should not set xid if available, and is disabled', function() { + segment.cookie('seg_xid', 'test_id'); + segment.options.crossDomainIdServers = []; + + segment.normalize(object); + + // context.traits will not be set, which implicitly verifies that + // context.traits.crossDomainId is not set. + assert.equal(object.context.traits, undefined); + }); + + it('should not set xid if not available, and context.traits is not defined', function() { + segment.cookie('seg_xid', null); + segment.normalize(object); + // context.traits will not be set, which implicitly verifies that + // context.traits.crossDomainId is not set. + assert.equal(object.context.traits, undefined); + }); + + it('should not set xid if not available and context.traits is defined', function() { + segment.cookie('seg_xid', null); + + var msg = { context: { traits: { email: 'prateek@segment.com' } } }; + segment.normalize(msg); + assert.equal(msg.context.traits.crossDomainId, undefined); + }); + + describe('failed initializations', function() { + it('should add failedInitializations as part of _metadata object if this.analytics.failedInitilizations is not empty', function() { + var spy = sinon.spy(segment, 'normalize'); + var TestIntegration = integration('TestIntegration'); + TestIntegration.prototype.initialize = function() { + throw new Error('Uh oh!'); + }; + TestIntegration.prototype.page = function() {}; + var testIntegration = new TestIntegration(); + analytics.use(TestIntegration); + analytics.add(testIntegration); + analytics.initialize(); + analytics.page(); + assert( + spy.returnValues[0]._metadata.failedInitializations[0] === + 'TestIntegration' + ); + }); + }); + + describe('unbundling', function() { + var segment; + + beforeEach(function() { + var Analytics = analytics.constructor; + var ajs = new Analytics(); + segment = new Segment(options); + ajs.use(Segment); + ajs.use(integration('other')); + ajs.add(segment); + ajs.initialize({ other: {} }); + }); + + it('should add a list of bundled integrations when `addBundledMetadata` is set', function() { + segment.options.addBundledMetadata = true; + segment.normalize(object); + + assert(object); + assert(object._metadata); + assert.deepEqual(object._metadata.bundled, ['Segment.io', 'other']); + }); + + it('should add a list of unbundled integrations when `addBundledMetadata` and `unbundledIntegrations` are set', function() { + segment.options.addBundledMetadata = true; + segment.options.unbundledIntegrations = ['other2']; + segment.normalize(object); + + assert(object); + assert(object._metadata); + assert.deepEqual(object._metadata.unbundled, ['other2']); + }); + + it('should not add _metadata when `addBundledMetadata` is unset', function() { + segment.normalize(object); + + assert(object); + assert(!object._metadata); + }); + }); + + it('should pick up messageId from AJS', function() { + object = analytics.normalize(object); // ajs core generates the message ID here + var messageId = object.messageId; + segment.normalize(object); + assert.equal(object.messageId, messageId); + }); + }); + }); + + describe('after loading', function() { + beforeEach(function(done) { + analytics.once('ready', done); + analytics.initialize(); + analytics.page(); + }); + + describe('#settings', function() { + it('should have retryQueue enabled', function() { + analytics.assert(segment.options.retryQueue === true); + }); + }); + + var cases = { + 'retryQueue enabled': true, + 'retryQueue disabled': false + }; + + for (var scenario in cases) { + if (!cases.hasOwnProperty(scenario)) { + continue; + } + + describe('with ' + scenario, function() { + beforeEach(function() { + segment.options.retryQueue = cases[scenario]; + }); + + describe('#page', function() { + beforeEach(function() { + analytics.stub(segment, 'enqueue'); + }); + + it('should enqueue section, name and properties', function() { + analytics.page( + 'section', + 'name', + { property: true }, + { opt: true } + ); + var args = segment.enqueue.args[0]; + analytics.assert(args[0] === '/p'); + analytics.assert(args[1].name === 'name'); + analytics.assert(args[1].category === 'section'); + analytics.assert(args[1].properties.property === true); + analytics.assert(args[1].context.opt === true); + analytics.assert(args[1].timestamp); + }); + }); + + describe('#identify', function() { + beforeEach(function() { + analytics.stub(segment, 'enqueue'); + }); + + it('identify should not ultimately call getCachedCrossDomainId if crossDomainAnalytics is not enabled', function() { + segment.options.crossDomainIdServers = []; + var getCachedCrossDomainIdSpy = sinon.spy( + segment, + 'getCachedCrossDomainId' + ); + segment.normalize({}); + sinon.assert.notCalled(getCachedCrossDomainIdSpy); + segment.getCachedCrossDomainId.restore(); + }); + + it('should enqueue an id and traits', function() { + analytics.identify('id', { trait: true }, { opt: true }); + var args = segment.enqueue.args[0]; + analytics.assert(args[0] === '/i'); + analytics.assert(args[1].userId === 'id'); + analytics.assert(args[1].traits.trait === true); + analytics.assert(args[1].context.opt === true); + analytics.assert(args[1].timestamp); + }); + }); + + describe('#track', function() { + beforeEach(function() { + analytics.stub(segment, 'enqueue'); + }); + + it('should enqueue an event and properties', function() { + analytics.track('event', { prop: true }, { opt: true }); + var args = segment.enqueue.args[0]; + analytics.assert(args[0] === '/t'); + analytics.assert(args[1].event === 'event'); + analytics.assert(args[1].context.opt === true); + analytics.assert(args[1].properties.prop === true); + analytics.assert(args[1].traits == null); + analytics.assert(args[1].timestamp); + }); + }); + + describe('#group', function() { + beforeEach(function() { + analytics.stub(segment, 'enqueue'); + }); + + it('should enqueue groupId and traits', function() { + analytics.group('id', { trait: true }, { opt: true }); + var args = segment.enqueue.args[0]; + analytics.assert(args[0] === '/g'); + analytics.assert(args[1].groupId === 'id'); + analytics.assert(args[1].context.opt === true); + analytics.assert(args[1].traits.trait === true); + analytics.assert(args[1].timestamp); + }); + }); + + describe('#alias', function() { + beforeEach(function() { + analytics.stub(segment, 'enqueue'); + }); + + it('should enqueue .userId and .previousId', function() { + analytics.alias('to', 'from'); + var args = segment.enqueue.args[0]; + analytics.assert(args[0] === '/a'); + analytics.assert(args[1].previousId === 'from'); + analytics.assert(args[1].userId === 'to'); + analytics.assert(args[1].timestamp); + }); + + it('should fallback to user.anonymousId if .previousId is omitted', function() { + analytics.user().anonymousId('anon-id'); + analytics.alias('to'); + var args = segment.enqueue.args[0]; + analytics.assert(args[0] === '/a'); + analytics.assert(args[1].previousId === 'anon-id'); + analytics.assert(args[1].userId === 'to'); + analytics.assert(args[1].timestamp); + }); + + it('should fallback to user.anonymousId if .previousId and user.id are falsey', function() { + analytics.alias('to'); + var args = segment.enqueue.args[0]; + analytics.assert(args[0] === '/a'); + analytics.assert(args[1].previousId); + analytics.assert(args[1].previousId.length === 36); + analytics.assert(args[1].userId === 'to'); + }); + + it('should rename `.from` and `.to` to `.previousId` and `.userId`', function() { + analytics.alias('user-id', 'previous-id'); + var args = segment.enqueue.args[0]; + analytics.assert(args[0] === '/a'); + analytics.assert(args[1].previousId === 'previous-id'); + analytics.assert(args[1].userId === 'user-id'); + analytics.assert(args[1].from == null); + analytics.assert(args[1].to == null); + }); + }); + + describe('#enqueue', function() { + var xhr; + + beforeEach(function() { + analytics.spy(segment, 'session'); + sinon.spy(segment, 'debug'); + xhr = sinon.useFakeXMLHttpRequest(); + }); + + afterEach(function() { + if (xhr.restore) xhr.restore(); + if (segment.debug.restore) segment.debug.restore(); + }); + + it( + 'should use https: protocol when http:', + sinon.test(function() { + var spy = sinon.spy(); + xhr.onCreate = spy; + + protocol('http:'); + segment.enqueue('/i', { userId: 'id' }); + + assert(spy.calledOnce); + var req = spy.getCall(0).args[0]; + assert.strictEqual(req.url, 'https://api.segment.io/v1/i'); + }) + ); + + it( + 'should use https: protocol when https:', + sinon.test(function() { + var spy = sinon.spy(); + xhr.onCreate = spy; + + protocol('https:'); + segment.enqueue('/i', { userId: 'id' }); + + assert(spy.calledOnce); + var req = spy.getCall(0).args[0]; + assert.strictEqual(req.url, 'https://api.segment.io/v1/i'); + }) + ); + + it( + 'should use https: protocol when https:', + sinon.test(function() { + var spy = sinon.spy(); + xhr.onCreate = spy; + + protocol('file:'); + segment.enqueue('/i', { userId: 'id' }); + + assert(spy.calledOnce); + var req = spy.getCall(0).args[0]; + assert.strictEqual(req.url, 'https://api.segment.io/v1/i'); + }) + ); + + it( + 'should use https: protocol when chrome-extension:', + sinon.test(function() { + var spy = sinon.spy(); + xhr.onCreate = spy; + + protocol('chrome-extension:'); + segment.enqueue('/i', { userId: 'id' }); + + assert(spy.calledOnce); + var req = spy.getCall(0).args[0]; + assert.strictEqual(req.url, 'https://api.segment.io/v1/i'); + }) + ); + + it( + 'should enqueue to `api.segment.io/v1` by default', + sinon.test(function() { + var spy = sinon.spy(); + xhr.onCreate = spy; + + protocol('https:'); + segment.enqueue('/i', { userId: 'id' }); + + assert(spy.calledOnce); + var req = spy.getCall(0).args[0]; + assert.strictEqual(req.url, 'https://api.segment.io/v1/i'); + }) + ); + + it( + 'should enqueue to `options.apiHost` when set', + sinon.test(function() { + segment.options.apiHost = 'api.example.com'; + + var spy = sinon.spy(); + xhr.onCreate = spy; + + protocol('https:'); + segment.enqueue('/i', { userId: 'id' }); + + assert(spy.calledOnce); + var req = spy.getCall(0).args[0]; + assert.strictEqual(req.url, 'https://api.example.com/i'); + }) + ); + + it( + 'should enqueue a normalized payload', + sinon.test(function() { + var spy = sinon.spy(); + xhr.onCreate = spy; + + var payload = { + key1: 'value1', + key2: 'value2' + }; + + segment.normalize = function() { + return payload; + }; + + segment.enqueue('/i', {}); + + assert(spy.calledOnce); + var req = spy.getCall(0).args[0]; + assert.strictEqual(JSON.parse(req.requestBody).key1, 'value1'); + assert.strictEqual(JSON.parse(req.requestBody).key2, 'value2'); + }) + ); + + it( + 'should not log a normal payload', + sinon.test(function() { + var spy = sinon.spy(); + xhr.onCreate = spy; + + var payload = { + key1: 'value1', + key2: 'value2' + }; + + segment.normalize = function() { + return payload; + }; + + segment.enqueue('/i', {}); + + sinon.assert.neverCalledWith( + segment.debug, + 'message must be less than 32kb %O', + payload + ); + + assert(spy.calledOnce); + var req = spy.getCall(0).args[0]; + assert.strictEqual(JSON.parse(req.requestBody).key1, 'value1'); + assert.strictEqual(JSON.parse(req.requestBody).key2, 'value2'); + }) + ); + + it( + 'should enqueue an oversized payload', + sinon.test(function() { + var spy = sinon.spy(); + xhr.onCreate = spy; + + var payload = {}; + for (var i = 0; i < 1750; i++) { + payload['key' + i] = 'value' + i; + } + + segment.normalize = function() { + return payload; + }; + + segment.enqueue('/i', {}); + + sinon.assert.calledWith( + segment.debug, + 'message must be less than 32kb %O', + payload + ); + + assert(spy.calledOnce); + var req = spy.getCall(0).args[0]; + assert.strictEqual( + JSON.parse(req.requestBody).key1749, + 'value1749' + ); + }) + ); + }); + + // FIXME(ndhoule): See note at `isPhantomJS` definition + (isPhantomJS + ? xdescribe + : describe)('e2e tests — without queueing', function() { + beforeEach(function() { + segment.options.retryQueue = false; + }); + + describe('/g', function() { + it('should succeed', function(done) { + segment.enqueue('/g', { groupId: 'gid', userId: 'uid' }, function( + err, + res + ) { + if (err) return done(err); + analytics.assert(JSON.parse(res.responseText).success); + done(); + }); + }); + }); + + describe('/p', function() { + it('should succeed', function(done) { + var data = { userId: 'id', name: 'page', properties: {} }; + segment.enqueue('/p', data, function(err, res) { + if (err) return done(err); + analytics.assert(JSON.parse(res.responseText).success); + done(); + }); + }); + }); + + describe('/a', function() { + it('should succeed', function(done) { + var data = { userId: 'id', from: 'b', to: 'a' }; + segment.enqueue('/a', data, function(err, res) { + if (err) return done(err); + analytics.assert(JSON.parse(res.responseText).success); + done(); + }); + }); + }); + + describe('/t', function() { + it('should succeed', function(done) { + var data = { userId: 'id', event: 'my-event', properties: {} }; + + segment.enqueue('/t', data, function(err, res) { + if (err) return done(err); + analytics.assert(JSON.parse(res.responseText).success); + done(); + }); + }); + }); + + describe('/i', function() { + it('should succeed', function(done) { + var data = { userId: 'id' }; + + segment.enqueue('/i', data, function(err, res) { + if (err) return done(err); + analytics.assert(JSON.parse(res.responseText).success); + done(); + }); + }); + }); + }); + + (isPhantomJS + ? xdescribe + : describe)('e2e tests — with queueing', function() { + beforeEach(function() { + segment.options.retryQueue = true; + analytics.initialize(); + }); + + describe('/g', function() { + it('should succeed', function(done) { + segment._lsqueue.on('processed', function(err, res) { + if (err) return done(err); + analytics.assert(JSON.parse(res.responseText).success); + done(); + }); + segment.enqueue('/g', { groupId: 'gid', userId: 'uid' }); + }); + }); + + describe('/p', function() { + it('should succeed', function(done) { + segment._lsqueue.on('processed', function(err, res) { + if (err) return done(err); + analytics.assert(JSON.parse(res.responseText).success); + done(); + }); + segment.enqueue('/p', { + userId: 'id', + name: 'page', + properties: {} + }); + }); + }); + + describe('/a', function() { + it('should succeed', function(done) { + segment._lsqueue.on('processed', function(err, res) { + if (err) return done(err); + analytics.assert(JSON.parse(res.responseText).success); + done(); + }); + segment.enqueue('/a', { userId: 'id', from: 'b', to: 'a' }); + }); + }); + + describe('/t', function() { + it('should succeed', function(done) { + segment._lsqueue.on('processed', function(err, res) { + if (err) return done(err); + analytics.assert(JSON.parse(res.responseText).success); + done(); + }); + segment.enqueue('/t', { + userId: 'id', + event: 'my-event', + properties: {} + }); + }); + }); + + describe('/i', function() { + it('should succeed', function(done) { + segment._lsqueue.on('processed', function(err, res) { + if (err) return done(err); + analytics.assert(JSON.parse(res.responseText).success); + done(); + }); + segment.enqueue('/i', { userId: 'id' }); + }); + }); + }); + + describe('#cookie', function() { + beforeEach(function() { + segment.cookie('foo', null); + }); + + it('should persist the cookie even when the hostname is "dev"', function() { + Segment.global = { navigator: {}, location: {} }; + Segment.global.location.href = 'https://dev:300/path'; + analytics.assert(segment.cookie('foo') == null); + segment.cookie('foo', 'bar'); + analytics.assert(segment.cookie('foo') === 'bar'); + Segment.global = window; + }); + + it('should persist the cookie even when the hostname is "127.0.0.1"', function() { + Segment.global = { navigator: {}, location: {} }; + Segment.global.location.href = 'http://127.0.0.1:3000/'; + analytics.assert(segment.cookie('foo') == null); + segment.cookie('foo', 'bar'); + analytics.assert(segment.cookie('foo') === 'bar'); + Segment.global = window; + }); + + it('should persist the cookie even when the hostname is "app.herokuapp.com"', function() { + Segment.global = { navigator: {}, location: {} }; + Segment.global.location.href = 'https://app.herokuapp.com/about'; + Segment.global.location.hostname = 'app.herokuapp.com'; + analytics.assert(segment.cookie('foo') == null); + segment.cookie('foo', 'bar'); + analytics.assert(segment.cookie('foo') === 'bar'); + Segment.global = window; + }); + }); + + describe('#crossDomainId', function() { + var server; + + beforeEach(function() { + server = sinon.fakeServer.create(); + segment.options.crossDomainIdServers = [ + 'userdata.example1.com', + 'xid.domain2.com', + 'localhost' + ]; + analytics.stub(segment, 'onidentify'); + }); + + afterEach(function() { + server.restore(); + }); + + it('should not crash with invalid config', function() { + segment.options.crossDomainIdServers = undefined; + + var res = null; + var err = null; + segment.retrieveCrossDomainId(function(error, response) { + res = response; + err = error; + }); + + analytics.assert(!res); + analytics.assert(err === 'crossDomainId not enabled'); + }); + + it('should use cached cross domain identifier from LS when saveCrossDomainIdInLocalStorage is true', function() { + segment.options.crossDomainIdServers = ['localhost']; + segment.options.saveCrossDomainIdInLocalStorage = true; + + store('seg_xid', 'test_xid_cache_ls'); + + var res = null; + var err = null; + segment.retrieveCrossDomainId(function(error, response) { + res = response; + err = error; + }); + + assert.isNull(err); + assert.deepEqual(res, { + crossDomainId: 'test_xid_cache_ls' + }); + }); + + it('should use cached cross domain identifier from cookies when saveCrossDomainIdInLocalStorage is false', function() { + segment.options.crossDomainIdServers = ['localhost']; + segment.options.saveCrossDomainIdInLocalStorage = false; + + segment.cookie('seg_xid', 'test_xid_cache_cookie'); + + var res = null; + var err = null; + segment.retrieveCrossDomainId(function(error, response) { + res = response; + err = error; + }); + + assert.isNull(err); + assert.deepEqual(res, { + crossDomainId: 'test_xid_cache_cookie' + }); + }); + + describe('getCachedCrossDomainId', function() { + it('should return identifiers from localstorage when saveCrossDomainIdInLocalStorage is true', function() { + store('seg_xid', 'test_xid_cache_ls'); + segment.cookie('seg_xid', 'test_xid_cache_cookie'); + + segment.options.saveCrossDomainIdInLocalStorage = true; + + assert.equal( + segment.getCachedCrossDomainId(), + 'test_xid_cache_ls' + ); + }); + + it('should return identifiers from localstorage when saveCrossDomainIdInLocalStorage is true', function() { + store('seg_xid', 'test_xid_cache_ls'); + segment.cookie('seg_xid', 'test_xid_cache_cookie'); + + segment.options.saveCrossDomainIdInLocalStorage = false; + + assert.equal( + segment.getCachedCrossDomainId(), + 'test_xid_cache_cookie' + ); + }); + }); + + var cases = { + 'saveCrossDomainIdInLocalStorage true': true, + 'saveCrossDomainIdInLocalStorage false': false + }; + + for (var scenario in cases) { + if (!cases.hasOwnProperty(scenario)) { + continue; + } + + describe('with ' + scenario, function() { + it('should generate xid locally if there is only one (current hostname) server', function() { + segment.options.crossDomainIdServers = ['localhost']; + segment.options.saveCrossDomainIdInLocalStorage = + cases[scenario]; + + var res = null; + segment.retrieveCrossDomainId(function(err, response) { + res = response; + }); + + var identify = segment.onidentify.args[0]; + var crossDomainId = identify[0].traits().crossDomainId; + analytics.assert(crossDomainId); + + analytics.assert(res.crossDomainId === crossDomainId); + analytics.assert(res.fromDomain === 'localhost'); + + assert.equal(segment.getCachedCrossDomainId(), crossDomainId); + }); + + it('should obtain crossDomainId', function() { + server.respondWith( + 'GET', + 'https://xid.domain2.com/v1/id/' + segment.options.apiKey, + [ + 200, + { 'Content-Type': 'application/json' }, + '{ "id": "xdomain-id-1" }' + ] + ); + if (segment.options.saveCrossDomainIdInLocalStorage) { + server.respondWith( + 'GET', + 'https://localhost/v1/saveId?writeKey=' + + segment.options.apiKey + + '&xid=xdomain-id-1', + [200, { 'Content-Type': 'text/plan' }, 'OK'] + ); + } + server.respondImmediately = true; + + var res = null; + segment.retrieveCrossDomainId(function(err, response) { + res = response; + }); + + var identify = segment.onidentify.args[0]; + analytics.assert( + identify[0].traits().crossDomainId === 'xdomain-id-1' + ); + + analytics.assert(res.crossDomainId === 'xdomain-id-1'); + analytics.assert(res.fromDomain === 'xid.domain2.com'); + + assert.equal(segment.getCachedCrossDomainId(), 'xdomain-id-1'); + }); + + it('should generate crossDomainId if no server has it', function() { + server.respondWith( + 'GET', + 'https://xid.domain2.com/v1/id/' + segment.options.apiKey, + [ + 200, + { 'Content-Type': 'application/json' }, + '{ "id": null }' + ] + ); + server.respondWith( + 'GET', + 'https://userdata.example1.com/v1/id/' + + segment.options.apiKey, + [ + 200, + { 'Content-Type': 'application/json' }, + '{ "id": null }' + ] + ); + if (segment.options.saveCrossDomainIdInLocalStorage) { + server.respondWith('GET', /https:\/\/localhost\/v1\/saveId/, [ + 200, + { 'Content-Type': 'text/plan' }, + 'OK' + ]); + } + server.respondImmediately = true; + + var res = null; + segment.retrieveCrossDomainId(function(err, response) { + res = response; + }); + + var identify = segment.onidentify.args[0]; + var crossDomainId = identify[0].traits().crossDomainId; + analytics.assert(crossDomainId); + + analytics.assert(res.crossDomainId === crossDomainId); + analytics.assert(res.fromDomain === 'localhost'); + + assert.equal(segment.getCachedCrossDomainId(), crossDomainId); + }); + + it('should bail if all servers error', function() { + var err = null; + var res = null; + segment.retrieveCrossDomainId(function(error, response) { + err = error; + res = response; + }); + + server.respondWith( + 'GET', + 'https://xid.domain2.com/v1/id/' + segment.options.apiKey, + [500, { 'Content-Type': 'application/json' }, ''] + ); + server.respondWith( + 'GET', + 'https://userdata.example1.com/v1/id/' + + segment.options.apiKey, + [500, { 'Content-Type': 'application/json' }, ''] + ); + server.respond(); + + var identify = segment.onidentify.args[0]; + analytics.assert(!identify); + analytics.assert(!res); + analytics.assert(err === 'Internal Server Error'); + + assert.equal(segment.getCachedCrossDomainId(), null); + }); + + it('should bail if some servers fail and others have no xid', function() { + var err = null; + var res = null; + segment.retrieveCrossDomainId(function(error, response) { + err = error; + res = response; + }); + + server.respondWith( + 'GET', + 'https://xid.domain2.com/v1/id/' + segment.options.apiKey, + [400, { 'Content-Type': 'application/json' }, ''] + ); + server.respondWith( + 'GET', + 'https://userdata.example1.com/v1/id/' + + segment.options.apiKey, + [ + 200, + { 'Content-Type': 'application/json' }, + '{ "id": null }' + ] + ); + server.respond(); + + var identify = segment.onidentify.args[0]; + analytics.assert(!identify); + analytics.assert(!res); + analytics.assert(err === 'Bad Request'); + + assert.equal(segment.getCachedCrossDomainId(), null); + }); + + it('should succeed even if one server fails', function() { + server.respondWith( + 'GET', + 'https://xid.domain2.com/v1/id/' + segment.options.apiKey, + [500, { 'Content-Type': 'application/json' }, ''] + ); + server.respondWith( + 'GET', + 'https://userdata.example1.com/v1/id/' + + segment.options.apiKey, + [ + 200, + { 'Content-Type': 'application/json' }, + '{ "id": "xidxid" }' + ] + ); + if (segment.options.saveCrossDomainIdInLocalStorage) { + server.respondWith( + 'GET', + 'https://localhost/v1/saveId?writeKey=' + + segment.options.apiKey + + '&xid=xidxid', + [200, { 'Content-Type': 'text/plan' }, 'OK'] + ); + } + server.respondImmediately = true; + + var err = null; + var res = null; + segment.retrieveCrossDomainId(function(error, response) { + err = error; + res = response; + }); + + var identify = segment.onidentify.args[0]; + analytics.assert( + identify[0].traits().crossDomainId === 'xidxid' + ); + + analytics.assert(res.crossDomainId === 'xidxid'); + analytics.assert(res.fromDomain === 'userdata.example1.com'); + analytics.assert(!err); + + assert.equal(segment.getCachedCrossDomainId(), 'xidxid'); + }); + }); + } + + describe('isCrossDomainAnalyticsEnabled', function() { + it('should return false when crossDomainIdServers is undefined', function() { + segment.options.crossDomainIdServers = undefined; + + assert.equal(segment.isCrossDomainAnalyticsEnabled(), false); + }); + + it('should return false when crossDomainIdServers is empty', function() { + segment.options.crossDomainIdServers = []; + + assert.equal(segment.isCrossDomainAnalyticsEnabled(), false); + }); + + it('should return true when crossDomainIdServers is set', function() { + segment.options.crossDomainIdServers = [ + 'userdata.example1.com', + 'xid.domain2.com', + 'localhost' + ]; + + assert.equal(segment.isCrossDomainAnalyticsEnabled(), true); + }); + + it('should return true even when crossDomainIdServers is set with 1 server', function() { + segment.options.crossDomainIdServers = ['localhost']; + + assert.equal(segment.isCrossDomainAnalyticsEnabled(), true); + }); + }); + + describe('deleteCrossDomainId', function() { + it('should not delete cross domain identifiers by default', function() { + segment.cookie('seg_xid', 'test_xid'); + segment.cookie('seg_xid_ts', 'test_xid_ts'); + segment.cookie('seg_xid_fd', 'test_xid_fd'); + analytics.identify({ + crossDomainId: 'test_xid' + }); + + segment.deleteCrossDomainIdIfNeeded(); + + assert.equal(segment.cookie('seg_xid'), 'test_xid'); + assert.equal(segment.cookie('seg_xid_ts'), 'test_xid_ts'); + assert.equal(segment.cookie('seg_xid_fd'), 'test_xid_fd'); + assert.equal(analytics.user().traits().crossDomainId, 'test_xid'); + }); + + it('should do not delete cross domain identifiers if disabled', function() { + segment.options.deleteCrossDomainId = false; + + segment.cookie('seg_xid', 'test_xid'); + segment.cookie('seg_xid_ts', 'test_xid_ts'); + segment.cookie('seg_xid_fd', 'test_xid_fd'); + analytics.identify({ + crossDomainId: 'test_xid' + }); + + segment.deleteCrossDomainIdIfNeeded(); + + assert.equal(segment.cookie('seg_xid'), 'test_xid'); + assert.equal(segment.cookie('seg_xid_ts'), 'test_xid_ts'); + assert.equal(segment.cookie('seg_xid_fd'), 'test_xid_fd'); + assert.equal(analytics.user().traits().crossDomainId, 'test_xid'); + }); + + it('should delete cross domain identifiers if enabled', function() { + segment.options.deleteCrossDomainId = true; + + segment.cookie('seg_xid', 'test_xid'); + segment.cookie('seg_xid_ts', 'test_xid_ts'); + segment.cookie('seg_xid_fd', 'test_xid_fd'); + store('seg_xid', 'test_xid'); + + analytics.identify({ + crossDomainId: 'test_xid' + }); + + segment.deleteCrossDomainIdIfNeeded(); + + assert.equal(segment.cookie('seg_xid'), null); + assert.equal(segment.cookie('seg_xid_ts'), null); + assert.equal(segment.cookie('seg_xid_fd'), null); + assert.equal(store('seg_xid'), null); + assert.equal(analytics.user().traits().crossDomainId, null); + }); + + it('should delete localStorage trait even if only traits exists', function() { + segment.options.deleteCrossDomainId = true; + + analytics.identify({ + crossDomainId: 'test_xid' + }); + + segment.deleteCrossDomainIdIfNeeded(); + + assert.equal(analytics.user().traits().crossDomainId, null); + }); + + it('should delete xid cookie even if only cookie exists', function() { + segment.options.deleteCrossDomainId = true; + + segment.cookie('seg_xid', 'test_xid'); + + segment.deleteCrossDomainIdIfNeeded(); + + assert.equal(segment.cookie('seg_xid'), null); + assert.equal(segment.cookie('seg_xid_ts'), null); + assert.equal(segment.cookie('seg_xid_fd'), null); + }); + + it('should not delete any other traits if enabled', function() { + segment.options.deleteCrossDomainId = true; + + analytics.identify({ + crossDomainId: 'test_xid', + name: 'Prateek', + age: 26 + }); + + segment.deleteCrossDomainIdIfNeeded(); + + assert.deepEqual(analytics.user().traits(), { + name: 'Prateek', + age: 26 + }); + }); + }); + }); + }); + } + }); + + describe('localStorage queueing', function() { + var xhr; + + beforeEach(function(done) { + xhr = sinon.useFakeXMLHttpRequest(); + analytics.once('ready', done); + segment.options.retryQueue = true; + analytics.initialize(); + }); + + afterEach(function() { + segment._lsqueue.stop(); + xhr.restore(); + }); + + it('#enqueue should add to the retry queue', function() { + analytics.stub(segment._lsqueue, 'addItem'); + segment.enqueue('/i', { userId: '1' }); + assert(segment._lsqueue.addItem.calledOnce); + }); + + it('should send requests', function() { + var spy = sinon.spy(); + xhr.onCreate = spy; + + segment.enqueue('/i', { userId: '1' }); + + assert(spy.calledOnce); + var req = spy.getCall(0).args[0]; + var body = JSON.parse(req.requestBody); + assert.equal(body.userId, '1'); + }); + + it('should retry on HTTP errors', function() { + var clock = lolex.createClock(0); + var spy = sinon.spy(); + + Schedule.setClock(clock); + xhr.onCreate = spy; + + segment.enqueue('/i', { userId: '1' }); + assert(spy.calledOnce); + + var req = spy.getCall(0).args[0]; + req.respond(500, null, 'segment machine broke'); + + clock.tick(segment._lsqueue.getDelay(1)); + assert(spy.calledTwice); + }); + }); + + describe('sendJsonWithTimeout', function() { + var protocol = location.protocol; + var hostname = location.hostname; + var port = location.port; + var endpoint = '/base/data'; + var url = protocol + '//' + hostname + ':' + port + endpoint; + + var headers = { 'Content-Type': 'application/json' }; + + it('should timeout', function(done) { + if (send.type !== 'xhr') return done(); + + Segment.sendJsonWithTimeout(url, [1, 2, 3], headers, 1, function(err) { + assert(err !== null); + assert(err.type === 'timeout'); + done(); + }); + }); + + it('should work', function(done) { + if (send.type !== 'xhr') return done(); + + Segment.sendJsonWithTimeout(url, [1, 2, 3], headers, 10 * 1000, function( + err, + req + ) { + if (err) { + return done(new Error(err.message)); + } + var res = JSON.parse(req.responseText); + assert(res === true); + done(); + }); + }); + + describe('error handling', function() { + var xhr; + var req; + + beforeEach(function() { + xhr = sinon.useFakeXMLHttpRequest(); + xhr.onCreate = function(_req) { + req = _req; + }; + }); + + afterEach(function() { + xhr.restore(); + }); + + [429, 500, 503].forEach(function(code) { + it('should throw on ' + code + ' HTTP errors', function(done) { + if (send.type !== 'xhr') return done(); + + Segment.sendJsonWithTimeout( + url + '/null', + [1, 2, 3], + headers, + 10 * 1000, + function(err) { + assert( + RegExp('^HTTP Error ' + code + ' (.+)$').test(err.message) + ); + done(); + } + ); + + req.respond(code, null, 'nope'); + }); + }); + + [200, 204, 300, 302, 400, 404].forEach(function(code) { + it('should not throw on ' + code + ' HTTP errors', function(done) { + if (send.type !== 'xhr') return done(); + + Segment.sendJsonWithTimeout( + url + '/null', + [1, 2, 3], + headers, + 10 * 1000, + done + ); + + req.respond(code, null, 'ok'); + }); + }); + }); + }); +}); diff --git a/karma.conf.js b/karma.conf.js index c89e1fa5e..91dcafdb0 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -15,6 +15,41 @@ module.exports = function(config) { reporters: ['spec'], - browsers: ['ChromeHeadless'] + browsers: ['ChromeHeadless'], + + middleware: ['server'], + + plugins: [ + 'karma-*', + { + 'middleware:server': [ + 'factory', + function() { + return function(request, response, next) { + if (request.url === '/base/data' && request.method === 'POST') { + var body = ''; + + request.on('data', function(data) { + body += data; + }); + + request.on('end', function() { + try { + var data = JSON.parse(body); + response.writeHead(data.length === 3 ? 200 : 400); + return response.end(String(data.length === 3)); + } catch (err) { + response.writeHead(500); + return response.end(); + } + }); + } else { + next(); + } + }; + } + ] + } + ] }); }; diff --git a/yarn.lock b/yarn.lock index c5f4e61cb..7263202f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -947,6 +947,13 @@ dependencies: any-observable "^0.3.0" +"@segment/ad-params@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@segment/ad-params/-/ad-params-1.0.0.tgz#e02ded70a7f8db952af03c21208f47201b86bc95" + integrity sha1-4C3tcKf425Uq8DwhII9HIBuGvJU= + dependencies: + component-querystring "^2.0.0" + "@segment/alias@^1.0.1": version "1.0.2" resolved "https://registry.yarnpkg.com/@segment/alias/-/alias-1.0.2.tgz#1ce0d2a28df59706a1b5c92fb99c0c48adc22ec1" @@ -997,6 +1004,47 @@ spark-md5 "^2.0.2" uuid "^2.0.2" +"@segment/analytics.js-core@^3.8.0": + version "3.10.1" + resolved "https://registry.yarnpkg.com/@segment/analytics.js-core/-/analytics.js-core-3.10.1.tgz#faf983dbddb5917de3ff500843c04a2cc4236ab7" + integrity sha512-YfI4Zu1YQpQ+HZ0cVvnkEeFaKLJrVkQTN0P9yDPTgfHiK5aPIPyiTPvYNb17cLZX4j2PW+rvpDCi9RjVpFws0A== + dependencies: + "@ndhoule/clone" "^1.0.0" + "@ndhoule/defaults" "^2.0.1" + "@ndhoule/each" "^2.0.1" + "@ndhoule/extend" "^2.0.0" + "@ndhoule/foldl" "^2.0.1" + "@ndhoule/includes" "^2.0.1" + "@ndhoule/keys" "^2.0.0" + "@ndhoule/map" "^2.0.1" + "@ndhoule/pick" "^2.0.0" + "@segment/canonical" "^1.0.0" + "@segment/is-meta" "^1.0.0" + "@segment/isodate" "^1.0.2" + "@segment/isodate-traverse" "^1.0.1" + "@segment/prevent-default" "^1.0.0" + "@segment/send-json" "^3.0.0" + "@segment/store" "^1.3.20" + "@segment/top-domain" "^3.0.0" + bind-all "^1.0.0" + component-cookie "^1.1.2" + component-emitter "^1.2.1" + component-event "^0.1.4" + component-querystring "^2.0.0" + component-type "^1.2.1" + component-url "^0.2.1" + debug "^0.7.4" + extend "3.0.2" + inherits "^2.0.1" + install "^0.7.3" + is "^3.1.0" + json3 "^3.3.2" + new-date "^1.0.0" + next-tick "^0.2.2" + segmentio-facade "^3.0.2" + spark-md5 "^2.0.2" + uuid "^2.0.2" + "@segment/analytics.js-core@^3.8.2": version "3.8.2" resolved "https://registry.yarnpkg.com/@segment/analytics.js-core/-/analytics.js-core-3.8.2.tgz#97bd4d9b4882a44488d01eb3ef97958f98573cdd" @@ -1258,11 +1306,28 @@ next-tick "^0.2.2" script-onload "^1.0.2" +"@segment/localstorage-retry@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@segment/localstorage-retry/-/localstorage-retry-1.2.2.tgz#d141fab7212fd3efb0ee19bf7eb16b1e38930af3" + integrity sha1-0UH6tyEv0++w7hm/frFrHjiTCvM= + dependencies: + "@ndhoule/each" "^2.0.1" + "@ndhoule/keys" "^2.0.0" + component-emitter "^1.2.1" + debug "^0.7.4" + json3 "^3.3.2" + uuid "^3.0.1" + "@segment/prevent-default@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@segment/prevent-default/-/prevent-default-1.0.0.tgz#2ac896ee8c0249dc7af4ac032f8df900fe31892e" integrity sha1-KsiW7owCSdx69KwDL435AP4xiS4= +"@segment/protocol@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@segment/protocol/-/protocol-1.0.0.tgz#b92d39db580ab94c0e4d0cab09239dd0c3a3f69b" + integrity sha1-uS0521gKuUwOTQyrCSOd0MOj9ps= + "@segment/send-json@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@segment/send-json/-/send-json-3.0.0.tgz#f79e70efbd01b62361f5a2cf3fb67e91de43135e" @@ -1320,6 +1385,14 @@ resolved "https://registry.yarnpkg.com/@segment/trample/-/trample-0.2.0.tgz#5b141159f67b06efaa295d2ebe240b51096134c5" integrity sha1-WxQRWfZ7Bu+qKV0uviQLUQlhNMU= +"@segment/utm-params@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@segment/utm-params/-/utm-params-2.0.0.tgz#fea3c8a92bfba0d69e861fb3b26d7d882f139334" + integrity sha1-/qPIqSv7oNaehh+zsm19iC8TkzQ= + dependencies: + "@ndhoule/foldl" "^2.0.1" + component-querystring "^2.0.0" + "@sinonjs/commons@^1", "@sinonjs/commons@^1.0.2", "@sinonjs/commons@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.4.0.tgz#7b3ec2d96af481d7a0321252e7b1c94724ec5a78" @@ -2603,7 +2676,7 @@ component-cookie@^1.1.2, component-cookie@^1.1.4: dependencies: debug "2.2.0" -component-each@^0.2.6: +component-each@*, component-each@^0.2.6: version "0.2.6" resolved "https://registry.yarnpkg.com/component-each/-/component-each-0.2.6.tgz#991faf31ef4fcafbad04237124d381b3394941d5" integrity sha1-mR+vMe9PyvutBCNxJNOBszlJQdU= @@ -8289,7 +8362,7 @@ simple-git@^1.85.0: dependencies: debug "^4.0.1" -sinon@^1.17.6: +sinon@^1.17.4, sinon@^1.17.6: version "1.17.7" resolved "https://registry.yarnpkg.com/sinon/-/sinon-1.17.7.tgz#4542a4f49ba0c45c05eb2e9dd9d203e2b8efe0bf" integrity sha1-RUKk9JugxFwF6y6d2dID4rjv4L8= @@ -9715,6 +9788,19 @@ yeast@0.1.2: resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk= +yields-store@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/yields-store/-/yields-store-1.0.2.tgz#af16de4576ae323461ae27eafb01d0d5718e3649" + integrity sha1-rxbeRXauMjRhrifq+wHQ1XGONkk= + dependencies: + component-each "*" + yields-unserialize "*" + +yields-unserialize@*: + version "0.0.1" + resolved "https://registry.yarnpkg.com/yields-unserialize/-/yields-unserialize-0.0.1.tgz#75ab898ba307be40184293d931b2f6e3942b0bc4" + integrity sha1-dauJi6MHvkAYQpPZMbL245QrC8Q= + yup@^0.27.0: version "0.27.0" resolved "https://registry.yarnpkg.com/yup/-/yup-0.27.0.tgz#f8cb198c8e7dd2124beddc2457571329096b06e7"