Commit f2e80a6d authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Track every time pending updates are applied

parent d3f07781
......@@ -3,27 +3,30 @@ import { useCallback, useEffect } from 'preact/hooks';
import { useShortcut } from '../../shared/shortcut';
import { withServices } from '../service-context';
import type { AnalyticsService } from '../services/analytics';
import type { StreamerService } from '../services/streamer';
import type { ToastMessengerService } from '../services/toast-messenger';
import { useSidebarStore } from '../store';
export type PendingUpdatesButtonProps = {
// Injected
analytics: AnalyticsService;
streamer: StreamerService;
toastMessenger: ToastMessengerService;
};
function PendingUpdatesButton({
analytics,
streamer,
toastMessenger,
}: PendingUpdatesButtonProps) {
const store = useSidebarStore();
const pendingUpdateCount = store.pendingUpdateCount();
const hasPendingUpdates = store.hasPendingUpdates();
const applyPendingUpdates = useCallback(
() => streamer.applyPendingUpdates(),
[streamer],
);
const applyPendingUpdates = useCallback(() => {
streamer.applyPendingUpdates();
analytics.trackEvent('client.realtime.apply_updates');
}, [analytics, streamer]);
useShortcut('l', () => hasPendingUpdates && applyPendingUpdates());
......@@ -57,6 +60,7 @@ function PendingUpdatesButton({
}
export default withServices(PendingUpdatesButton, [
'analytics',
'streamer',
'toastMessenger',
]);
......@@ -6,12 +6,14 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { pluralize } from '../../shared/pluralize';
import { useShortcut } from '../../shared/shortcut';
import { withServices } from '../service-context';
import type { AnalyticsService } from '../services/analytics';
import type { StreamerService } from '../services/streamer';
import { useSidebarStore } from '../store';
export type PendingUpdatesNotificationProps = {
// Injected
streamer: StreamerService;
analytics: AnalyticsService;
// Test seams
setTimeout_?: typeof setTimeout;
......@@ -43,6 +45,7 @@ const collapseDelay = 5000;
function PendingUpdatesNotification({
streamer,
analytics,
/* istanbul ignore next - test seam */
setTimeout_ = setTimeout,
/* istanbul ignore next - test seam */
......@@ -51,10 +54,10 @@ function PendingUpdatesNotification({
const store = useSidebarStore();
const pendingUpdateCount = store.pendingUpdateCount();
const hasPendingChanges = store.hasPendingUpdatesOrDeletions();
const applyPendingUpdates = useCallback(
() => streamer.applyPendingUpdates(),
[streamer],
);
const applyPendingUpdates = useCallback(() => {
streamer.applyPendingUpdates();
analytics.trackEvent('client.realtime.apply_updates');
}, [analytics, streamer]);
const [collapsed, setCollapsed] = useState(false);
const timeout = useRef<number | null>(null);
......@@ -110,4 +113,7 @@ function PendingUpdatesNotification({
);
}
export default withServices(PendingUpdatesNotification, ['streamer']);
export default withServices(PendingUpdatesNotification, [
'streamer',
'analytics',
]);
......@@ -6,6 +6,7 @@ describe('PendingUpdatesButton', () => {
let fakeToastMessenger;
let fakeStore;
let fakeStreamer;
let fakeAnalytics;
beforeEach(() => {
fakeToastMessenger = {
......@@ -18,6 +19,9 @@ describe('PendingUpdatesButton', () => {
fakeStreamer = {
applyPendingUpdates: sinon.stub(),
};
fakeAnalytics = {
trackEvent: sinon.stub(),
};
$imports.$mock({
'../store': { useSidebarStore: () => fakeStore },
......@@ -36,6 +40,7 @@ describe('PendingUpdatesButton', () => {
<PendingUpdatesButton
streamer={fakeStreamer}
toastMessenger={fakeToastMessenger}
analytics={fakeAnalytics}
/>,
);
};
......@@ -82,6 +87,10 @@ describe('PendingUpdatesButton', () => {
applyBtn.props().onClick();
assert.called(fakeStreamer.applyPendingUpdates);
assert.calledWith(
fakeAnalytics.trackEvent,
'client.realtime.apply_updates',
);
});
it('applies updates when keyboard shortcut is pressed', () => {
......@@ -94,5 +103,9 @@ describe('PendingUpdatesButton', () => {
);
assert.called(fakeStreamer.applyPendingUpdates);
assert.calledWith(
fakeAnalytics.trackEvent,
'client.realtime.apply_updates',
);
});
});
......@@ -10,6 +10,7 @@ describe('PendingUpdatesNotification', () => {
let fakeSetTimeout;
let fakeClearTimeout;
let fakeStreamer;
let fakeAnalytics;
let fakeStore;
beforeEach(() => {
......@@ -18,6 +19,9 @@ describe('PendingUpdatesNotification', () => {
fakeStreamer = {
applyPendingUpdates: sinon.stub(),
};
fakeAnalytics = {
trackEvent: sinon.stub(),
};
fakeStore = {
pendingUpdateCount: sinon.stub().returns(3),
hasPendingUpdatesOrDeletions: sinon.stub().returns(true),
......@@ -38,6 +42,7 @@ describe('PendingUpdatesNotification', () => {
return mount(
<PendingUpdatesNotification
streamer={fakeStreamer}
analytics={fakeAnalytics}
setTimeout_={fakeSetTimeout}
clearTimeout_={fakeClearTimeout}
/>,
......@@ -106,8 +111,13 @@ describe('PendingUpdatesNotification', () => {
const wrapper = createComponent();
assert.notCalled(fakeStreamer.applyPendingUpdates);
assert.notCalled(fakeAnalytics.trackEvent);
wrapper.find('button').simulate('click');
assert.called(fakeStreamer.applyPendingUpdates);
assert.calledWith(
fakeAnalytics.trackEvent,
'client.realtime.apply_updates',
);
});
[true, false].forEach(hasPendingUpdates => {
......@@ -121,6 +131,7 @@ describe('PendingUpdatesNotification', () => {
wrapper = createComponent(container);
assert.notCalled(fakeStreamer.applyPendingUpdates);
assert.notCalled(fakeAnalytics.trackEvent);
document.documentElement.dispatchEvent(
new KeyboardEvent('keydown', { key: 'l' }),
);
......@@ -128,6 +139,7 @@ describe('PendingUpdatesNotification', () => {
fakeStreamer.applyPendingUpdates.called,
hasPendingUpdates,
);
assert.equal(fakeAnalytics.trackEvent.called, hasPendingUpdates);
} finally {
wrapper?.unmount();
container.remove();
......
......@@ -17,6 +17,7 @@ import {
preStartServer as preStartRPCServer,
} from './cross-origin-rpc';
import { ServiceContext } from './service-context';
import { AnalyticsService } from './services/analytics';
import { AnnotationActivityService } from './services/annotation-activity';
import { AnnotationsService } from './services/annotations';
import { AnnotationsExporter } from './services/annotations-exporter';
......@@ -134,6 +135,7 @@ function startApp(settings: SidebarSettings, appEl: HTMLElement) {
// Register services.
container
.register('analytics', AnalyticsService)
.register('annotationsExporter', AnnotationsExporter)
.register('annotationsService', AnnotationsService)
.register('annotationActivity', AnnotationActivityService)
......
import type { AnalyticsEventName, APIService } from './api';
/**
* @inject
*/
export class AnalyticsService {
private _api: APIService;
constructor(api: APIService) {
this._api = api;
}
trackEvent(name: AnalyticsEventName): void {
this._api.analytics.events
.create({}, { event: name })
.catch(e => console.warn(`Could not track event "${name}"`, e));
}
}
......@@ -171,6 +171,12 @@ type ListGroupParams = {
expand?: string[];
};
export type AnalyticsEventName = 'client.realtime.apply_updates';
export type AnalyticsEvent = {
event: AnalyticsEventName;
};
/**
* API client for the Hypothesis REST API.
*
......@@ -224,6 +230,11 @@ export class APIService {
read: APICall<{ authority?: string }, void, Profile>;
update: APICall<Record<string, unknown>, Partial<Profile>, Profile>;
};
analytics: {
events: {
create: APICall<Record<string, unknown>, AnalyticsEvent>;
};
};
constructor(
apiRoutes: APIRoutesService,
......@@ -304,6 +315,14 @@ export class APIService {
Profile
>,
};
this.analytics = {
events: {
create: apiCall('analytics.events.create') as APICall<
Record<string, unknown>,
AnalyticsEvent
>,
},
};
}
/**
......
import sinon from 'sinon';
import { AnalyticsService } from '../analytics';
describe('AnalyticsService', () => {
let fakeAPIService;
let analyticsService;
beforeEach(() => {
fakeAPIService = {
analytics: {
events: {
create: sinon.stub().resolves(),
},
},
};
analyticsService = new AnalyticsService(fakeAPIService);
});
describe('trackEvent', () => {
it('creates an event through the API', () => {
analyticsService.trackEvent('client.realtime.apply_updates');
assert.calledWith(
fakeAPIService.analytics.events.create,
{},
{ event: 'client.realtime.apply_updates' },
);
});
it('logs ', async () => {
const error = new Error('something failed');
fakeAPIService.analytics.events.create.rejects(error);
sinon.stub(console, 'warn');
try {
analyticsService.trackEvent('client.realtime.apply_updates');
// Wait for next tick so that the API call promise settles
await Promise.resolve();
assert.calledWith(
console.warn,
'Could not track event "client.realtime.apply_updates"',
error,
);
} finally {
console.warn.restore();
}
});
});
});
......@@ -84,6 +84,15 @@
"method": "DELETE",
"desc": "Delete an annotation"
}
},
"analytics": {
"events": {
"create": {
"url": "https://example.com/api/analytics/events",
"method": "POST",
"desc": "Create a new analytics event"
}
}
}
}
}
......@@ -196,6 +196,14 @@ describe('APIService', () => {
return api.profile.update({}, { preferences: {} });
});
it('creates analytics event', () => {
expectCall('post', 'analytics/events');
return api.analytics.events.create(
{},
{ event: 'client.realtime.apply_updates' },
);
});
context('when an API call fails', () => {
[
{
......
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