diff --git a/packages/insomnia-app/app/common/__tests__/analytics.test.ts b/packages/insomnia-app/app/common/__tests__/analytics.test.ts index a9668f3ed3..488f701cee 100644 --- a/packages/insomnia-app/app/common/__tests__/analytics.test.ts +++ b/packages/insomnia-app/app/common/__tests__/analytics.test.ts @@ -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?' + diff --git a/packages/insomnia-app/app/common/analytics.ts b/packages/insomnia-app/app/common/analytics.ts index 3fd593018b..a9253b9715 100644 --- a/packages/insomnia-app/app/common/analytics.ts +++ b/packages/insomnia-app/app/common/analytics.ts @@ -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) { +interface QueuedSegmentEvent { + event: SegmentEvent; + properties?: Record; + /** + * 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, + { 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 { @@ -274,6 +306,9 @@ async function _getDefaultParams(): Promise { 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 { 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} - * @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); - } -} diff --git a/packages/insomnia-app/app/main.development.ts b/packages/insomnia-app/app/main.development.ts index b6822cc5f5..a37ab8efdd 100644 --- a/packages/insomnia-app/app/main.development.ts +++ b/packages/insomnia-app/app/main.development.ts @@ -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; } diff --git a/packages/insomnia-app/package-lock.json b/packages/insomnia-app/package-lock.json index e41a401e8b..ef83fb2ed7 100644 --- a/packages/insomnia-app/package-lock.json +++ b/packages/insomnia-app/package-lock.json @@ -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" } diff --git a/packages/insomnia-app/package.json b/packages/insomnia-app/package.json index 918d8ff92e..0d0903fde6 100644 --- a/packages/insomnia-app/package.json +++ b/packages/insomnia-app/package.json @@ -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", diff --git a/packages/insomnia-send-request/package-lock.json b/packages/insomnia-send-request/package-lock.json index 793b8da5ef..acdb804687 100644 --- a/packages/insomnia-send-request/package-lock.json +++ b/packages/insomnia-send-request/package-lock.json @@ -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" } diff --git a/packages/insomnia-send-request/package.json b/packages/insomnia-send-request/package.json index 7b4f9faa93..4a588ae865 100644 --- a/packages/insomnia-send-request/package.json +++ b/packages/insomnia-send-request/package.json @@ -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",