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