use new frontend-analytics library

ARCH-517
This commit is contained in:
Robert Raposa
2019-04-10 10:50:02 -04:00
parent 77ec435059
commit 343e4cb062
11 changed files with 15 additions and 509 deletions

View File

@@ -1,5 +1,4 @@
coverage/*
dist/
node_modules/
src/analytics/segment.js
__mocks__/

9
package-lock.json generated
View File

@@ -2560,6 +2560,15 @@
"react-router-hash-link": "^1.2.1"
}
},
"@edx/frontend-analytics": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-analytics/-/frontend-analytics-1.0.0.tgz",
"integrity": "sha512-OBrzWAVsCwSraW25KpUikOTzFIPgGBi7akcqmGDRT9BZcAe/He/TE+29mg8v1KwFDCY1Q6QOKW8c6HOJAHSdpA==",
"requires": {
"form-urlencoded": "^3.0.0",
"lodash.snakecase": "^4.1.1"
}
},
"@edx/frontend-auth": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-auth/-/frontend-auth-3.2.0.tgz",

View File

@@ -26,6 +26,7 @@
"dependencies": {
"@cospired/i18n-iso-languages": "^2.0.2",
"@edx/edx-bootstrap": "git://github.com/edx/edx-bootstrap.git#update-with-documentation-site",
"@edx/frontend-analytics": "^1.0.0",
"@edx/frontend-auth": "^3.2.0",
"@edx/frontend-component-footer": "^2.0.3",
"@edx/frontend-component-site-header": "^2.1.4",

View File

@@ -1,133 +0,0 @@
import formurlencoded from 'form-urlencoded';
import { snakeCaseObject } from '../services/utils';
let config = {};
let hasIdentifyBeenCalled = false;
/**
* Configures analytics module for an application.
* Note: this is using a module method, rather than a class+constructor, so functions
* can easily be passed around without this-binding concerns.
*/
function configureAnalytics(newConfig) {
hasIdentifyBeenCalled = false;
config = {
loggingService: newConfig.loggingService,
authApiClient: newConfig.authApiClient,
analyticsApiBaseUrl: newConfig.analyticsApiBaseUrl,
};
}
function getTrackingLogApiBaseUrl() {
return `${config.analyticsApiBaseUrl}/event`;
}
function getAuthApiClient() {
if (!config.authApiClient) {
throw Error('You must configure the authApiClient.');
}
return config.authApiClient;
}
function getLoggingService() {
if (!config.loggingService) {
throw Error('You must configure the loggingService.');
}
return config.loggingService;
}
/**
* Checks that identify was first called. Otherwise, logs error.
*/
function checkIdentifyCalled() {
const loggingService = getLoggingService(); // verifies configuration early
if (!hasIdentifyBeenCalled) {
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 eventName (event_type on backend, but named to match Segment api)
* @param properties (event on backend, but named properties to match Segment api)
* @returns The promise returned by apiClient.post.
*/
function sendTrackingLogEvent(eventName, properties) {
const snakeEventData = snakeCaseObject(properties, { deep: true });
const serverData = {
event_type: eventName,
event: JSON.stringify(snakeEventData),
page: window.location.href,
};
const loggingService = getLoggingService(); // verifies configuration early
return getAuthApiClient().post(
getTrackingLogApiBaseUrl(),
formurlencoded(serverData),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
).catch((error) => {
loggingService.logAPIErrorResponse(error);
});
}
/**
* Send identify call to Segment, using userId from authApiClient.
* @param traits (optional)
*/
function identifyAuthenticatedUser(traits) {
const authState = getAuthApiClient().getAuthenticationState();
const loggingService = getLoggingService(); // verifies configuration early
if (authState.authentication && authState.authentication.userId) {
// eslint-disable-next-line no-undef
window.analytics.identify(authState.authentication.userId, traits);
hasIdentifyBeenCalled = true;
} else {
loggingService.logError('UserId was not available for call to sendAuthenticatedIdentify.');
}
}
/**
* Send anonymous identify call to Segment's identify.
* @param traits (optional)
*/
function identifyAnonymousUser(traits) {
window.analytics.identify(traits);
hasIdentifyBeenCalled = true;
}
/**
* Sends a track event to Segment and downstream.
* Note: For links and forms, you should use trackLink and trackForm instead.
* @param eventName
* @param properties (optional)
*/
function sendTrackEvent(eventName, properties) {
checkIdentifyCalled();
window.analytics.track(eventName, properties);
}
/**
* Sends a page event to Segment and downstream.
* @param category (optional) Name is required to pass a category.
* @param name (optional) If only one string arg provided, assumed to be name.
* @param properties (optional)
*/
function sendPageEvent(category, name, properties) {
checkIdentifyCalled();
window.analytics.page(category, name, properties);
}
export {
configureAnalytics,
identifyAnonymousUser,
identifyAuthenticatedUser,
sendPageEvent,
sendTrackEvent,
sendTrackingLogEvent,
};

View File

@@ -1,282 +0,0 @@
import {
configureAnalytics,
identifyAnonymousUser,
identifyAuthenticatedUser,
sendPageEvent,
sendTrackEvent,
sendTrackingLogEvent,
} from './analytics';
const eventType = 'test.event';
const eventData = {
testShallow: 'test-shallow',
testObject: {
testDeep: 'test-deep',
},
};
const testUserId = 99;
const testAnalyticsApiBaseUrl = '/analytics';
let mockAuthApiClient;
let mockLoggingService;
function createMockLoggingService() {
mockLoggingService = {
logError: jest.fn(),
logAPIErrorResponse: jest.fn(),
};
}
function createMockAuthApiClientAuthenticated() {
mockAuthApiClient = {
getAuthenticationState:
jest.fn(() => ({
authentication: { userId: testUserId },
})),
};
}
function createMockAuthApiClientAuthenticationIncomplete() {
mockAuthApiClient = {
getAuthenticationState:
jest.fn(() => ({
authentication: {},
})),
};
}
function createMockAuthApiClientPostResolved() {
mockAuthApiClient = {
post: jest.fn().mockResolvedValue(undefined),
};
}
function createMockAuthApiClientPostRejected() {
mockAuthApiClient = {
post: jest.fn().mockRejectedValue('test-error'),
};
}
function configureAnalyticsWithMocks() {
configureAnalytics({
loggingService: mockLoggingService,
authApiClient: mockAuthApiClient,
analyticsApiBaseUrl: testAnalyticsApiBaseUrl,
});
}
describe('analytics sendTrackingLogEvent', () => {
it('fails when loggingService is not configured', () => {
mockLoggingService = undefined;
createMockAuthApiClientPostResolved();
configureAnalyticsWithMocks();
expect(() => sendTrackingLogEvent(eventType, eventData))
.toThrowError('You must configure the loggingService.');
});
it('fails when authApiClient is not configured', () => {
createMockLoggingService();
mockAuthApiClient = undefined;
configureAnalyticsWithMocks();
expect(() => sendTrackingLogEvent(eventType, eventData))
.toThrowError('You must configure the authApiClient.');
});
it('posts expected data when successful', () => {
createMockLoggingService();
createMockAuthApiClientPostResolved();
configureAnalyticsWithMocks();
expect.assertions(4);
return sendTrackingLogEvent(eventType, eventData)
.then(() => {
expect(mockAuthApiClient.post.mock.calls.length).toEqual(1);
expect(mockAuthApiClient.post.mock.calls[0][0]).toEqual('/analytics/event');
const expectedData = 'event_type=test.event&event=%7B%22test_shallow%22%3A%22test-shallow%22%2C%22test_object%22%3A%7B%22test_deep%22%3A%22test-deep%22%7D%7D&page=http%3A%2F%2Flocalhost%2F';
expect(mockAuthApiClient.post.mock.calls[0][1]).toEqual(expectedData);
const config = mockAuthApiClient.post.mock.calls[0][2];
expect(config.headers['Content-Type']).toEqual('application/x-www-form-urlencoded');
});
});
it('calls loggingService.logAPIErrorResponse on error', () => {
createMockLoggingService();
createMockAuthApiClientPostRejected();
configureAnalyticsWithMocks();
expect.assertions(2);
return sendTrackingLogEvent(eventType, eventData)
.then(() => {
expect(mockLoggingService.logAPIErrorResponse.mock.calls.length).toBe(1);
expect(mockLoggingService.logAPIErrorResponse).toBeCalledWith('test-error');
});
});
});
describe('analytics identifyAuthenticatedUser', () => {
beforeEach(() => {
window.analytics = {
identify: jest.fn(),
};
});
it('fails when loggingService is not configured', () => {
mockLoggingService = undefined;
createMockAuthApiClientAuthenticated();
configureAnalyticsWithMocks();
expect(() => identifyAuthenticatedUser())
.toThrowError('You must configure the loggingService.');
});
it('fails when authApiClient is not configured', () => {
createMockLoggingService();
mockAuthApiClient = undefined;
configureAnalyticsWithMocks();
expect(() => identifyAuthenticatedUser())
.toThrowError('You must configure the authApiClient.');
});
it('calls Segment identify on success', () => {
createMockLoggingService();
createMockAuthApiClientAuthenticated();
configureAnalyticsWithMocks();
const testTraits = { anything: 'Yay!' };
identifyAuthenticatedUser(testTraits);
expect(window.analytics.identify.mock.calls.length).toBe(1);
expect(window.analytics.identify).toBeCalledWith(testUserId, testTraits);
});
it('logs error when authentication problem.', () => {
createMockLoggingService();
createMockAuthApiClientAuthenticationIncomplete();
configureAnalyticsWithMocks();
identifyAuthenticatedUser();
expect(mockLoggingService.logError.mock.calls.length).toBe(1);
expect(mockLoggingService.logError).toBeCalledWith('UserId was not available for call to sendAuthenticatedIdentify.');
});
});
describe('analytics identifyAnonymousUser', () => {
beforeEach(() => {
window.analytics = {
identify: jest.fn(),
};
});
it('calls Segment identify on success', () => {
const testTraits = { anything: 'Yay!' };
identifyAnonymousUser(testTraits);
expect(window.analytics.identify.mock.calls.length).toBe(1);
expect(window.analytics.identify).toBeCalledWith(testTraits);
});
});
function testSendPageAfterIdentify(identifyFunction) {
createMockLoggingService();
createMockAuthApiClientAuthenticated();
configureAnalyticsWithMocks();
identifyFunction();
const testCategory = 'test-category';
const testName = 'test-name';
const testProperties = { anything: 'Yay!' };
sendPageEvent(testCategory, testName, testProperties);
expect(window.analytics.page.mock.calls.length).toBe(1);
expect(window.analytics.page).toBeCalledWith(testCategory, testName, testProperties);
}
describe('analytics send Page event', () => {
beforeEach(() => {
window.analytics = {
identify: jest.fn(),
page: jest.fn(),
};
});
it('fails when loggingService is not configured', () => {
mockLoggingService = undefined;
mockAuthApiClient = undefined;
configureAnalyticsWithMocks();
expect(() => sendPageEvent()).toThrowError('You must configure the loggingService.');
});
it('calls Segment page on success after identifyAuthenticatedUser', () => {
testSendPageAfterIdentify(identifyAuthenticatedUser);
});
it('calls Segment page on success after identifyAnonymousUser', () => {
testSendPageAfterIdentify(identifyAnonymousUser);
});
it('fails if page called with no identify', () => {
createMockLoggingService();
mockAuthApiClient = undefined;
configureAnalyticsWithMocks();
sendPageEvent();
expect(mockLoggingService.logError.mock.calls.length).toBe(1);
expect(mockLoggingService.logError).toBeCalledWith('Identify must be called before other tracking events.');
});
});
function testSendTrackEventAfterIdentify(identifyFunction) {
createMockLoggingService();
createMockAuthApiClientAuthenticated();
configureAnalyticsWithMocks();
identifyFunction();
const testName = 'test-name';
const testProperties = { anything: 'Yay!' };
sendTrackEvent(testName, testProperties);
expect(window.analytics.track.mock.calls.length).toBe(1);
expect(window.analytics.track).toBeCalledWith(testName, testProperties);
}
describe('analytics send Track event', () => {
beforeEach(() => {
window.analytics = {
identify: jest.fn(),
track: jest.fn(),
};
});
it('fails when loggingService is not configured', () => {
mockLoggingService = undefined;
mockAuthApiClient = undefined;
configureAnalyticsWithMocks();
expect(() => sendTrackEvent()).toThrowError('You must configure the loggingService.');
});
it('calls Segment track on success after identifyAuthenticatedUser', () => {
testSendTrackEventAfterIdentify(identifyAuthenticatedUser);
});
it('calls Segment track on success after identifyAnonymousUser', () => {
testSendTrackEventAfterIdentify(identifyAnonymousUser);
});
it('fails if track called with no identify', () => {
createMockLoggingService();
mockAuthApiClient = undefined;
configureAnalyticsWithMocks();
sendTrackEvent();
expect(mockLoggingService.logError.mock.calls.length).toBe(1);
expect(mockLoggingService.logError).toBeCalledWith('Identify must be called before other tracking events.');
});
});

View File

@@ -1,87 +0,0 @@
// The code in this file is from Segment's website, with the following update:
// - Takes the segment key as a parameter (
// https://segment.com/docs/sources/website/analytics.js/quickstart/
function initializeSegment(segmentKey) {
// Create a queue, but don't obliterate an existing one!
var analytics = window.analytics = window.analytics || [];
// If the real analytics.js is already on the page return.
if (analytics.initialize) return;
// If the snippet was invoked already show an error.
if (analytics.invoked) {
if (window.console && console.error) {
console.error('Segment snippet included twice.');
}
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 = function(method){
return function(){
var args = Array.prototype.slice.call(arguments);
args.unshift(method);
analytics.push(args);
return analytics;
};
};
// For each of our methods, generate a queueing stub.
for (var i = 0; i < analytics.methods.length; i++) {
var key = analytics.methods[i];
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 = function(key, options){
// Create an async script element based on your key.
var script = document.createElement('script');
script.type = 'text/javascript';
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.
var first = document.getElementsByTagName('script')[0];
first.parentNode.insertBefore(script, first);
analytics._loadOptions = options;
};
// 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(segmentKey);
}
export { initializeSegment };

View File

@@ -4,11 +4,11 @@ import PropTypes from 'prop-types';
import { IntlProvider } from 'react-intl';
import { Route, Switch } from 'react-router-dom';
import { ConnectedRouter } from 'connected-react-router';
import { sendTrackEvent } from '@edx/frontend-analytics';
import SiteFooter from '@edx/frontend-component-footer';
import { fetchUserAccount, UserAccountApiService } from '@edx/frontend-auth';
import apiClient from '../config/apiClient';
import { sendTrackEvent } from '../analytics/analytics';
import { getLocale, getMessages } from '../i18n/i18n-loader';
import SiteHeader from './common/SiteHeader';
import ConnectedProfilePage from './ProfilePage';

View File

@@ -5,7 +5,7 @@ import { connect } from 'react-redux';
import { injectIntl, intlShape } from 'react-intl';
// Analytics
import { sendTrackingLogEvent } from '../analytics/analytics';
import { sendTrackingLogEvent } from '@edx/frontend-analytics';
// Actions
import {

View File

@@ -6,7 +6,7 @@ import { Provider } from 'react-redux';
import { IntlProvider } from 'react-intl';
import configureMockStore from 'redux-mock-store';
import * as analytics from '../analytics/analytics';
import * as analytics from '@edx/frontend-analytics';
import ConnectedProfilePage from './ProfilePage';

View File

@@ -1,7 +1,6 @@
import { configureAnalytics, initializeSegment } from '@edx/frontend-analytics';
import LoggingService from '@edx/frontend-logging';
import { configureAnalytics } from '../analytics/analytics';
import { initializeSegment } from '../analytics/segment';
import apiClient from '../config/apiClient';
import { configuration } from '../config/environment';

View File

@@ -2,12 +2,12 @@ import 'babel-polyfill';
import React from 'react';
import ReactDOM from 'react-dom';
import { identifyAuthenticatedUser, sendPageEvent } from '@edx/frontend-analytics';
import './config/analytics';
import configureStore from './config/configureStore';
import apiClient from './config/apiClient';
import { handleRtl } from './i18n/i18n-loader';
import { identifyAuthenticatedUser, sendPageEvent } from './analytics/analytics';
import './index.scss';