feat(metrics): extract common code for emitting amplitude events

https://github.com/mozilla/fxa-shared/pull/20
r=shane-tomlinson
This commit is contained in:
Phil Booth 2018-04-09 16:00:11 +01:00 коммит произвёл GitHub
Родитель b377166887
Коммит d8e9379e72
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
4 изменённых файлов: 694 добавлений и 0 удалений

Просмотреть файл

@ -3,6 +3,9 @@ module.exports = {
email: {
popularDomains: require('./email/popularDomains')
},
metrics: {
amplitude: require('./metrics/amplitude')
},
l10n: {
localizeTimestamp: require('./l10n/localizeTimestamp'),
supportedLanguages: require('./l10n/supportedLanguages')

348
metrics/amplitude.js Normal file
Просмотреть файл

@ -0,0 +1,348 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict';
let APP_VERSION;
try {
APP_VERSION = /^[0-9]+\.([0-9]+)\./.exec(require('../../../package.json').version)[1];
} catch (err) {
}
const DAY = 1000 * 60 * 60 * 24;
const WEEK = DAY * 7;
const FOUR_WEEKS = WEEK * 4;
const GROUPS = {
activity: 'fxa_activity',
connectDevice: 'fxa_connect_device',
email: 'fxa_email',
emailFirst: 'fxa_email_first',
login: 'fxa_login',
registration: 'fxa_reg',
settings: 'fxa_pref',
sms: 'fxa_sms'
};
const CONNECT_DEVICE_FLOWS = {
'app-store': 'store_buttons',
install_from: 'store_buttons',
signin_from: 'signin',
sms: 'sms'
};
const EMAIL_TYPES = {
// Indexed by content server view name
'complete-reset-password': 'reset_password',
'complete-signin': 'login',
'verify-email': 'registration',
// Indexed by auth server template name
newDeviceLoginEmail: 'login',
passwordChangedEmail: 'change_password',
passwordResetEmail: 'reset_password',
passwordResetRequiredEmail: 'reset_password',
postChangePrimaryEmail: 'change_email',
postRemoveSecondaryEmail: 'secondary_email',
postVerifyEmail: 'registration',
postVerifySecondaryEmail: 'secondary_email',
postConsumeRecoveryCodeEmail: '2fa',
postNewRecoveryCodesEmail: '2fa',
recoveryEmail: 'reset_password',
unblockCode: 'unblock',
verifyEmail: 'registration',
verifyLoginEmail: 'login',
verifyLoginCodeEmail: 'login',
verifyPrimaryEmail: 'verify',
verifySyncEmail: 'registration',
verifySecondaryEmail: 'secondary_email'
}
const NEWSLETTER_STATES = {
optIn: 'subscribed',
optOut: 'unsubscribed',
};
const EVENT_PROPERTIES = {
[GROUPS.activity]: NOP,
[GROUPS.connectDevice]: mapConnectDeviceFlow,
[GROUPS.email]: mapEmailType,
[GROUPS.emailFirst]: NOP,
[GROUPS.login]: NOP,
[GROUPS.registration]: NOP,
[GROUPS.settings]: mapDisconnectReason,
[GROUPS.sms]: NOP
}
function NOP () {}
function mapConnectDeviceFlow (eventType, eventCategory, eventTarget) {
const connect_device_flow = CONNECT_DEVICE_FLOWS[eventCategory];
if (connect_device_flow) {
const result = { connect_device_flow };
if (eventTarget) {
result.connect_device_os = eventTarget;
}
return result;
}
}
function mapEmailType (eventType, eventCategory, eventTarget, data) {
const email_type = EMAIL_TYPES[eventCategory];
if (email_type) {
const result = { email_type, email_provider: data.emailDomain };
const { templateVersion } = data;
if (templateVersion) {
result.email_template = eventCategory;
result.email_version = templateVersion;
}
return result;
}
}
function mapDisconnectReason (eventType, eventCategory) {
if (eventType === 'disconnect_device' && eventCategory) {
return { reason: eventCategory };
}
}
module.exports = {
GROUPS,
EMAIL_TYPES,
/**
* Initialize an amplitude event mapper. You can read more about the amplitude
* event structure here:
*
* https://amplitude.zendesk.com/hc/en-us/articles/204771828-HTTP-API
*
* And you can see our event taxonomy here:
*
* https://docs.google.com/spreadsheets/d/1G_8OJGOxeWXdGJ1Ugmykk33Zsl-qAQL05CONSeD4Uz4
*
* @param {Object} services An object of client-id:service-name mappings.
*
* @param {Object} events An object of name:definition event mappings, where
* each defintion value is itself an object with `group`
* and `event` string properties.
*
* @param {Map} fuzzyEvents A map of regex:definition event mappings. Each regex
* key may include up to two capturing groups. The first
* group is used as the `eventCategory` and the second is
* used as the `eventTarget`. Again each definition value
* is an object containing `group` and `event` properties
* but here `group` can be a string or a function. If it's
* a function, it will be passed the matched `eventCategory`
* as its argument and should return the group string.
*
* @returns {Function} The mapper function.
*/
initialize (services, events, fuzzyEvents) {
/**
* Map from a source event and it's associated data to an amplitude event.
*
* @param {Object} event The source event to map from.
*
* @param {String} event.type The type of the event.
*
* @param {Number} event.time The time of the event in epoch-milliseconds.
*
* @param {Object} data All of the data associated with the event. This
* parameter supports many properties that are too
* numerous to list here, but may be discerned with
* ease by perusing the code.
*/
return (event, data) => {
if (! event || ! data) {
return;
}
let eventType = event.type;
let mapping = events[eventType];
let eventCategory, eventTarget;
if (! mapping) {
for (const [ key, value ] of fuzzyEvents.entries()) {
const match = key.exec(eventType);
if (match) {
mapping = value;
if (match.length >= 2) {
eventCategory = match[1];
if (match.length === 3) {
eventTarget = match[2];
}
}
break;
}
}
}
if (mapping) {
eventType = mapping.event;
let eventGroup = mapping.group;
if (typeof eventGroup === 'function') {
eventGroup = eventGroup(eventCategory);
if (! eventGroup) {
return;
}
}
return pruneUnsetValues({
op: 'amplitudeEvent',
event_type: `${eventGroup} - ${eventType}`,
time: event.time,
user_id: data.uid,
device_id: data.deviceId,
session_id: data.flowBeginTime,
app_version: APP_VERSION,
language: data.lang,
country: data.country,
region: data.region,
os_name: data.os,
os_version: data.osVersion,
device_model: data.formFactor,
event_properties: mapEventProperties(eventType, eventGroup, eventCategory, eventTarget, data),
user_properties: mapUserProperties(eventGroup, eventCategory, data)
});
}
}
function mapEventProperties (eventType, eventGroup, eventCategory, eventTarget, data) {
const { serviceName, clientId } = getServiceNameAndClientId(data);
return Object.assign(pruneUnsetValues({
service: serviceName,
oauth_client_id: clientId
}), EVENT_PROPERTIES[eventGroup](eventType, eventCategory, eventTarget, data))
}
function getServiceNameAndClientId (data) {
let serviceName, clientId;
const { service } = data;
if (service && service !== 'content-server') {
if (service === 'sync') {
serviceName = service
} else {
serviceName = services[service] || 'undefined_oauth'
clientId = service
}
}
return { serviceName, clientId }
}
function mapUserProperties (eventGroup, eventCategory, data) {
return Object.assign(
pruneUnsetValues({
entrypoint: data.entrypoint,
flow_id: data.flowId,
ua_browser: data.browser,
ua_version: data.browserVersion,
utm_campaign: data.utm_campaign,
utm_content: data.utm_content,
utm_medium: data.utm_medium,
utm_source: data.utm_source,
utm_term: data.utm_term
}),
mapAppendProperties(data),
mapSyncDevices(data),
mapNewsletterState(eventCategory, data)
);
}
function mapAppendProperties (data) {
const servicesUsed = mapServicesUsed(data);
const experiments = mapExperiments(data);
if (servicesUsed || experiments) {
return {
'$append': Object.assign({}, servicesUsed, experiments)
};
}
}
function mapServicesUsed (data) {
const { serviceName } = getServiceNameAndClientId(data);
if (serviceName) {
return {
fxa_services_used: serviceName
};
}
}
}
};
function pruneUnsetValues (data) {
const result = {};
Object.keys(data).forEach(key => {
const value = data[key];
if (value || value === false) {
result[key] = value;
}
})
return result;
}
function mapExperiments (data) {
const { experiments } = data;
if (Array.isArray(experiments) && experiments.length > 0) {
return {
experiments: experiments.map(e => `${toSnakeCase(e.choice)}_${toSnakeCase(e.group)}`)
};
}
}
function toSnakeCase (string) {
return string.replace(/([a-z])([A-Z])/g, (s, c1, c2) => `${c1}_${c2.toLowerCase()}`)
.replace(/([A-Z])/g, c => c.toLowerCase())
.replace(/\./g, '_')
.replace(/-/g, '_');
}
function mapSyncDevices (data) {
const { devices } = data;
if (Array.isArray(devices)) {
return {
sync_device_count: devices.length,
sync_active_devices_day: countDevices(devices, DAY),
sync_active_devices_week: countDevices(devices, WEEK),
sync_active_devices_month: countDevices(devices, FOUR_WEEKS)
};
}
}
function countDevices (devices, period) {
return devices.filter(device => device.lastAccessTime >= Date.now() - period).length
}
function mapNewsletterState (eventCategory, data) {
let newsletter_state = NEWSLETTER_STATES[eventCategory];
if (! newsletter_state) {
const { marketingOptIn } = data;
if (marketingOptIn === true || marketingOptIn === false) {
newsletter_state = marketingOptIn ? 'subscribed' : 'unsubscribed';
}
}
if (newsletter_state) {
return { newsletter_state };
}
}

Просмотреть файл

@ -14,6 +14,8 @@ describe('index:', () => {
});
it('exports the correct interface', () => {
assert.isArray(index.email.popularDomains);
assert.isObject(index.metrics.amplitude);
assert.isArray(index.l10n.supportedLanguages);
assert.isFunction(index.l10n.localizeTimestamp);
})

341
test/metrics/amplitude.js Normal file
Просмотреть файл

@ -0,0 +1,341 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict';
const { assert } = require('chai');
const DAY = 1000 * 60 * 60 * 24;
const WEEK = DAY * 7;
const FOUR_WEEKS = WEEK * 4;
describe('metrics/amplitude:', () => {
let amplitude;
before(() => {
amplitude = require('../../metrics/amplitude');
});
it('exports the event groups', () => {
assert.isObject(amplitude.GROUPS);
assert.isString(amplitude.GROUPS.activity);
assert.isString(amplitude.GROUPS.connectDevice);
assert.isString(amplitude.GROUPS.email);
assert.isString(amplitude.GROUPS.emailFirst);
assert.isString(amplitude.GROUPS.login);
assert.isString(amplitude.GROUPS.registration);
assert.isString(amplitude.GROUPS.settings);
assert.isString(amplitude.GROUPS.sms);
});
it('exports the email types', () => {
assert.isObject(amplitude.EMAIL_TYPES);
assert.isString(amplitude.EMAIL_TYPES['complete-reset-password']);
assert.isString(amplitude.EMAIL_TYPES['complete-signin']);
assert.isString(amplitude.EMAIL_TYPES['verify-email']);
assert.isString(amplitude.EMAIL_TYPES.newDeviceLoginEmail);
assert.isString(amplitude.EMAIL_TYPES.passwordChangedEmail);
assert.isString(amplitude.EMAIL_TYPES.passwordResetEmail);
assert.isString(amplitude.EMAIL_TYPES.passwordResetRequiredEmail);
assert.isString(amplitude.EMAIL_TYPES.postChangePrimaryEmail);
assert.isString(amplitude.EMAIL_TYPES.postRemoveSecondaryEmail);
assert.isString(amplitude.EMAIL_TYPES.postVerifyEmail);
assert.isString(amplitude.EMAIL_TYPES.postVerifySecondaryEmail);
assert.isString(amplitude.EMAIL_TYPES.postConsumeRecoveryCodeEmail);
assert.isString(amplitude.EMAIL_TYPES.postNewRecoveryCodesEmail);
assert.isString(amplitude.EMAIL_TYPES.recoveryEmail);
assert.isString(amplitude.EMAIL_TYPES.unblockCode);
assert.isString(amplitude.EMAIL_TYPES.verifyEmail);
assert.isString(amplitude.EMAIL_TYPES.verifyLoginEmail);
assert.isString(amplitude.EMAIL_TYPES.verifyLoginCodeEmail);
assert.isString(amplitude.EMAIL_TYPES.verifyPrimaryEmail);
assert.isString(amplitude.EMAIL_TYPES.verifySyncEmail);
assert.isString(amplitude.EMAIL_TYPES.verifySecondaryEmail);
});
it('exports an initialize method', () => {
assert.isFunction(amplitude.initialize);
assert.lengthOf(amplitude.initialize, 3);
});
describe('initialize:', () => {
let transform;
before(() => {
transform = amplitude.initialize({
foo: 'bar',
baz: 'qux'
}, {
sourceEvent1: {
group: amplitude.GROUPS.activity,
event: 'wibble'
},
sourceEvent2: {
group: amplitude.GROUPS.sms,
event: 'blee'
}
}, new Map([
[ /3/, {
group: amplitude.GROUPS.login,
event: 'targetEvent3'
} ],
[ /^(wibble)\.(blee)/, {
group: eventCategory => eventCategory === 'wibble' ? amplitude.GROUPS.registration : null,
event: 'targetEvent4'
} ],
[ /(sms)\.(\w+)/, {
group: amplitude.GROUPS.connectDevice,
event: 'cadEvent'
} ],
[ /(verifySecondaryEmail)\.(\w+)/, {
group: amplitude.GROUPS.email,
event: 'emailEvent'
} ],
[ /disconnect\.(\w+)\.(\w+)/, {
group: amplitude.GROUPS.settings,
event: 'disconnect_device'
} ],
[ /newsletter\.(\w+)\.(\w+)/, {
group: amplitude.GROUPS.settings,
event: 'newsletterEvent'
} ]
]));
});
describe('transform a simple event:', () => {
let now, result;
before(() => {
now = Date.now();
result = transform({
type: 'sourceEvent2',
time: 42
}, {
browser: 'a',
browserVersion: 'b',
country: 'c',
deviceId: 'd',
devices: [
{ lastAccessTime: now - DAY + 1000 },
{ lastAccessTime: now - DAY - 1 },
{ lastAccessTime: now - WEEK + 1000 },
{ lastAccessTime: now - WEEK - 1 },
{ lastAccessTime: now - FOUR_WEEKS + 1000 },
{ lastAccessTime: now - FOUR_WEEKS - 1 }
],
emailDomain: 'e',
entrypoint: 'f',
experiments: [
{ choice: 'g', group: 'h' },
{ choice: 'iI', group: 'jJ-J' }
],
flowBeginTime: 'k',
flowId: 'l',
formFactor: 'm',
lang: 'n',
marketingOptIn: 'o',
os: 'p',
osVersion: 'q',
region: 'r',
service: 'baz',
templateVersion: 's',
uid: 't',
utm_campaign: 'u',
utm_content: 'v',
utm_medium: 'w',
utm_source: 'x',
utm_term: 'y'
});
})
it('returned the correct result', () => {
assert.deepEqual(result, {
country: 'c',
device_id: 'd',
device_model: 'm',
event_properties: {
oauth_client_id: 'baz',
service: 'qux'
},
event_type: 'fxa_sms - blee',
language: 'n',
op: 'amplitudeEvent',
os_name: 'p',
os_version: 'q',
region: 'r',
session_id: 'k',
time: 42,
user_id: 't',
user_properties: {
'$append': {
experiments: [ 'g_h', 'i_i_j_j_j' ],
fxa_services_used: 'qux'
},
entrypoint: 'f',
flow_id: 'l',
sync_active_devices_day: 1,
sync_active_devices_month: 5,
sync_active_devices_week: 3,
sync_device_count: 6,
ua_browser: 'a',
ua_version: 'b',
utm_campaign: 'u',
utm_content: 'v',
utm_medium: 'w',
utm_source: 'x',
utm_term: 'y'
}
});
});
});
describe('transform a fuzzy event:', () => {
let result;
before(() => {
result = transform({
type: 'sourceEvent3',
time: 1
}, {
deviceId: 'a',
flowBeginTime: 'b',
flowId: 'c',
uid: 'd'
});
})
it('returned the correct result', () => {
assert.deepEqual(result, {
device_id: 'a',
event_properties: {},
event_type: 'fxa_login - targetEvent3',
op: 'amplitudeEvent',
session_id: 'b',
time: 1,
user_id: 'd',
user_properties: {
flow_id: 'c'
}
});
});
});
describe('transform a fuzzy event with a group function:', () => {
let result;
before(() => {
result = transform({ type: 'wibble.blee' }, {});
})
it('returned the correct event type', () => {
assert.equal(result.event_type, 'fxa_reg - targetEvent4');
});
});
describe('transform an event with connect-another-device properties:', () => {
let result;
before(() => {
result = transform({ type: 'sms.ios' }, {});
})
it('returned the correct event data', () => {
assert.equal(result.event_type, 'fxa_connect_device - cadEvent');
assert.deepEqual(result.event_properties, {
connect_device_flow: 'sms',
connect_device_os: 'ios'
});
});
});
describe('transform an event with email properties:', () => {
let result;
before(() => {
result = transform({ type: 'verifySecondaryEmail.wibble' }, {
emailDomain: 'foo',
templateVersion: 'bar'
});
})
it('returned the correct event data', () => {
assert.equal(result.event_type, 'fxa_email - emailEvent');
assert.deepEqual(result.event_properties, {
email_provider: 'foo',
email_template: 'verifySecondaryEmail',
email_type: 'secondary_email',
email_version: 'bar'
});
});
});
describe('transform an event with disconnect properties:', () => {
let result;
before(() => {
result = transform({ type: 'disconnect.wibble.blee' }, {});
})
it('returned the correct event data', () => {
assert.equal(result.event_type, 'fxa_pref - disconnect_device');
assert.deepEqual(result.event_properties, { reason: 'wibble' });
});
});
describe('transform an event with newsletter properties:', () => {
let result;
before(() => {
result = transform({ type: 'newsletter.optIn.wibble' }, {});
})
it('returned the correct event data', () => {
assert.equal(result.event_type, 'fxa_pref - newsletterEvent');
assert.deepEqual(result.user_properties, { newsletter_state: 'subscribed' });
});
});
describe('transform an event with undefined service:', () => {
let result;
before(() => {
result = transform({ type: 'wibble.blee' }, { service: 'gribble' });
})
it('returned the correct event data', () => {
assert.deepEqual(result.event_properties, {
oauth_client_id: 'gribble',
service: 'undefined_oauth'
});
assert.deepEqual(result.user_properties, { '$append': { fxa_services_used: 'undefined_oauth' } });
});
});
describe('transform an event with service=sync:', () => {
let result;
before(() => {
result = transform({ type: 'wibble.blee' }, { service: 'sync' });
})
it('returned the correct event data', () => {
assert.deepEqual(result.event_properties, { service: 'sync' });
assert.deepEqual(result.user_properties, { '$append': { fxa_services_used: 'sync' } });
});
});
describe('transform an event with service=content-server:', () => {
let result;
before(() => {
result = transform({ type: 'wibble.blee' }, { service: 'content-server' });
})
it('returned the correct event data', () => {
assert.deepEqual(result.event_properties, {});
assert.deepEqual(result.user_properties, {});
});
});
});
});