Commit cca76111 authored by Sean Roberts's avatar Sean Roberts Committed by GitHub

Merge pull request #206 from hypothesis/annotation-analytics

Adding client metric tracking for interactions with annotations
parents 0188b331 77ea41de
......@@ -50,8 +50,27 @@ function analytics($analytics, $window, settings) {
* in our analytics. Example: 'sidebarOpened'. Use camelCase to track multiple
* words.
*/
track: function(event){
$analytics.eventTrack(event, options);
track: function(event, label, metricValue){
$analytics.eventTrack(event, Object.assign({}, {
label: label || undefined,
metricValue: isNaN(metricValue) ? undefined : metricValue,
}, options));
},
events: {
ANNOTATION_CREATED: 'annotationCreated',
ANNOTATION_DELETED: 'annotationDeleted',
ANNOTATION_UPDATED: 'annotationUpdated',
HIGHLIGHT_CREATED: 'highlightCreated',
HIGHLIGHT_UPDATED: 'highlightUpdated',
HIGHLIGHT_DELETED: 'highlightDeleted',
PAGE_NOTE_CREATED: 'pageNoteCreated',
PAGE_NOTE_UPDATED: 'pageNoteUpdated',
PAGE_NOTE_DELETED: 'pageNoteDeleted',
REPLY_CREATED: 'replyCreated',
REPLY_UPDATED: 'replyUpdated',
REPLY_DELETED: 'replyDeleted',
SIDEBAR_OPENED: 'sidebarOpened',
},
};
}
......
......@@ -9,6 +9,7 @@ var persona = require('../filter/persona');
var isNew = annotationMetadata.isNew;
var isReply = annotationMetadata.isReply;
var isPageNote = annotationMetadata.isPageNote;
/** Return a human-readable error message for the given server error.
*
......@@ -46,7 +47,7 @@ function updateModel(annotation, changes, permissions) {
// @ngInject
function AnnotationController(
$document, $q, $rootScope, $scope, $timeout, $window, annotationUI,
$document, $q, $rootScope, $scope, $timeout, $window, analytics, annotationUI,
annotationMapper, drafts, flash, features, groups, permissions, serviceUrl,
session, store, streamer) {
......@@ -56,12 +57,18 @@ function AnnotationController(
/** Save an annotation to the server. */
function save(annot) {
var saved;
if (annot.id) {
var updating = !!annot.id;
if (updating) {
saved = store.annotation.update({id: annot.id}, annot);
} else {
saved = store.annotation.create({}, annot);
}
return saved.then(function (savedAnnot) {
var event;
// Copy across internal properties which are not part of the annotation
// model saved on the server
savedAnnot.$tag = annot.$tag;
......@@ -70,6 +77,20 @@ function AnnotationController(
savedAnnot[k] = annot[k];
}
});
if(vm.isReply()){
event = updating ? analytics.events.REPLY_UPDATED : analytics.events.REPLY_CREATED;
}else if(vm.isHighlight()){
event = updating ? analytics.events.HIGHLIGHT_UPDATED : analytics.events.HIGHLIGHT_CREATED;
}else if(isPageNote(vm.annotation)) {
event = updating ? analytics.events.PAGE_NOTE_UPDATED : analytics.events.PAGE_NOTE_CREATED;
}else {
event = updating ? analytics.events.ANNOTATION_UPDATED : analytics.events.ANNOTATION_CREATED;
}
analytics.track(event);
return savedAnnot;
});
}
......@@ -213,8 +234,22 @@ function AnnotationController(
errorMessage(reason), 'Deleting annotation failed');
};
$scope.$apply(function() {
annotationMapper.deleteAnnotation(vm.annotation).then(
null, onRejected);
annotationMapper.deleteAnnotation(vm.annotation).then(function(){
var event;
if(vm.isReply()){
event = analytics.events.REPLY_DELETED;
}else if(vm.isHighlight()){
event = analytics.events.HIGHLIGHT_DELETED;
}else if(isPageNote(vm.annotation)){
event = analytics.events.PAGE_NOTE_DELETED;
}else {
event = analytics.events.ANNOTATION_DELETED;
}
analytics.track(event);
}, onRejected);
});
}
}, true);
......@@ -291,8 +326,7 @@ function AnnotationController(
// example there's no vm.annotation.highlight: true. Instead a highlight is
// defined as an annotation that isn't a page note or a reply and that
// has no text or tags.
var isPageNote = (vm.annotation.target || []).length === 0;
return (!isPageNote && !isReply(vm.annotation) && !vm.hasContent());
return (!isPageNote(vm.annotation) && !isReply(vm.annotation) && !vm.hasContent());
}
};
......
......@@ -82,6 +82,7 @@ describe('annotation', function() {
var $scope;
var $timeout;
var $window;
var fakeAnalytics;
var fakeAnnotationMapper;
var fakeDrafts;
var fakeFlash;
......@@ -121,6 +122,11 @@ describe('annotation', function() {
beforeEach(angular.mock.module(function($provide) {
sandbox = sinon.sandbox.create();
fakeAnalytics = {
track: sandbox.stub(),
events: {},
};
fakeAnnotationMapper = {
createAnnotation: sandbox.stub().returns({
permissions: {
......@@ -195,6 +201,7 @@ describe('annotation', function() {
hasPendingDeletion: sinon.stub(),
};
$provide.value('analytics', fakeAnalytics);
$provide.value('annotationMapper', fakeAnnotationMapper);
$provide.value('annotationUI', fakeAnnotationUI);
$provide.value('drafts', fakeDrafts);
......
......@@ -2,6 +2,14 @@
var analyticsService = require('../analytics');
var createEventObj = function(override){
return {
category: override.category,
label: override.label,
metricValue: override.metricValue,
};
};
describe('analytics', function () {
var $analyticsStub;
......@@ -32,31 +40,47 @@ describe('analytics', function () {
var validTypes = ['chrome-extension', 'embed', 'bookmarklet', 'via'];
validTypes.forEach(function(appType, index){
analyticsService($analyticsStub, $windowStub, {appType: appType}).track('event' + index);
assert.deepEqual(eventTrackStub.args[index], ['event' + index, {category: appType}]);
assert.deepEqual(eventTrackStub.args[index], ['event' + index, createEventObj({category: appType})]);
});
});
it('sets category as embed if no other matches can be made', function () {
analyticsService($analyticsStub, $windowStub).track('eventA');
assert.deepEqual(eventTrackStub.args[0], ['eventA', {category: 'embed'}]);
assert.deepEqual(eventTrackStub.args[0], ['eventA', createEventObj({category: 'embed'})]);
});
it('sets category as via if url matches the via uri pattern', function () {
$windowStub.document.referrer = 'https://via.hypothes.is/';
analyticsService($analyticsStub, $windowStub).track('eventA');
assert.deepEqual(eventTrackStub.args[0], ['eventA', {category: 'via'}]);
assert.deepEqual(eventTrackStub.args[0], ['eventA', createEventObj({category: 'via'})]);
// match staging as well
$windowStub.document.referrer = 'https://qa-via.hypothes.is/';
analyticsService($analyticsStub, $windowStub).track('eventB');
assert.deepEqual(eventTrackStub.args[1], ['eventB', {category: 'via'}]);
assert.deepEqual(eventTrackStub.args[1], ['eventB', createEventObj({category: 'via'})]);
});
it('sets category as chrome-extension if protocol matches chrome-extension:', function () {
$windowStub.location.protocol = 'chrome-extension:';
analyticsService($analyticsStub, $windowStub).track('eventA');
assert.deepEqual(eventTrackStub.args[0], ['eventA', {category: 'chrome-extension'}]);
assert.deepEqual(eventTrackStub.args[0], ['eventA', createEventObj({category: 'chrome-extension'})]);
});
});
it('allows custom labels to be sent for an event', function () {
analyticsService($analyticsStub, $windowStub, {appType: 'embed'}).track('eventA', 'labelA');
assert.deepEqual(eventTrackStub.args[0], ['eventA', createEventObj({category: 'embed', label: 'labelA'})]);
});
it('allows custom metricValues to be sent for an event', function () {
analyticsService($analyticsStub, $windowStub, {appType: 'embed'}).track('eventA', null, 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 () {
analyticsService($analyticsStub, $windowStub, {appType: 'embed'}).track('eventA', 'labelabc', 242.2);
assert.deepEqual(eventTrackStub.args[0], ['eventA', createEventObj({category: 'embed', label: 'labelabc', metricValue: 242.2})]);
});
});
......@@ -90,7 +90,7 @@ function oldAnnotation() {
return {
id: 'annotation_id',
$highlight: undefined,
target: ['foo', 'bar'],
target: [{source: 'source', 'selector': [] }],
references: [],
text: 'This is my annotation',
tags: ['tag_1', 'tag_2'],
......
......@@ -12,6 +12,7 @@ describe('AppController', function () {
var $rootScope = null;
var fakeAnnotationMetadata = null;
var fakeAnnotationUI = null;
var fakeAnalytics = null;
var fakeAuth = null;
var fakeDrafts = null;
var fakeFeatures = null;
......@@ -58,6 +59,11 @@ describe('AppController', function () {
clearSelectedAnnotations: sandbox.spy(),
};
fakeAnalytics = {
track: sandbox.stub(),
events: {},
};
fakeAuth = {};
fakeDrafts = {
......@@ -107,6 +113,7 @@ describe('AppController', function () {
$provide.value('annotationUI', fakeAnnotationUI);
$provide.value('auth', fakeAuth);
$provide.value('analytics', fakeAnalytics);
$provide.value('drafts', fakeDrafts);
$provide.value('features', fakeFeatures);
$provide.value('frameSync', fakeFrameSync);
......
......@@ -71,7 +71,8 @@ describe('WidgetController', function () {
sandbox = sinon.sandbox.create();
fakeAnalytics = {
track: sandbox.spy(),
track: sandbox.stub(),
events: {},
};
fakeAnnotationMapper = {
......
......@@ -202,7 +202,7 @@ module.exports = function WidgetController(
$scope.$on('sidebarOpened', function () {
analytics.track('sidebarOpened');
analytics.track(analytics.events.SIDEBAR_OPENED);
streamer.connect();
});
......
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