Commit 75d0cbbc authored by Robert Knight's avatar Robert Knight

Remove angulartics dependency

As part of the migration away from AngularJS, replace Angulartics with
direct usage of the Google Analytics client.

Since we only use a single analytics provider (Google Analytics) and
only basic event tracking functionality in a single-route application,
its easiest just to replace the code with direct calls to the
analytics.js API.

See https://developers.google.com/analytics/devguides/collection/analyticsjs/events

Fixes #976
parent bbd65498
...@@ -15,7 +15,6 @@ ...@@ -15,7 +15,6 @@
"angular-route": "^1.7.5", "angular-route": "^1.7.5",
"angular-sanitize": "^1.7.5", "angular-sanitize": "^1.7.5",
"angular-toastr": "^2.1.1", "angular-toastr": "^2.1.1",
"angulartics": "0.17.2",
"autofill-event": "0.0.1", "autofill-event": "0.0.1",
"autoprefixer": "^9.4.7", "autoprefixer": "^9.4.7",
"aws-sdk": "^2.345.0", "aws-sdk": "^2.345.0",
......
...@@ -15,8 +15,6 @@ module.exports = { ...@@ -15,8 +15,6 @@ module.exports = {
'angular-sanitize', 'angular-sanitize',
'ng-tags-input', 'ng-tags-input',
'angular-toastr', 'angular-toastr',
'angulartics/src/angulartics',
'angulartics/src/angulartics-ga',
], ],
katex: ['katex'], katex: ['katex'],
showdown: ['showdown'], showdown: ['showdown'],
......
...@@ -75,7 +75,7 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -75,7 +75,7 @@ describe('sidebar.components.hypothesis-app', function() {
fakeAnalytics = { fakeAnalytics = {
track: sandbox.stub(), track: sandbox.stub(),
events: require('../../services/analytics')().events, events: require('../../services/analytics').events,
}; };
fakeAuth = {}; fakeAuth = {};
......
...@@ -101,6 +101,17 @@ function setupHttp($http, streamer) { ...@@ -101,6 +101,17 @@ function setupHttp($http, streamer) {
$http.defaults.headers.common['X-Client-Id'] = streamer.clientId; $http.defaults.headers.common['X-Client-Id'] = streamer.clientId;
} }
/**
* Send a page view event when the app starts up.
*
* We don't bother tracking route changes later because the client only uses a
* single route in a given session.
*/
// @ngInject
function sendPageView(analytics) {
analytics.sendPageView();
}
function startAngularApp(config) { function startAngularApp(config) {
angular angular
.module('h', [ .module('h', [
...@@ -112,11 +123,6 @@ function startAngularApp(config) { ...@@ -112,11 +123,6 @@ function startAngularApp(config) {
// Angular addons which do not export the Angular module // Angular addons which do not export the Angular module
// name via module.exports // name via module.exports
['angulartics', require('angulartics')][0],
[
'angulartics.google.analytics',
require('angulartics/src/angulartics-ga'),
][0],
['ngTagsInput', require('ng-tags-input')][0], ['ngTagsInput', require('ng-tags-input')][0],
['ui.bootstrap', require('./vendor/ui-bootstrap-custom-tpls-0.13.4')][0], ['ui.bootstrap', require('./vendor/ui-bootstrap-custom-tpls-0.13.4')][0],
...@@ -221,6 +227,7 @@ function startAngularApp(config) { ...@@ -221,6 +227,7 @@ function startAngularApp(config) {
.config(configureRoutes) .config(configureRoutes)
.config(configureToastr) .config(configureToastr)
.run(sendPageView)
.run(setupHttp) .run(setupHttp)
.run(crossOriginRPC.server.start); .run(crossOriginRPC.server.start);
......
...@@ -2,13 +2,44 @@ ...@@ -2,13 +2,44 @@
const VIA_REFERRER = /^https:\/\/(qa-)?via.hypothes.is\//; const VIA_REFERRER = /^https:\/\/(qa-)?via.hypothes.is\//;
const globalGAOptions = function(win, settings) { const events = {
settings = settings || {}; ANNOTATION_CREATED: 'annotationCreated',
ANNOTATION_DELETED: 'annotationDeleted',
const globalOpts = { ANNOTATION_FLAGGED: 'annotationFlagged',
category: '', ANNOTATION_SHARED: 'annotationShared',
}; ANNOTATION_UPDATED: 'annotationUpdated',
DOCUMENT_SHARED: 'documentShared',
GROUP_LEAVE: 'groupLeave',
GROUP_SWITCH: 'groupSwitch',
GROUP_VIEW_ACTIVITY: 'groupViewActivity',
HIGHLIGHT_CREATED: 'highlightCreated',
HIGHLIGHT_UPDATED: 'highlightUpdated',
HIGHLIGHT_DELETED: 'highlightDeleted',
LOGIN_FAILURE: 'loginFailure',
LOGIN_SUCCESS: 'loginSuccessful',
LOGOUT_FAILURE: 'logoutFailure',
LOGOUT_SUCCESS: 'logoutSuccessful',
PAGE_NOTE_CREATED: 'pageNoteCreated',
PAGE_NOTE_UPDATED: 'pageNoteUpdated',
PAGE_NOTE_DELETED: 'pageNoteDeleted',
REPLY_CREATED: 'replyCreated',
REPLY_UPDATED: 'replyUpdated',
REPLY_DELETED: 'replyDeleted',
SIDEBAR_OPENED: 'sidebarOpened',
SIGN_UP_REQUESTED: 'signUpRequested',
};
/**
* Return a string identifying the context in which the client is being used.
*
* This is used as the "category" for analytics events to support comparing
* behavior across different environments in which the client is used.
*
* @param {Window} win
* @param {Object} settings - Settings rendered into sidebar HTML
* @return {string}
*/
function clientType(win, settings = {}) {
const validTypes = [ const validTypes = [
'chrome-extension', 'chrome-extension',
'firefox-extension', 'firefox-extension',
...@@ -16,6 +47,7 @@ const globalGAOptions = function(win, settings) { ...@@ -16,6 +47,7 @@ const globalGAOptions = function(win, settings) {
'bookmarklet', 'bookmarklet',
'via', 'via',
]; ];
let type;
// The preferred method for deciding what type of app is running is // The preferred method for deciding what type of app is running is
// through the setting of the appType to one of the valid types above. // through the setting of the appType to one of the valid types above.
...@@ -23,17 +55,55 @@ const globalGAOptions = function(win, settings) { ...@@ -23,17 +55,55 @@ const globalGAOptions = function(win, settings) {
// the appType setting explicitly - these are the app types that were // the appType setting explicitly - these are the app types that were
// added before we added the analytics logic // added before we added the analytics logic
if (validTypes.indexOf((settings.appType || '').toLowerCase()) > -1) { if (validTypes.indexOf((settings.appType || '').toLowerCase()) > -1) {
globalOpts.category = settings.appType.toLowerCase(); type = settings.appType.toLowerCase();
} else if (win.location.protocol === 'chrome-extension:') { } else if (win.location.protocol === 'chrome-extension:') {
globalOpts.category = 'chrome-extension'; type = 'chrome-extension';
} else if (VIA_REFERRER.test(win.document.referrer)) { } else if (VIA_REFERRER.test(win.document.referrer)) {
globalOpts.category = 'via'; type = 'via';
} else { } else {
globalOpts.category = 'embed'; type = 'embed';
} }
return globalOpts; return type;
}; }
/**
* Wrapper around the Google Analytics client.
*
* See https://developers.google.com/analytics/devguides/collection/analyticsjs/
*/
class GoogleAnalytics {
/**
* @param {Function} ga - The `window.ga` interface to analytics.js
* @param {string} category - Category for events.
*/
constructor(ga, category) {
this.ga = ga;
this.category = category;
}
/**
* Report a user interaction to Google Analytics.
*
* See https://developers.google.com/analytics/devguides/collection/analyticsjs/events
*
* @param {string} action - The user action
* @param {string} label
* @param [number] value
*/
sendEvent(action, label, value) {
this.ga('send', 'event', this.category, action, label, value);
}
/**
* Report a page view.
*
* This should be sent on initial page load and route changes.
*/
sendPageView() {
this.ga('send', 'pageview');
}
}
/** /**
* Analytics API to simplify and standardize the values that we * Analytics API to simplify and standardize the values that we
...@@ -45,56 +115,30 @@ const globalGAOptions = function(win, settings) { ...@@ -45,56 +115,30 @@ const globalGAOptions = function(win, settings) {
* We will standardize the category to be the appType of the client settings * We will standardize the category to be the appType of the client settings
*/ */
// @ngInject // @ngInject
function analytics($analytics, $window, settings) { function analytics($window, settings) {
const options = $window ? globalGAOptions($window, settings) : {}; const category = clientType($window, settings);
const noop = () => {};
const ga = $window.ga || noop;
const googleAnalytics = new GoogleAnalytics(ga, category);
return { return {
sendPageView() {
googleAnalytics.sendPageView();
},
/** /**
* @param {string} event This is the event name that we are capturing * @param {string} event This is the event name that we are capturing
* in our analytics. Example: 'sidebarOpened'. Use camelCase to track multiple * in our analytics. Example: 'sidebarOpened'. Use camelCase to track multiple
* words. * words.
*/ */
track: function(event, label, metricValue) { track(event, label, metricValue) {
$analytics.eventTrack( googleAnalytics.sendEvent(event, label, metricValue);
event,
Object.assign(
{},
{
label: label || undefined,
metricValue: isNaN(metricValue) ? undefined : metricValue,
},
options
)
);
}, },
events: { events,
ANNOTATION_CREATED: 'annotationCreated',
ANNOTATION_DELETED: 'annotationDeleted',
ANNOTATION_FLAGGED: 'annotationFlagged',
ANNOTATION_SHARED: 'annotationShared',
ANNOTATION_UPDATED: 'annotationUpdated',
DOCUMENT_SHARED: 'documentShared',
GROUP_LEAVE: 'groupLeave',
GROUP_SWITCH: 'groupSwitch',
GROUP_VIEW_ACTIVITY: 'groupViewActivity',
HIGHLIGHT_CREATED: 'highlightCreated',
HIGHLIGHT_UPDATED: 'highlightUpdated',
HIGHLIGHT_DELETED: 'highlightDeleted',
LOGIN_FAILURE: 'loginFailure',
LOGIN_SUCCESS: 'loginSuccessful',
LOGOUT_FAILURE: 'logoutFailure',
LOGOUT_SUCCESS: 'logoutSuccessful',
PAGE_NOTE_CREATED: 'pageNoteCreated',
PAGE_NOTE_UPDATED: 'pageNoteUpdated',
PAGE_NOTE_DELETED: 'pageNoteDeleted',
REPLY_CREATED: 'replyCreated',
REPLY_UPDATED: 'replyUpdated',
REPLY_DELETED: 'replyDeleted',
SIDEBAR_OPENED: 'sidebarOpened',
SIGN_UP_REQUESTED: 'signUpRequested',
},
}; };
} }
analytics.events = events;
module.exports = analytics; module.exports = analytics;
...@@ -2,27 +2,13 @@ ...@@ -2,27 +2,13 @@
const analyticsService = require('../analytics'); const analyticsService = require('../analytics');
const createEventObj = function(override) {
return {
category: override.category,
label: override.label,
metricValue: override.metricValue,
};
};
describe('analytics', function() { describe('analytics', function() {
let $analyticsStub;
let $windowStub; let $windowStub;
let eventTrackStub; let svc;
beforeEach(function() { beforeEach(function() {
$analyticsStub = {
eventTrack: sinon.stub(),
};
eventTrackStub = $analyticsStub.eventTrack;
$windowStub = { $windowStub = {
ga: sinon.stub(),
location: { location: {
href: '', href: '',
protocol: 'https:', protocol: 'https:',
...@@ -31,93 +17,95 @@ describe('analytics', function() { ...@@ -31,93 +17,95 @@ describe('analytics', function() {
referrer: '', referrer: '',
}, },
}; };
svc = analyticsService($windowStub, { appType: 'embed' });
}); });
function checkEventSent(
category,
event,
label = undefined,
value = undefined
) {
assert.calledWith(
$windowStub.ga,
'send',
'event',
category,
event,
label,
value
);
}
describe('applying global category based on environment contexts', function() { describe('applying global category based on environment contexts', function() {
it('sets the category to match the appType setting value', function() { it('sets the category to match the appType setting value', function() {
const validTypes = ['chrome-extension', 'embed', 'bookmarklet', 'via']; const validTypes = ['chrome-extension', 'embed', 'bookmarklet', 'via'];
validTypes.forEach(function(appType, index) { validTypes.forEach(function(appType, index) {
analyticsService($analyticsStub, $windowStub, { analyticsService($windowStub, { appType: appType }).track(
appType: appType, 'event' + index
}).track('event' + index); );
assert.deepEqual(eventTrackStub.args[index], [ checkEventSent(appType, 'event' + index);
'event' + index,
createEventObj({ category: appType }),
]);
}); });
}); });
it('sets category as embed if no other matches can be made', function() { it('sets category as embed if no other matches can be made', function() {
analyticsService($analyticsStub, $windowStub).track('eventA'); analyticsService($windowStub).track('eventA');
assert.deepEqual(eventTrackStub.args[0], [ checkEventSent('embed', 'eventA');
'eventA',
createEventObj({ category: 'embed' }),
]);
}); });
it('sets category as via if url matches the via uri pattern', function() { it('sets category as via if url matches the via uri pattern', function() {
$windowStub.document.referrer = 'https://via.hypothes.is/'; $windowStub.document.referrer = 'https://via.hypothes.is/';
analyticsService($analyticsStub, $windowStub).track('eventA'); analyticsService($windowStub).track('eventA');
assert.deepEqual(eventTrackStub.args[0], [ checkEventSent('via', 'eventA');
'eventA',
createEventObj({ category: 'via' }),
]);
// match staging as well // match staging as well
$windowStub.document.referrer = 'https://qa-via.hypothes.is/'; $windowStub.document.referrer = 'https://qa-via.hypothes.is/';
analyticsService($analyticsStub, $windowStub).track('eventB'); analyticsService($windowStub).track('eventB');
assert.deepEqual(eventTrackStub.args[1], [ checkEventSent('via', 'eventB');
'eventB',
createEventObj({ category: 'via' }),
]);
}); });
it('sets category as chrome-extension if protocol matches chrome-extension:', function() { it('sets category as chrome-extension if protocol matches chrome-extension:', function() {
$windowStub.location.protocol = 'chrome-extension:'; $windowStub.location.protocol = 'chrome-extension:';
analyticsService($analyticsStub, $windowStub).track('eventA'); analyticsService($windowStub).track('eventA');
assert.deepEqual(eventTrackStub.args[0], [ checkEventSent('chrome-extension', 'eventA');
'eventA',
createEventObj({ category: 'chrome-extension' }),
]);
}); });
}); });
it('allows custom labels to be sent for an event', function() { describe('#track', () => {
analyticsService($analyticsStub, $windowStub, { appType: 'embed' }).track( it('allows custom labels to be sent for an event', function() {
'eventA', svc.track('eventA', 'labelA');
'labelA' checkEventSent('embed', 'eventA', 'labelA');
); });
assert.deepEqual(eventTrackStub.args[0], [
'eventA', it('allows custom metricValues to be sent for an event', function() {
createEventObj({ category: 'embed', label: 'labelA' }), svc.track('eventA', null, 242.2);
]); checkEventSent('embed', 'eventA', null, 242.2);
});
it('allows custom metricValues and labels to be sent for an event', function() {
svc.track('eventA', 'labelabc', 242.2);
checkEventSent('embed', 'eventA', 'labelabc', 242.2);
});
}); });
it('allows custom metricValues to be sent for an event', function() { describe('#sendPageView', () => {
analyticsService($analyticsStub, $windowStub, { appType: 'embed' }).track( it('sends a page view hit', () => {
'eventA', svc.sendPageView();
null, assert.calledWith($windowStub.ga, 'send', 'pageview');
242.2 });
);
assert.deepEqual(eventTrackStub.args[0], [
'eventA',
createEventObj({ category: 'embed', metricValue: 242.2 }),
]);
}); });
it('allows custom metricValues and labels to be sent for an event', function() { context('when Google Analytics is not loaded', () => {
analyticsService($analyticsStub, $windowStub, { appType: 'embed' }).track( it('analytics methods can be called but do nothing', () => {
'eventA', const ga = $windowStub.ga;
'labelabc', delete $windowStub.ga;
242.2 const svc = analyticsService($windowStub, {});
);
assert.deepEqual(eventTrackStub.args[0], [ svc.track('someEvent');
'eventA', svc.sendPageView();
createEventObj({
category: 'embed', assert.notCalled(ga);
label: 'labelabc', });
metricValue: 242.2,
}),
]);
}); });
}); });
...@@ -29,7 +29,7 @@ describe('sidebar.session', function() { ...@@ -29,7 +29,7 @@ describe('sidebar.session', function() {
let state = {}; let state = {};
fakeAnalytics = { fakeAnalytics = {
track: sinon.stub(), track: sinon.stub(),
events: require('../analytics')().events, events: require('../analytics').events,
}; };
const fakeStore = { const fakeStore = {
getState: function() { getState: function() {
......
...@@ -825,11 +825,6 @@ angular@^1.7.5: ...@@ -825,11 +825,6 @@ angular@^1.7.5:
resolved "https://registry.yarnpkg.com/angular/-/angular-1.7.7.tgz#26bd87693deadcbd5944610a7a0463fc79a18803" resolved "https://registry.yarnpkg.com/angular/-/angular-1.7.7.tgz#26bd87693deadcbd5944610a7a0463fc79a18803"
integrity sha512-MH3JEGd8y/EkNCKJ8EV6Ch0j9X0rZTta/QVIDpBWaIdfh85/e5KO8+ZKgvWIb02MQuiS20pDFmMFlv4ZaLcLWg== integrity sha512-MH3JEGd8y/EkNCKJ8EV6Ch0j9X0rZTta/QVIDpBWaIdfh85/e5KO8+ZKgvWIb02MQuiS20pDFmMFlv4ZaLcLWg==
angulartics@0.17.2:
version "0.17.2"
resolved "https://registry.yarnpkg.com/angulartics/-/angulartics-0.17.2.tgz#ef7e7ba6f30013e1d50da3232bd630388bb5feb3"
integrity sha1-7357pvMAE+HVDaMjK9YwOIu1/rM=
ansi-colors@3.2.3: ansi-colors@3.2.3:
version "3.2.3" version "3.2.3"
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.3.tgz#57d35b8686e851e2cc04c403f1c00203976a1813" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.3.tgz#57d35b8686e851e2cc04c403f1c00203976a1813"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment