import formurlencoded from 'form-urlencoded';
import { snakeCaseObject } from '../utils';
/**
* @implements {AnalyticsService}
* @memberof module:Analytics
*/
class SegmentAnalyticsService {
constructor({ httpClient, loggingService, config }) {
this.loggingService = loggingService;
this.httpClient = httpClient;
this.trackingLogApiUrl = `${config.LMS_BASE_URL}/event`;
this.segmentKey = config.SEGMENT_KEY;
this.hasIdentifyBeenCalled = false;
this.segmentInitialized = false;
if (this.segmentKey) {
this.initializeSegment();
}
}
// The code in this function is from Segment's website, with a few updates:
// - It uses the segmentKey from the SegmentAnalyticsService instance.
// - It also saves a "segmentInitialized" variable on the SegmentAnalyticsService instance so
// that the service can keep track of its own initialization state.
// Reference:
// https://segment.com/docs/connections/sources/catalog/libraries/website/javascript/quickstart/
initializeSegment() {
// Create a queue, but don't obliterate an existing one!
global.analytics = global.analytics || [];
const { analytics } = global;
// If the real analytics.js is already on the page return.
if (analytics.initialize) {
this.segmentInitialized = true;
return;
}
// If the snippet was invoked do nothing.
if (analytics.invoked) {
this.segmentInitialized = true;
return;
}
// Invoked flag, to make sure the snippet
// is never invoked twice.
analytics.invoked = true;
// A list of the methods in Analytics.js to stub.
analytics.methods = [
'trackSubmit',
'trackClick',
'trackLink',
'trackForm',
'pageview',
'identify',
'reset',
'group',
'track',
'ready',
'alias',
'debug',
'page',
'once',
'off',
'on',
];
// Define a factory to create stubs. These are placeholders
// for methods in Analytics.js so that you never have to wait
// for it to load to actually record data. The `method` is
// stored as the first argument, so we can replay the data.
analytics.factory = method => ((...args) => {
args.unshift(method);
analytics.push(args);
return analytics;
});
// For each of our methods, generate a queueing stub.
analytics.methods.forEach((key) => {
analytics[key] = analytics.factory(key);
});
// Define a method to load Analytics.js from our CDN,
// and that will be sure to only ever load it once.
analytics.load = (key, options) => {
// Create an async script element based on your key.
const script = document.createElement('script');
script.type = 'text/javascript';
script.onerror = () => {
this.segmentInitialized = false;
const event = new Event('segmentFailed');
document.dispatchEvent(event);
};
script.async = true;
script.src = `https://cdn.segment.com/analytics.js/v1/${key}/analytics.min.js`;
// Insert our script next to the first script element.
const first = document.getElementsByTagName('script')[0];
first.parentNode.insertBefore(script, first);
analytics._loadOptions = options; // eslint-disable-line no-underscore-dangle
this.segmentInitialized = true;
};
// Add a version to keep track of what's in the wild.
analytics.SNIPPET_VERSION = '4.1.0';
// Load Analytics.js with your key, which will automatically
// load the tools you've enabled for your account. Boosh!
analytics.load(this.segmentKey);
}
/**
* Checks that identify was first called. Otherwise, logs error.
*
*/
checkIdentifyCalled() {
if (!this.hasIdentifyBeenCalled) {
this.loggingService.logError('Identify must be called before other tracking events.');
}
}
/**
* Logs events to tracking log and downstream.
* For tracking log event documentation, see
* https://openedx.atlassian.net/wiki/spaces/AN/pages/13205895/Event+Design+and+Review+Process
*
* @param {string} eventName (event_type on backend, but named to match Segment api)
* @param {Object} properties (event on backend, but named properties to match Segment api)
* @returns {Promise} The promise returned by HttpClient.post.
*/
sendTrackingLogEvent(eventName, properties) {
const snakeEventData = snakeCaseObject(properties, { deep: true });
const serverData = {
event_type: eventName,
event: JSON.stringify(snakeEventData),
page: global.location.href,
};
return this.httpClient.post(
this.trackingLogApiUrl,
formurlencoded(serverData),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
).catch((error) => {
this.loggingService.logError(error);
});
}
/**
* * Send identify call to Segment.
*
* @param {string} userId
* @param {*} [traits]
*/
identifyAuthenticatedUser(userId, traits) {
if (!userId) {
throw new Error('UserId is required for identifyAuthenticatedUser.');
}
if (!this.segmentInitialized) {
return;
}
global.analytics.identify(userId, traits);
this.hasIdentifyBeenCalled = true;
}
/**
* Send anonymous identify call to Segment's identify.
*
* @param {*} [traits]
* @returns {Promise} Promise that will resolve once the document readyState is complete
*/
identifyAnonymousUser(traits) { // eslint-disable-line no-unused-vars
if (!this.segmentInitialized) {
return Promise.resolve();
}
// if we do not have an authenticated user (indicated by being in this method),
// but we still have a user id associated in segment, reset the local segment state
// This has to be wrapped in the analytics.ready() callback because the analytics.user()
// function isn't available until the analytics.js package has finished initializing.
return new Promise((resolve, reject) => { // eslint-disable-line no-unused-vars
global.analytics.ready(() => {
if (global.analytics.user().id()) {
global.analytics.reset();
}
// We don’t need to call `identify` for anonymous users and can just make the value of
// hasIdentifyBeenCalled true. Segment automatically assigns them an anonymousId, so
// just calling `page` and `track` works fine without identify.
this.hasIdentifyBeenCalled = true;
resolve();
});
// this is added to handle a specific use-case where if a user has blocked the analytics
// tools in their browser, this promise does not get resolved and user sees a blank
// page. Dispatching this event in script.onerror callback in analytics.load.
document.addEventListener('segmentFailed', resolve);
// This is added to handle the google analytics blocked case which is injected into
// the DOM by segment.min.js.
setTimeout(() => {
if (!global.ga || !global.ga.create || !global.google_tag_manager) {
this.segmentInitialized = false;
resolve();
}
}, 2000);
});
}
/**
* Sends a track event to Segment and downstream.
* Note: For links and forms, you should use trackLink and trackForm instead.
*
* @param {*} eventName
* @param {*} [properties]
*/
sendTrackEvent(eventName, properties) {
if (!this.segmentInitialized) {
return;
}
this.checkIdentifyCalled();
global.analytics.track(eventName, properties);
}
/**
* Sends a page event to Segment and downstream.
*
* @param {*} [name] If only one string arg provided, assumed to be name.
* @param {*} [category] Name is required to pass a category.
* @param {*} [properties]
*/
sendPageEvent(category, name, properties) {
if (!this.segmentInitialized) {
return;
}
this.checkIdentifyCalled();
global.analytics.page(category, name, properties);
}
}
export default SegmentAnalyticsService;