Improve analytics / metrics

This commit is contained in:
Ben Gotow 2017-08-27 00:52:07 -07:00
parent 8445efec55
commit e945f33378
7 changed files with 457 additions and 4 deletions

View 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,
});
}
}

View file

@ -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.)

View 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()

View file

@ -0,0 +1,5 @@
import AnalyticsStore from './analytics-store'
export function activate() {
AnalyticsStore.activate()
}

View 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": "*"
}
}

View file

@ -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",

View file

@ -354,10 +354,6 @@ export default class NylasEnvConstructor {
return this.getWindowType() === 'emptyWindow';
}
isWorkWindow() {
return this.getWindowType() === 'work';
}
isComposerWindow() {
return this.getWindowType() === 'composer';
}