diff --git a/.eslintignore b/.eslintignore index 6dae667..4eea572 100755 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,4 @@ coverage/* dist/ node_modules/ -src/analytics/segment.js __mocks__/ diff --git a/package-lock.json b/package-lock.json index 1acda53..b5c792b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6f601f1..203c3eb 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/analytics/analytics.js b/src/analytics/analytics.js deleted file mode 100755 index c24dc7e..0000000 --- a/src/analytics/analytics.js +++ /dev/null @@ -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, -}; diff --git a/src/analytics/analytics.test.js b/src/analytics/analytics.test.js deleted file mode 100644 index 0243e15..0000000 --- a/src/analytics/analytics.test.js +++ /dev/null @@ -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.'); - }); -}); diff --git a/src/analytics/segment.js b/src/analytics/segment.js deleted file mode 100644 index a19ad82..0000000 --- a/src/analytics/segment.js +++ /dev/null @@ -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 }; diff --git a/src/components/App.jsx b/src/components/App.jsx index 7936ce2..5cd2f99 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -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'; diff --git a/src/components/ProfilePage.jsx b/src/components/ProfilePage.jsx index b54df7b..de8aa79 100644 --- a/src/components/ProfilePage.jsx +++ b/src/components/ProfilePage.jsx @@ -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 { diff --git a/src/components/ProfilePage.test.jsx b/src/components/ProfilePage.test.jsx index 5d1f5f5..f2d5c15 100644 --- a/src/components/ProfilePage.test.jsx +++ b/src/components/ProfilePage.test.jsx @@ -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'; diff --git a/src/config/analytics.js b/src/config/analytics.js index acba1b4..4a40e6c 100644 --- a/src/config/analytics.js +++ b/src/config/analytics.js @@ -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'; diff --git a/src/index.jsx b/src/index.jsx index 7b881b1..4317817 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -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';