From 1a11c4af128fa8f4d80d1e3c18e46d3b2dfac04a Mon Sep 17 00:00:00 2001 From: Carlos Mecha Date: Fri, 22 Nov 2019 09:17:59 -0800 Subject: [PATCH] Add integration segmentio This commit copies the content of the integration repo into the "integrations" folder. Original repo: https://github.com/segment-integrations/analytics.js-integration-segmentio Readme: https://github.com/segment-integrations/analytics.js-integration-segmentio/blob/master/README.md --- integrations/segmentio/HISTORY.md | 186 +++ integrations/segmentio/README.md | 12 + integrations/segmentio/RELEASING.md | 9 + integrations/segmentio/karma.conf.coverage.js | 44 + integrations/segmentio/lib/index.js | 702 ++++++++ integrations/segmentio/package.json | 81 + integrations/segmentio/test/.eslintrc | 3 + integrations/segmentio/test/index.test.js | 1454 +++++++++++++++++ 8 files changed, 2491 insertions(+) create mode 100644 integrations/segmentio/HISTORY.md create mode 100644 integrations/segmentio/README.md create mode 100644 integrations/segmentio/RELEASING.md create mode 100644 integrations/segmentio/karma.conf.coverage.js create mode 100644 integrations/segmentio/lib/index.js create mode 100644 integrations/segmentio/package.json create mode 100644 integrations/segmentio/test/.eslintrc create mode 100644 integrations/segmentio/test/index.test.js 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/RELEASING.md b/integrations/segmentio/RELEASING.md new file mode 100644 index 000000000..b33686e36 --- /dev/null +++ b/integrations/segmentio/RELEASING.md @@ -0,0 +1,9 @@ +# RELEASING + +We automatically publish Github tagged releases from our CI to NPM. + +We use [`np`](https://github.com/sindresorhus/np) to prepare a release. + +`np` will be automatically installed by running `yarn`, and you can run it with `yarn run np`. You can pass flags to it just as you would with `np`, e.g. you can run `yarn np minor`. + +If you run `np` directly, take care to use the [`--no-publish`](https://github.com/sindresorhus/np#publish-with-a-ci) flag. This ensures that we don't directly publish to NPM from a local dev machine. diff --git a/integrations/segmentio/karma.conf.coverage.js b/integrations/segmentio/karma.conf.coverage.js new file mode 100644 index 000000000..2c74fc79a --- /dev/null +++ b/integrations/segmentio/karma.conf.coverage.js @@ -0,0 +1,44 @@ +/* eslint-env node */ +'use strict'; + +var baseConfig = require('./karma.conf'); + +module.exports = function(config) { + baseConfig(config); + + config.set({ + singleRun: true, + + reporters: ['spec', 'summary', 'junit', 'coverage'], + + specReporter: { + suppressPassed: true + }, + + junitReporter: { + outputDir: 'junit-reports', + suite: require('./package.json').name + }, + + coverageReporter: { + reporters: [ + { type: 'lcovonly', subdir: '.' }, + { type: 'json', subdir: '.' } + ] + }, + + browserify: { + debug: true, + transform: [ + [ + 'browserify-istanbul', + { + instrumenterConfig: { + embedSource: true + } + } + ] + ] + } + }); +}; \ No newline at end of file diff --git a/integrations/segmentio/lib/index.js b/integrations/segmentio/lib/index.js new file mode 100644 index 000000000..42a24c1c4 --- /dev/null +++ b/integrations/segmentio/lib/index.js @@ -0,0 +1,702 @@ +'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(item, done) { + // apply sentAt at flush time and reset on each retry + // so the tracking-api doesn't interpret a time skew + 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(msg) { + 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, msg, fn) { + var url = 'https://' + this.options.apiHost + path; + var headers = { 'Content-Type': 'text/plain' }; + msg = this.normalize(msg); + + // 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 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..920fa0534 --- /dev/null +++ b/integrations/segmentio/package.json @@ -0,0 +1,81 @@ +{ + "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": { + "commitmsg": "commitlint -E GIT_PARAMS", + "cz": "git-cz", + "np": "np --no-publish", + "test": "make test" + }, + "author": "Segment \u003cfriends@segment.com\u003e", + "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": { + "@commitlint/cli": "^8.0.0", + "@commitlint/config-conventional": "^8.0.0", + "@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": "^13.0.0", + "browserify-istanbul": "^2.0.0", + "codecov": "^3.5.0", + "commitizen": "^3.1.1", + "commitlint-circle": "^1.0.0", + "cz-conventional-changelog": "^2.1.0", + "eslint": "^2.9.0", + "eslint-plugin-mocha": "^2.2.0", + "eslint-plugin-require-path-exists": "^1.1.5", + "istanbul": "^0.4.3", + "karma": "1.3.0", + "karma-browserify": "^5.0.4", + "karma-chrome-launcher": "^1.0.1", + "karma-coverage": "^1.0.0", + "karma-junit-reporter": "^1.0.0", + "karma-mocha": "1.0.1", + "karma-phantomjs-launcher": "^1.0.0", + "karma-sauce-launcher": "^1.0.0", + "karma-spec-reporter": "0.0.26", + "lolex": "^2.7.1", + "mocha": "^2.2.5", + "np": "^3.0.4", + "npm-check": "^5.2.1", + "phantomjs-prebuilt": "^2.1.7", + "proclaim": "^3.4.1", + "sinon": "^1.17.4", + "snyk": "^1.177.0", + "watchify": "^3.7.0" + } +} \ No newline at end of file diff --git a/integrations/segmentio/test/.eslintrc b/integrations/segmentio/test/.eslintrc new file mode 100644 index 000000000..bab7e8304 --- /dev/null +++ b/integrations/segmentio/test/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "@segment/eslint-config/mocha" +} diff --git a/integrations/segmentio/test/index.test.js b/integrations/segmentio/test/index.test.js new file mode 100644 index 000000000..87d5ac7dc --- /dev/null +++ b/integrations/segmentio/test/index.test.js @@ -0,0 +1,1454 @@ +'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) { + if (err !== null) { + assert(err.type === 'timeout'); + } else { + // Fail instead of hang if test didn't timeout properly + assert(false); + } + 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'); + }); + }); + }); + }); +});