Segment Queueable Events for App Started (#4383)

This commit is contained in:
Dimitri Mitropoulos authored and GitHub committed 2022-01-20 23:22:55 +00:00
1 parent 35c451b803
commit 24b610ffeb
7 files changed
+170 -143

No files matched your search

@@ -34,7 +34,7 @@ describe('init()', () => {
});
expect(settings.enableAnalytics).toBe(false);
expect(electron.net.request.mock.calls).toEqual([]);
await _trackEvent(true, 'Foo', 'Bar');
await _trackEvent({ interactive: true, category: 'Foo', action: 'Bar' });
jest.runAllTimers();
expect(electron.net.request.mock.calls).toEqual([]);
});
@@ -46,7 +46,7 @@ describe('init()', () => {
});
expect(settings.enableAnalytics).toBe(true);
expect(electron.net.request.mock.calls).toEqual([]);
await _trackEvent(true, 'Foo', 'Bar');
await _trackEvent({ interactive: true, category: 'Foo', action: 'Bar' });
jest.runAllTimers();
expect(electron.net.request.mock.calls).toEqual([
[
@@ -79,7 +79,7 @@ describe('init()', () => {
deviceId: 'device',
enableAnalytics: true,
});
await _trackEvent(false, 'Foo', 'Bar');
await _trackEvent({ interactive: false, category: 'Foo', action: 'Bar' });
jest.runAllTimers();
expect(electron.net.request.mock.calls).toEqual([
[
@@ -146,7 +146,13 @@ describe('init()', () => {
});
await _trackPageView('/my/path');
jest.runAllTimers();
await _trackEvent(true, 'cat', 'act', 'lab', 'val');
await _trackEvent({
interactive: true,
category: 'cat',
action: 'act',
label: 'lab',
value: 'val',
});
expect(electron.net.request.mock.calls).toEqual([
[
'https://www.google-analytics.com/collect?' +
+104 -99
View File
@@ -52,41 +52,13 @@ export function trackEvent(
value?: string | null,
) {
process.nextTick(async () => {
await _trackEvent(true, category, action, label, value);
});
}
export function trackNonInteractiveEvent(
category: string,
action: string,
label?: string | null,
value?: string | null,
) {
process.nextTick(async () => {
await _trackEvent(false, category, action, label, value, false);
});
}
/**
* Tracks an analytics event but queues it for later if analytics are
* currently disabled. Once analytics setting is enabled, any queued
* events will be sent automatically.
*
* This should be used sparingly!
*
* @param category
* @param action
* @param label
* @param value
*/
export function trackNonInteractiveEventQueueable(
category: string,
action: string,
label?: string | null,
value?: string | null,
) {
process.nextTick(async () => {
await _trackEvent(false, category, action, label, value, true);
await _trackEvent({
interactive: true,
category,
action,
label,
value,
});
});
}
@@ -115,6 +87,7 @@ export async function getDeviceId() {
let segmentClient: Analytics | null = null;
export enum SegmentEvent {
appStarted = 'App Started',
collectionCreate = 'Collection Created',
documentCreate = 'Document Created',
requestCreate = 'Request Created',
@@ -149,10 +122,60 @@ export function vcsSegmentEventProperties(
};
}
export async function trackSegmentEvent(event: SegmentEvent, properties?: Record<string, any>) {
interface QueuedSegmentEvent {
event: SegmentEvent;
properties?: Record<string, any>;
/**
* timestamps are required for Queued Segment Events so that when/if the event is enventually fired, it's fired with the timestamp when the event actually occurred.
* see: https://segment.com/docs/connections/spec/common
*/
timestamp: Date;
}
/**
* Flush any analytics events that were built up when analytics were disabled.
*/
let queuedEvents: QueuedSegmentEvent[] = [];
async function flushQueuedEvents() {
console.log(`[segment] Flushing ${queuedEvents.length} queued events`, queuedEvents);
const events = [...queuedEvents];
// Clear queue before we even start sending to prevent races
queuedEvents = [];
await Promise.all(events.map(({ event, properties, timestamp }) => (
trackSegmentEvent(event, properties, { timestamp })
)));
}
interface TrackSegmentEventOptions {
/**
* Tracks an analytics event but queues it for later if analytics are currently disabled
* Once analytics setting is enabled, any queued events will be sent automatically, all at once.
*/
queueable?: boolean;
timestamp?: Date;
}
export async function trackSegmentEvent(
event: SegmentEvent,
properties?: Record<string, any>,
{ queueable, timestamp }: TrackSegmentEventOptions = {},
) {
const settings = await models.settings.getOrCreate();
if (!settings.enableAnalytics) {
if (queueable) {
const queuedEvent: QueuedSegmentEvent = {
event,
properties,
timestamp: new Date(),
};
console.log('[segment] Queued event', queuedEvent);
queuedEvents.push(queuedEvent);
}
return;
}
@@ -177,6 +200,7 @@ export async function trackSegmentEvent(event: SegmentEvent, properties?: Record
userId,
event,
properties,
...(timestamp ? { timestamp } : {}),
context: {
app: {
name: getAppName(),
@@ -187,9 +211,13 @@ export async function trackSegmentEvent(event: SegmentEvent, properties?: Record
version: process.getSystemVersion(),
},
},
}, error => {
if (error) {
console.warn('[analytics] Error sending segment event', error);
}
});
} catch (err) {
console.warn('[analytics] Error sending segment event', err);
} catch (error: unknown) {
console.warn('[analytics] Unexpected error while sending segment event', error);
}
}
@@ -212,14 +240,19 @@ function _getOsName() {
}
// Exported for testing
export async function _trackEvent(
interactive: boolean,
category: string,
action: string,
label?: string | null,
value?: string | null,
queueable?: boolean | null,
) {
export async function _trackEvent({
interactive,
category,
action,
label,
value,
}: {
interactive: boolean;
category: string;
action: string;
label?: string | null;
value?: string | null;
}) {
const prefix = interactive ? '[ga] Event' : '[ga] Non-interactive';
console.log(prefix, [category, action, label, value].filter(Boolean).join(', '));
const params = [
@@ -235,24 +268,24 @@ export async function _trackEvent(
name: KEY_EVENT_ACTION,
value: action,
},
];
!interactive &&
params.push({
...(!interactive ? [{
name: KEY_NON_INTERACTION,
value: '1',
});
label &&
params.push({
}] : []),
...(label ? [{
name: KEY_EVENT_LABEL,
value: label,
});
value &&
params.push({
}] : []),
...(value ? [{
name: KEY_EVENT_VALUE,
value: value,
});
// @ts-expect-error -- TSCONVERSION appears to be a genuine error
await _sendToGoogle(params, !!queueable);
}] : []),
];
await _sendToGoogle({ params });
}
export async function _trackPageView(location: string) {
@@ -264,8 +297,7 @@ export async function _trackPageView(location: string) {
value: 'pageview',
},
];
// @ts-expect-error -- TSCONVERSION appears to be a genuine error
await _sendToGoogle(params, false);
await _sendToGoogle({ params });
}
async function _getDefaultParams(): Promise<RequestParameter[]> {
@@ -274,6 +306,9 @@ async function _getDefaultParams(): Promise<RequestParameter[]> {
const ua = String(window?.navigator?.userAgent)
.replace(new RegExp(`${getAppId()}\\/\\d+\\.\\d+\\.\\d+ `), '')
.replace(/Electron\/\d+\.\d+\.\d+ /, '');
const viewport = getViewportSize();
const params = [
{
name: KEY_VERSION,
@@ -331,50 +366,40 @@ async function _getDefaultParams(): Promise<RequestParameter[]> {
name: KEY_APPLICATION_VERSION,
value: getAppVersion(),
},
];
const viewport = getViewportSize();
viewport &&
params.push({
...(viewport ? [{
name: KEY_VIEWPORT_SIZE,
value: viewport,
});
global.document &&
params.push({
}] : []),
...(global.document ? [{
name: KEY_DOCUMENT_ENCODING,
value: global.document.inputEncoding,
});
}] : []),
];
return params;
}
// Monitor database changes to see if analytics gets enabled. If analytics
// become enabled, flush any queued events.
// Monitor database changes to see if analytics gets enabled.
// If analytics become enabled, flush any queued events.
db.onChange(async changes => {
for (const change of changes) {
const [event, doc] = change;
if (isSettings(doc) && event === 'update') {
if (doc.enableAnalytics) {
await _flushQueuedEvents();
await flushQueuedEvents();
}
}
}
});
async function _sendToGoogle(params: RequestParameter, queueable: boolean) {
async function _sendToGoogle({ params }: { params: RequestParameter[] }) {
const settings = await models.settings.getOrCreate();
if (!settings.enableAnalytics) {
if (queueable) {
console.log('[ga] Queued event', params);
_queuedEvents.push(params);
}
return;
}
const baseParams = await _getDefaultParams();
// @ts-expect-error -- TSCONVERSION appears to be a genuine error
const allParams = [...baseParams, ...params];
const qs = buildQueryStringFromParams(allParams);
const baseUrl = isDevelopment()
@@ -427,23 +452,3 @@ async function _sendToGoogle(params: RequestParameter, queueable: boolean) {
});
request.end();
}
/**
* Flush any analytics events that were built up when analytics
* were disabled.
* @returns {Promise<void>}
* @private
*/
let _queuedEvents: RequestParameter[] = [];
async function _flushQueuedEvents() {
console.log(`[ga] Flushing ${_queuedEvents.length} queued events`);
const tmp = [..._queuedEvents];
// Clear queue before we even start sending to prevent races
_queuedEvents = [];
for (const params of tmp) {
console.log('[ga] Flushing queued event', params);
await _sendToGoogle(params, false);
}
}
+5 -12
View File
@@ -4,7 +4,7 @@ import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electro
import path from 'path';
import appConfig from '../config/config.json';
import { trackNonInteractiveEventQueueable } from './common/analytics';
import { SegmentEvent, trackSegmentEvent } from './common/analytics';
import { changelogUrl, getAppVersion, isDevelopment, isMac } from './common/constants';
import { database } from './common/database';
import { disableSpellcheckerDownload, exitAppFailure } from './common/electron-helpers';
@@ -200,21 +200,14 @@ async function _trackStats() {
lastVersion: oldStats.currentVersion,
launches: oldStats.launches + 1,
});
// Update Stats Object
const firstLaunch = stats.launches === 1;
const justUpdated = !firstLaunch && stats.currentVersion !== stats.lastVersion;
if (firstLaunch) {
trackNonInteractiveEventQueueable('General', 'First Launch', stats.currentVersion);
} else if (justUpdated) {
trackNonInteractiveEventQueueable('General', 'Updated', stats.currentVersion);
} else {
trackNonInteractiveEventQueueable('General', 'Launched', stats.currentVersion);
}
trackSegmentEvent(SegmentEvent.appStarted, {}, { queueable: true });
ipcMain.once('window-ready', () => {
const { currentVersion } = stats;
const { currentVersion, launches, lastVersion } = stats;
const firstLaunch = launches === 1;
const justUpdated = !firstLaunch && currentVersion !== lastVersion;
if (!justUpdated || !currentVersion) {
return;
}
+32 -12
View File
@@ -5500,9 +5500,9 @@
"integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw=="
},
"@types/analytics-node": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/analytics-node/-/analytics-node-3.1.4.tgz",
"integrity": "sha512-i6cqjFotMq1dEwXxyXRqnzp/HmWPCskptrVUQ1UzRIGs/zICFWM2bIJyLt6f9A9/+qE98wls1AHWPQ4WXYS0HA==",
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/analytics-node/-/analytics-node-3.1.7.tgz",
"integrity": "sha512-qoBHCXqFqC22Up8dus8YIloZ2t1f8MJx9b3E08ZBK04yJ/ai8U2WuFUnaIBiD1okw4VtuNjqKn9mgLHnLxb5OQ==",
"dev": true
},
"@types/anymatch": {
@@ -7178,18 +7178,38 @@
"dev": true
},
"analytics-node": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/analytics-node/-/analytics-node-4.0.1.tgz",
"integrity": "sha512-+zXOOTB+eTRW6R9+pfvPfk1dHraFJzhNnAyZiYJIDGOjHQgfk9qfqgoJX9MfR4qY0J/E1YJ3FBncrLGadTDW1A==",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/analytics-node/-/analytics-node-6.0.0.tgz",
"integrity": "sha512-qhwB5Fl/ps7VTg1/RnD3qJohceSHUjzTBqNn3DCmQZu/AdgPbGPeNFYu2o3xIuIyq+xZElrv0Do0b/zuGxBL9g==",
"requires": {
"@segment/loosely-validate-event": "^2.0.0",
"axios": "^0.21.1",
"axios-retry": "^3.0.2",
"axios": "^0.21.4",
"axios-retry": "3.2.0",
"lodash.isstring": "^4.0.1",
"md5": "^2.2.1",
"ms": "^2.0.0",
"remove-trailing-slash": "^0.1.0",
"uuid": "^3.2.1"
"uuid": "^8.3.2"
},
"dependencies": {
"axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"requires": {
"follow-redirects": "^1.14.0"
}
},
"follow-redirects": {
"version": "1.14.7",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz",
"integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ=="
},
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
}
}
},
"ansi-align": {
@@ -7790,9 +7810,9 @@
}
},
"axios-retry": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.1.9.tgz",
"integrity": "sha512-NFCoNIHq8lYkJa6ku4m+V1837TP6lCa7n79Iuf8/AqATAHYB0ISaAS1eyIenDOfHOLtym34W65Sjke2xjg2fsA==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.2.0.tgz",
"integrity": "sha512-RK2cLMgIsAQBDhlIsJR5dOhODPigvel18XUv1dDXW+4k1FzebyfRk+C+orot6WPZOYFKSfhLwHPwVmTVOODQ5w==",
"requires": {
"is-retry-allowed": "^1.1.0"
}
+2 -2
View File
@@ -81,7 +81,7 @@
"@hapi/hawk": "^8.0.0",
"@hot-loader/react-dom": "^16.8.6",
"@stoplight/spectral": "^5.9.0",
"analytics-node": "^4.0.1",
"analytics-node": "^6.0.0",
"aws4": "^1.9.0",
"axios": "^0.21.2",
"class-autobind-decorator": "^3.0.1",
@@ -189,7 +189,7 @@
"@testing-library/react": "^11.2.6",
"@testing-library/react-hooks": "^5.1.2",
"@testing-library/user-event": "^14.0.0-beta.7",
"@types/analytics-node": "^3.1.4",
"@types/analytics-node": "^3.1.7",
"@types/aws4": "^1.5.1",
"@types/clone": "^2.1.0",
"@types/codemirror": "^5.60.2",
+16 -13
View File
@@ -361,24 +361,27 @@
}
},
"analytics-node": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/analytics-node/-/analytics-node-4.0.1.tgz",
"integrity": "sha512-+zXOOTB+eTRW6R9+pfvPfk1dHraFJzhNnAyZiYJIDGOjHQgfk9qfqgoJX9MfR4qY0J/E1YJ3FBncrLGadTDW1A==",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/analytics-node/-/analytics-node-6.0.0.tgz",
"integrity": "sha512-qhwB5Fl/ps7VTg1/RnD3qJohceSHUjzTBqNn3DCmQZu/AdgPbGPeNFYu2o3xIuIyq+xZElrv0Do0b/zuGxBL9g==",
"requires": {
"@segment/loosely-validate-event": "^2.0.0",
"axios": "^0.21.1",
"axios-retry": "^3.0.2",
"axios": "^0.21.4",
"axios-retry": "3.2.0",
"lodash.isstring": "^4.0.1",
"md5": "^2.2.1",
"ms": "^2.0.0",
"remove-trailing-slash": "^0.1.0",
"uuid": "^3.2.1"
"uuid": "^8.3.2"
},
"dependencies": {
"uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
"axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"requires": {
"follow-redirects": "^1.14.0"
}
}
}
},
@@ -511,9 +514,9 @@
}
},
"axios-retry": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.1.9.tgz",
"integrity": "sha512-NFCoNIHq8lYkJa6ku4m+V1837TP6lCa7n79Iuf8/AqATAHYB0ISaAS1eyIenDOfHOLtym34W65Sjke2xjg2fsA==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.2.0.tgz",
"integrity": "sha512-RK2cLMgIsAQBDhlIsJR5dOhODPigvel18XUv1dDXW+4k1FzebyfRk+C+orot6WPZOYFKSfhLwHPwVmTVOODQ5w==",
"requires": {
"is-retry-allowed": "^1.1.0"
}
+1 -1
View File
@@ -9,7 +9,7 @@
"dependencies": {
"@hapi/hawk": "^8.0.0",
"@stoplight/spectral": "^5.9.0",
"analytics-node": "^4.0.1",
"analytics-node": "^6.0.0",
"aws4": "^1.10.0",
"axios": "^0.21.2",
"clone": "^2.1.2",