mirror of
https://github.com/Kong/insomnia
synced 2024-11-08 14:49:53 +00:00
433 lines
11 KiB
TypeScript
433 lines
11 KiB
TypeScript
import Analytics from 'analytics-node';
|
|
import * as electron from 'electron';
|
|
import { buildQueryStringFromParams, joinUrlAndQueryString } from 'insomnia-url';
|
|
import * as uuid from 'uuid';
|
|
|
|
import { getAccountId } from '../account/session';
|
|
import { database as db } from '../common/database';
|
|
import * as models from '../models/index';
|
|
import type { RequestParameter } from '../models/request';
|
|
import { isSettings } from '../models/settings';
|
|
import {
|
|
getAppId,
|
|
getAppName,
|
|
getAppPlatform,
|
|
getAppVersion,
|
|
getGoogleAnalyticsId,
|
|
getGoogleAnalyticsLocation,
|
|
getSegmentWriteKey,
|
|
isDevelopment,
|
|
} from './constants';
|
|
import { getScreenResolution, getUserLanguage, getViewportSize } from './electron-helpers';
|
|
|
|
const DIMENSION_PLATFORM = 1;
|
|
const DIMENSION_VERSION = 2;
|
|
const KEY_TRACKING_ID = 'tid';
|
|
const KEY_VERSION = 'v';
|
|
const KEY_CLIENT_ID = 'cid';
|
|
const KEY_HIT_TYPE = 't';
|
|
const KEY_LOCATION = 'dl';
|
|
const KEY_TITLE = 'dt';
|
|
const KEY_NON_INTERACTION = 'ni';
|
|
const KEY_VIEWPORT_SIZE = 'vp';
|
|
const KEY_SCREEN_RESOLUTION = 'sr';
|
|
const KEY_USER_LANGUAGE = 'ul';
|
|
const KEY_USER_AGENT = 'ua';
|
|
const KEY_DOCUMENT_ENCODING = 'de';
|
|
const KEY_EVENT_CATEGORY = 'ec';
|
|
const KEY_EVENT_ACTION = 'ea';
|
|
const KEY_EVENT_LABEL = 'el';
|
|
const KEY_EVENT_VALUE = 'ev';
|
|
const KEY_ANONYMIZE_IP = 'aip';
|
|
const KEY_APPLICATION_NAME = 'an';
|
|
const KEY_APPLICATION_ID = 'aid';
|
|
const KEY_APPLICATION_VERSION = 'av';
|
|
const KEY_CUSTOM_DIMENSION_PREFIX = 'cd';
|
|
let _currentLocationPath = '/';
|
|
|
|
export function trackEvent(
|
|
category: string,
|
|
action: string,
|
|
label?: string | null,
|
|
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);
|
|
});
|
|
}
|
|
|
|
export function trackPageView(path: string) {
|
|
process.nextTick(async () => {
|
|
await _trackPageView(path);
|
|
});
|
|
}
|
|
|
|
export async function getDeviceId() {
|
|
const settings = await models.settings.getOrCreate();
|
|
let { deviceId } = settings;
|
|
|
|
if (!deviceId) {
|
|
// Migrate old GA ID into settings model if needed
|
|
const oldId = (window && window.localStorage.getItem('gaClientId')) || null;
|
|
deviceId = oldId || uuid.v4();
|
|
await models.settings.update(settings, {
|
|
deviceId,
|
|
});
|
|
}
|
|
|
|
return deviceId;
|
|
}
|
|
|
|
let segmentClient: Analytics | null = null;
|
|
|
|
export enum SegmentEvent {
|
|
collectionCreate = 'Collection Created',
|
|
documentCreate = 'Document Created',
|
|
pluginExportLoadAllWokspace = 'Plugin export loading all workspace',
|
|
pluginExportLoadWorkspacesInProject = 'Plugin export loading workspaces for active project',
|
|
requestCreate = 'Request Created',
|
|
requestExecute = 'Request Executed',
|
|
projectLocalCreate = 'Local Project Created',
|
|
projectLocalDelete = 'Local Project Deleted',
|
|
testSuiteCreate = 'Test Suite Created',
|
|
testSuiteDelete = 'Test Suite Deleted',
|
|
unitTestCreate = 'Unit Test Created',
|
|
unitTestDelete = 'Unit Test Deleted',
|
|
unitTestRun = 'Ran Individual Unit Test',
|
|
unitTestRunAll = 'Ran All Unit Tests',
|
|
}
|
|
|
|
export async function trackSegmentEvent(event: SegmentEvent, properties?: Record<string, any>) {
|
|
const settings = await models.settings.getOrCreate();
|
|
|
|
if (!settings.enableAnalytics) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (!segmentClient) {
|
|
segmentClient = new Analytics(getSegmentWriteKey(), {
|
|
// @ts-expect-error -- TSCONVERSION
|
|
axiosConfig: {
|
|
// This is needed to ensure that we use the NodeJS adapter in the render process
|
|
...(global?.require && {
|
|
adapter: global.require('axios/lib/adapters/http'),
|
|
}),
|
|
},
|
|
});
|
|
}
|
|
|
|
const anonymousId = await getDeviceId();
|
|
// TODO: This currently always returns an empty string in the main process
|
|
// This is due to the session data being stored in localStorage
|
|
const userId = getAccountId();
|
|
segmentClient.track({
|
|
anonymousId,
|
|
userId,
|
|
event,
|
|
properties,
|
|
context: {
|
|
app: {
|
|
name: getAppName(),
|
|
version: getAppVersion(),
|
|
},
|
|
os: {
|
|
name: _getOsName(),
|
|
version: process.getSystemVersion(),
|
|
},
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.warn('[analytics] Error sending segment event', err);
|
|
}
|
|
}
|
|
|
|
// ~~~~~~~~~~~~~~~~~ //
|
|
// Private Functions //
|
|
// ~~~~~~~~~~~~~~~~~ //
|
|
function _getOsName() {
|
|
const platform = getAppPlatform();
|
|
|
|
switch (platform) {
|
|
case 'darwin':
|
|
return 'mac';
|
|
|
|
case 'win32':
|
|
return 'windows';
|
|
|
|
default:
|
|
return platform;
|
|
}
|
|
}
|
|
|
|
// Exported for testing
|
|
export async function _trackEvent(
|
|
interactive: boolean,
|
|
category: string,
|
|
action: string,
|
|
label?: string | null,
|
|
value?: string | null,
|
|
queueable?: boolean | null,
|
|
) {
|
|
const prefix = interactive ? '[ga] Event' : '[ga] Non-interactive';
|
|
console.log(prefix, [category, action, label, value].filter(Boolean).join(', '));
|
|
const params = [
|
|
{
|
|
name: KEY_HIT_TYPE,
|
|
value: 'event',
|
|
},
|
|
{
|
|
name: KEY_EVENT_CATEGORY,
|
|
value: category,
|
|
},
|
|
{
|
|
name: KEY_EVENT_ACTION,
|
|
value: action,
|
|
},
|
|
];
|
|
!interactive &&
|
|
params.push({
|
|
name: KEY_NON_INTERACTION,
|
|
value: '1',
|
|
});
|
|
label &&
|
|
params.push({
|
|
name: KEY_EVENT_LABEL,
|
|
value: label,
|
|
});
|
|
value &&
|
|
params.push({
|
|
name: KEY_EVENT_VALUE,
|
|
value: value,
|
|
});
|
|
// @ts-expect-error -- TSCONVERSION appears to be a genuine error
|
|
await _sendToGoogle(params, !!queueable);
|
|
}
|
|
|
|
export async function _trackPageView(location: string) {
|
|
_currentLocationPath = location;
|
|
console.log('[ga] Page', _currentLocationPath);
|
|
const params = [
|
|
{
|
|
name: KEY_HIT_TYPE,
|
|
value: 'pageview',
|
|
},
|
|
];
|
|
// @ts-expect-error -- TSCONVERSION appears to be a genuine error
|
|
await _sendToGoogle(params, false);
|
|
}
|
|
|
|
async function _getDefaultParams(): Promise<RequestParameter[]> {
|
|
const deviceId = await getDeviceId();
|
|
// Prepping user agent string prior to sending to GA due to Electron base UA not being GA friendly.
|
|
const ua = String(window?.navigator?.userAgent)
|
|
.replace(new RegExp(`${getAppId()}\\/\\d+\\.\\d+\\.\\d+ `), '')
|
|
.replace(/Electron\/\d+\.\d+\.\d+ /, '');
|
|
const params = [
|
|
{
|
|
name: KEY_VERSION,
|
|
value: '1',
|
|
},
|
|
{
|
|
name: KEY_TRACKING_ID,
|
|
value: getGoogleAnalyticsId(),
|
|
},
|
|
{
|
|
name: KEY_CLIENT_ID,
|
|
value: deviceId,
|
|
},
|
|
{
|
|
name: KEY_USER_AGENT,
|
|
value: ua,
|
|
},
|
|
{
|
|
name: KEY_LOCATION,
|
|
value: getGoogleAnalyticsLocation() + _currentLocationPath,
|
|
},
|
|
{
|
|
name: KEY_SCREEN_RESOLUTION,
|
|
value: getScreenResolution(),
|
|
},
|
|
{
|
|
name: KEY_USER_LANGUAGE,
|
|
value: getUserLanguage(),
|
|
},
|
|
{
|
|
name: KEY_TITLE,
|
|
value: `${getAppId()}:${getAppVersion()}`,
|
|
},
|
|
{
|
|
name: KEY_CUSTOM_DIMENSION_PREFIX + DIMENSION_PLATFORM,
|
|
value: getAppPlatform(),
|
|
},
|
|
{
|
|
name: KEY_CUSTOM_DIMENSION_PREFIX + DIMENSION_VERSION,
|
|
value: getAppVersion(),
|
|
},
|
|
{
|
|
name: KEY_ANONYMIZE_IP,
|
|
value: '1',
|
|
},
|
|
{
|
|
name: KEY_APPLICATION_NAME,
|
|
value: getAppName(),
|
|
},
|
|
{
|
|
name: KEY_APPLICATION_ID,
|
|
value: getAppId(),
|
|
},
|
|
{
|
|
name: KEY_APPLICATION_VERSION,
|
|
value: getAppVersion(),
|
|
},
|
|
];
|
|
const viewport = getViewportSize();
|
|
viewport &&
|
|
params.push({
|
|
name: KEY_VIEWPORT_SIZE,
|
|
value: viewport,
|
|
});
|
|
global.document &&
|
|
params.push({
|
|
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.
|
|
db.onChange(async changes => {
|
|
for (const change of changes) {
|
|
const [event, doc] = change;
|
|
|
|
if (isSettings(doc) && event === 'update') {
|
|
if (doc.enableAnalytics) {
|
|
await _flushQueuedEvents();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
async function _sendToGoogle(params: RequestParameter, queueable: boolean) {
|
|
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()
|
|
? 'https://www.google-analytics.com/debug/collect'
|
|
: 'https://www.google-analytics.com/collect';
|
|
const url = joinUrlAndQueryString(baseUrl, qs);
|
|
const net = (electron.remote || electron).net;
|
|
const request = net.request(url);
|
|
request.once('error', err => {
|
|
console.warn('[ga] Network error', err);
|
|
});
|
|
request.once('response', response => {
|
|
const { statusCode } = response;
|
|
|
|
if (statusCode < 200 && statusCode >= 300) {
|
|
console.warn('[ga] Bad status code ' + statusCode);
|
|
}
|
|
|
|
const chunks: Buffer[] = [];
|
|
const [contentType] = response.headers['content-type'] || [];
|
|
|
|
if (contentType !== 'application/json') {
|
|
// Production GA API returns a Gif to use for tracking
|
|
return;
|
|
}
|
|
|
|
response.on('end', () => {
|
|
const jsonStr = Buffer.concat(chunks).toString('utf8');
|
|
|
|
try {
|
|
const data = JSON.parse(jsonStr);
|
|
const { hitParsingResult } = data;
|
|
|
|
if (hitParsingResult.valid) {
|
|
return;
|
|
}
|
|
|
|
for (const result of hitParsingResult || []) {
|
|
for (const msg of result.parserMessage || []) {
|
|
console.warn(`[ga] Error ${msg.description}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('[ga] Failed to parse response', err);
|
|
}
|
|
});
|
|
response.on('data', chunk => {
|
|
chunks.push(chunk);
|
|
});
|
|
});
|
|
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);
|
|
}
|
|
}
|