mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-03-09 06:23:30 +08:00
Improve analytics / metrics
This commit is contained in:
parent
8445efec55
commit
e945f33378
7 changed files with 457 additions and 4 deletions
286
app/internal_packages/analytics/analytics-electron/index.es6
Normal file
286
app/internal_packages/analytics/analytics-electron/index.es6
Normal file
|
@ -0,0 +1,286 @@
|
|||
/* eslint no-param-reassign: 0 */
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const validate = require('@segment/loosely-validate-event');
|
||||
const debug = require('debug')('analytics-node');
|
||||
const version = `3.0.0`
|
||||
|
||||
// BG: Dependencies of analytics-node I lifted in
|
||||
|
||||
// https://github.com/segmentio/crypto-token/blob/master/lib/index.js
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
function uid(length, fn) {
|
||||
const str = (bytes) => {
|
||||
const res = [];
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
res.push(chars[bytes[i] % chars.length]);
|
||||
}
|
||||
return res.join('');
|
||||
}
|
||||
|
||||
if (typeof length === 'function') {
|
||||
fn = length;
|
||||
length = null;
|
||||
}
|
||||
if (!length) {
|
||||
length = 10;
|
||||
}
|
||||
if (!fn) {
|
||||
return str(crypto.randomBytes(length));
|
||||
}
|
||||
|
||||
crypto.randomBytes(length, (err, bytes) => {
|
||||
if (err) {
|
||||
fn(err);
|
||||
return;
|
||||
}
|
||||
fn(null, str(bytes));
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// https://github.com/stephenmathieson/remove-trailing-slash/blob/master/index.js
|
||||
function removeSlash(str) {
|
||||
return String(str).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
|
||||
const setImmediate = global.setImmediate || process.nextTick.bind(process)
|
||||
const noop = () => {}
|
||||
|
||||
export default class Analytics {
|
||||
/**
|
||||
* Initialize a new `Analytics` with your Segment project's `writeKey` and an
|
||||
* optional dictionary of `options`.
|
||||
*
|
||||
* @param {String} writeKey
|
||||
* @param {Object} [options] (optional)
|
||||
* @property {Number} flushAt (default: 20)
|
||||
* @property {Number} flushInterval (default: 10000)
|
||||
* @property {String} host (default: 'https://api.segment.io')
|
||||
*/
|
||||
|
||||
constructor(writeKey, options) {
|
||||
options = options || {}
|
||||
|
||||
assert(writeKey, 'You must pass your Segment project\'s write key.')
|
||||
|
||||
this.queue = []
|
||||
this.writeKey = writeKey
|
||||
this.host = removeSlash(options.host || 'https://api.segment.io')
|
||||
this.flushAt = Math.max(options.flushAt, 1) || 20
|
||||
this.flushInterval = options.flushInterval || 10000
|
||||
this.flushed = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an identify `message`.
|
||||
*
|
||||
* @param {Object} message
|
||||
* @param {Function} [callback] (optional)
|
||||
* @return {Analytics}
|
||||
*/
|
||||
|
||||
identify(message, callback) {
|
||||
validate(message, 'identify')
|
||||
this.enqueue('identify', message, callback)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a group `message`.
|
||||
*
|
||||
* @param {Object} message
|
||||
* @param {Function} [callback] (optional)
|
||||
* @return {Analytics}
|
||||
*/
|
||||
|
||||
group(message, callback) {
|
||||
validate(message, 'group')
|
||||
this.enqueue('group', message, callback)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a track `message`.
|
||||
*
|
||||
* @param {Object} message
|
||||
* @param {Function} [callback] (optional)
|
||||
* @return {Analytics}
|
||||
*/
|
||||
|
||||
track(message, callback) {
|
||||
validate(message, 'track')
|
||||
this.enqueue('track', message, callback)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a page `message`.
|
||||
*
|
||||
* @param {Object} message
|
||||
* @param {Function} [callback] (optional)
|
||||
* @return {Analytics}
|
||||
*/
|
||||
|
||||
page(message, callback) {
|
||||
validate(message, 'page')
|
||||
this.enqueue('page', message, callback)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a screen `message`.
|
||||
*
|
||||
* @param {Object} message
|
||||
* @param {Function} fn (optional)
|
||||
* @return {Analytics}
|
||||
*/
|
||||
|
||||
screen(message, callback) {
|
||||
validate(message, 'screen')
|
||||
this.enqueue('screen', message, callback)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an alias `message`.
|
||||
*
|
||||
* @param {Object} message
|
||||
* @param {Function} [callback] (optional)
|
||||
* @return {Analytics}
|
||||
*/
|
||||
|
||||
alias(message, callback) {
|
||||
validate(message, 'alias')
|
||||
this.enqueue('alias', message, callback)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a `message` of type `type` to the queue and
|
||||
* check whether it should be flushed.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {Object} message
|
||||
* @param {Functino} [callback] (optional)
|
||||
* @api private
|
||||
*/
|
||||
|
||||
enqueue(type, message, callback) {
|
||||
callback = callback || noop
|
||||
|
||||
message = Object.assign({}, message)
|
||||
message.type = type
|
||||
message.context = Object.assign({
|
||||
library: {
|
||||
name: 'analytics-node',
|
||||
version,
|
||||
},
|
||||
}, message.context)
|
||||
|
||||
message._metadata = Object.assign({
|
||||
nodeVersion: process.versions.node,
|
||||
}, message._metadata)
|
||||
|
||||
if (!message.timestamp) {
|
||||
message.timestamp = new Date()
|
||||
}
|
||||
|
||||
if (!message.messageId) {
|
||||
message.messageId = `node-${uid(32)}`
|
||||
}
|
||||
|
||||
debug('%s: %o', type, message)
|
||||
|
||||
this.queue.push({ message, callback })
|
||||
|
||||
if (!this.flushed) {
|
||||
this.flushed = true
|
||||
this.flush()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.queue.length >= this.flushAt) {
|
||||
this.flush()
|
||||
}
|
||||
|
||||
if (this.flushInterval && !this.timer) {
|
||||
this.timer = setTimeout(this.flush.bind(this), this.flushInterval)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the current queue
|
||||
*
|
||||
* @param {Function} [callback] (optional)
|
||||
* @return {Analytics}
|
||||
*/
|
||||
|
||||
async flush(callback) {
|
||||
callback = callback || noop
|
||||
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
|
||||
if (!this.queue.length) {
|
||||
setImmediate(callback)
|
||||
return;
|
||||
}
|
||||
|
||||
const items = this.queue.splice(0, this.flushAt)
|
||||
const callbacks = items.map(item => item.callback)
|
||||
const messages = items.map(item => item.message)
|
||||
|
||||
const data = {
|
||||
batch: messages,
|
||||
timestamp: new Date(),
|
||||
sentAt: new Date(),
|
||||
}
|
||||
|
||||
debug('flush: %o', data)
|
||||
|
||||
const options = {
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include',
|
||||
headers: new Headers(),
|
||||
method: 'POST',
|
||||
};
|
||||
options.headers.set('Accept', 'application/json');
|
||||
options.headers.set('Authorization', `Basic ${btoa(`${this.writeKey}:`)}`)
|
||||
options.headers.set('Content-Type', 'application/json');
|
||||
|
||||
const runCallbacks = (err) => {
|
||||
callbacks.forEach((cb) => cb(err))
|
||||
callback(err, data);
|
||||
debug('flushed: %o', data);
|
||||
};
|
||||
|
||||
let resp = null;
|
||||
try {
|
||||
resp = await fetch(`${this.host}/v1/batch`, options);
|
||||
} catch (err) {
|
||||
runCallbacks(err, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
runCallbacks(new Error(`${resp.statusCode}: ${resp.statusText}`), null);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await resp.json();
|
||||
if (body.error) {
|
||||
runCallbacks(new Error(body.error.message), null);
|
||||
return;
|
||||
}
|
||||
runCallbacks(null, {
|
||||
status: resp.statusCode,
|
||||
body: body,
|
||||
ok: true,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
This folder contains a modified version of analytics-node. The original version uses `superagent`, which is both unnecessary (since we have both Browser and Node APIs and can use `fetch` and also added ~2.1MB of JavaScript to Merani.)
|
150
app/internal_packages/analytics/lib/analytics-store.es6
Normal file
150
app/internal_packages/analytics/lib/analytics-store.es6
Normal file
|
@ -0,0 +1,150 @@
|
|||
import _ from 'underscore'
|
||||
import NylasStore from 'nylas-store'
|
||||
import {
|
||||
IdentityStore,
|
||||
Actions,
|
||||
AccountStore,
|
||||
FocusedPerspectiveStore,
|
||||
NylasAPIRequest,
|
||||
} from 'nylas-exports'
|
||||
|
||||
import AnalyticsSink from '../analytics-electron'
|
||||
|
||||
/**
|
||||
* We wait 15 seconds to give the alias time to register before we send
|
||||
* any events.
|
||||
*/
|
||||
const DEBOUNCE_TIME = 5 * 1000;
|
||||
|
||||
class AnalyticsStore extends NylasStore {
|
||||
|
||||
activate() {
|
||||
// Allow requests to be grouped together if they're fired back-to-back,
|
||||
// but generally report each event as it happens. This segment library
|
||||
// is intended for a server where the user doesn't quit...
|
||||
this.analytics = new AnalyticsSink("merani", {
|
||||
host: `${NylasAPIRequest.rootURLForServer('identity')}/api/s`,
|
||||
flushInterval: 500,
|
||||
flushAt: 5,
|
||||
})
|
||||
this.launchTime = Date.now();
|
||||
|
||||
const identifySoon = _.debounce(this.identify, DEBOUNCE_TIME);
|
||||
identifySoon();
|
||||
|
||||
// also ping the server every hour to make sure someone running
|
||||
// the app for days has up-to-date traits.
|
||||
setInterval(identifySoon, 60 * 60 * 1000); // 60 min
|
||||
|
||||
this.listenTo(IdentityStore, identifySoon);
|
||||
this.listenTo(Actions.recordUserEvent, (eventName, eventArgs) => {
|
||||
this.track(eventName, eventArgs);
|
||||
})
|
||||
}
|
||||
|
||||
// Properties applied to all events (only).
|
||||
eventState() {
|
||||
// Get a bit of context about the current perspective
|
||||
// so we can assess usage of unified inbox, etc.
|
||||
const perspective = FocusedPerspectiveStore.current();
|
||||
let currentProvider = null;
|
||||
|
||||
if (perspective && perspective.accountIds.length > 1) {
|
||||
currentProvider = 'Unified';
|
||||
} else {
|
||||
const account = perspective ? AccountStore.accountForId(perspective.accountIds[0]) : AccountStore.accounts()[0];
|
||||
currentProvider = account.displayProvider();
|
||||
}
|
||||
|
||||
return {
|
||||
currentProvider,
|
||||
};
|
||||
}
|
||||
|
||||
// Properties applied to all events and all people during an identify.
|
||||
superTraits() {
|
||||
const theme = NylasEnv.themes ? NylasEnv.themes.getActiveTheme() : null;
|
||||
|
||||
return {
|
||||
version: NylasEnv.getVersion().split("-")[0],
|
||||
platform: process.platform,
|
||||
activeTheme: theme ? theme.name : null,
|
||||
workspaceMode: NylasEnv.config.get("core.workspace.mode"),
|
||||
};
|
||||
}
|
||||
|
||||
baseTraits() {
|
||||
return Object.assign({}, this.superTraits(), {
|
||||
firstDaySeen: this.firstDaySeen(),
|
||||
timeSinceLaunch: (Date.now() - this.launchTime) / 1000,
|
||||
accountCount: AccountStore.accounts().length,
|
||||
providers: AccountStore.accounts().map((a) => a.displayProvider()),
|
||||
});
|
||||
}
|
||||
|
||||
personalTraits() {
|
||||
const identity = IdentityStore.identity();
|
||||
if (!(identity && identity.id)) { return {}; }
|
||||
|
||||
return {
|
||||
email: identity.emailAddress,
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
};
|
||||
}
|
||||
|
||||
track(eventName, eventArgs = {}) {
|
||||
// if (NylasEnv.inDevMode()) { return }
|
||||
|
||||
const identity = IdentityStore.identity()
|
||||
if (!(identity && identity.id)) { return; }
|
||||
|
||||
this.analytics.track({
|
||||
event: eventName,
|
||||
userId: identity.id,
|
||||
properties: Object.assign({},
|
||||
eventArgs,
|
||||
this.eventState(),
|
||||
this.superTraits(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
firstDaySeen() {
|
||||
let firstDaySeen = NylasEnv.config.get("firstDaySeen");
|
||||
if (!firstDaySeen) {
|
||||
const [y, m, d] = (new Date()).toISOString().split(/[-|T]/);
|
||||
firstDaySeen = `${m}/${d}/${y}`;
|
||||
NylasEnv.config.set("firstDaySeen", firstDaySeen);
|
||||
}
|
||||
return firstDaySeen;
|
||||
}
|
||||
|
||||
identify = () => {
|
||||
if (!NylasEnv.isMainWindow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const identity = IdentityStore.identity();
|
||||
if (!(identity && identity.id)) { return; }
|
||||
|
||||
this.analytics.identify({
|
||||
userId: identity.id,
|
||||
traits: this.baseTraits(),
|
||||
integrations: {All: true},
|
||||
});
|
||||
|
||||
// Ensure we never send PI anywhere but Mixpanel
|
||||
|
||||
this.analytics.identify({
|
||||
userId: identity.id,
|
||||
traits: Object.assign({}, this.baseTraits(), this.personalTraits()),
|
||||
integrations: {
|
||||
All: false,
|
||||
Mixpanel: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new AnalyticsStore()
|
5
app/internal_packages/analytics/lib/main.es6
Normal file
5
app/internal_packages/analytics/lib/main.es6
Normal file
|
@ -0,0 +1,5 @@
|
|||
import AnalyticsStore from './analytics-store'
|
||||
|
||||
export function activate() {
|
||||
AnalyticsStore.activate()
|
||||
}
|
14
app/internal_packages/analytics/package.json
Normal file
14
app/internal_packages/analytics/package.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "analytics",
|
||||
"version": "0.1.0",
|
||||
"main": "./lib/main",
|
||||
"description": "Analytics",
|
||||
"license": "Proprietary",
|
||||
"private": true,
|
||||
"windowTypes": {
|
||||
"default": true
|
||||
},
|
||||
"engines": {
|
||||
"merani": "*"
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@
|
|||
"license": "GPL-3.0",
|
||||
"main": "./src/browser/main.js",
|
||||
"dependencies": {
|
||||
"@segment/loosely-validate-event": "^1.1.2",
|
||||
"async": "^0.9",
|
||||
"babel-core": "6.22.0",
|
||||
"babel-preset-electron": "bengotow/babel-preset-electron#00783dfc438f122997993ae597a41ec315ba121b",
|
||||
|
|
|
@ -354,10 +354,6 @@ export default class NylasEnvConstructor {
|
|||
return this.getWindowType() === 'emptyWindow';
|
||||
}
|
||||
|
||||
isWorkWindow() {
|
||||
return this.getWindowType() === 'work';
|
||||
}
|
||||
|
||||
isComposerWindow() {
|
||||
return this.getWindowType() === 'composer';
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue