packages/fxa-auth-server/lib/metrics/amplitude.js (372 lines of code) (raw):
/* 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/. */
// This module contains mappings from activity/flow event names to
// amplitude event definitions. A module in fxa-shared is responsible
// for performing the actual transformations.
//
// You can see the event taxonomy here:
//
// https://docs.google.com/spreadsheets/d/1G_8OJGOxeWXdGJ1Ugmykk33Zsl-qAQL05CONSeD4Uz4
'use strict';
const { Container } = require('typedi');
const { StatsD } = require('hot-shots');
const config = require('../../config').default.getProperties();
const logger = require('../log')(config.log.level, 'amplitude');
const { GROUPS, initialize } =
require('fxa-shared/metrics/amplitude').amplitude;
const { version: VERSION } = require('../../package.json');
// Maps template name to email type
const EMAIL_TYPES = {
subscriptionAccountFinishSetup: 'subscription_account_finish_setup',
subscriptionAccountReminderFirst: 'subscription_account_finish_setup',
subscriptionAccountReminderSecond: 'subscription_account_finish_setup',
subscriptionReactivation: 'subscription_reactivation',
subscriptionRenewalReminder: 'subscription_renewal_reminder',
subscriptionReplaced: 'subscription_replaced',
subscriptionUpgrade: 'subscription_upgrade',
subscriptionDowngrade: 'subscription_downgrade',
subscriptionPaymentExpired: 'subscription_payment_expired',
subscriptionsPaymentExpired: 'subscriptions_payment_expired',
subscriptionPaymentProviderCancelled:
'subscription_payment_provider_cancelled',
subscriptionsPaymentProviderCancelled:
'subscriptions_payment_provider_cancelled',
subscriptionPaymentFailed: 'subscription_payment_failed',
subscriptionAccountDeletion: 'subscription_account_deletion',
subscriptionCancellation: 'subscription_cancellation',
subscriptionFailedPaymentsCancellation:
'subscription_failed_payments_cancellation',
subscriptionSubsequentInvoice: 'subscription_subsequent_invoice',
subscriptionFirstInvoice: 'subscription_first_invoice',
downloadSubscription: 'subscription_download',
fraudulentAccountDeletion: 'account_deletion',
lowRecoveryCodes: '2fa',
newDeviceLogin: 'login',
passwordChanged: 'change_password',
passwordChangeRequired: 'change_password',
passwordForgotOtp: 'reset_password',
passwordReset: 'reset_password',
passwordResetAccountRecovery: 'account_recovery',
passwordResetWithRecoveryKeyPrompt: 'reset_password',
postAddLinkedAccount: 'login',
postChangePrimary: 'change_email',
postRemoveSecondary: 'secondary_email',
postVerify: 'registration',
postVerifySecondary: 'secondary_email',
postAddTwoStepAuthentication: '2fa',
postRemoveTwoStepAuthentication: '2fa',
postAddAccountRecovery: 'account_recovery',
postChangeAccountRecovery: 'account_recovery',
postRemoveAccountRecovery: 'account_recovery',
postConsumeRecoveryCode: '2fa',
postNewRecoveryCodes: '2fa',
postAddRecoveryPhone: '2fa',
postRemoveRecoveryPhone: '2fa',
postChangeRecoveryPhone: '2fa',
postSigninRecoveryPhone: 'login',
postSigninRecoveryCode: 'login',
recovery: 'reset_password',
unblockCode: 'unblock',
verify: 'registration',
verifySecondaryCode: 'secondary_email',
verifyShortCode: 'registration',
verifyLogin: 'login',
verifyLoginCode: 'login',
verifyPrimary: 'verify',
verificationReminderFirst: 'registration',
verificationReminderSecond: 'registration',
verificationReminderFinal: 'registration',
cadReminderFirst: 'connect_another_device',
cadReminderSecond: 'connect_another_device',
};
const EVENTS = {
'account.confirmed': {
group: GROUPS.login,
event: 'email_confirmed',
},
'account.created': {
group: GROUPS.registration,
event: 'created',
},
'account.login': {
group: GROUPS.login,
event: 'success',
},
'account.login.blocked': {
group: GROUPS.login,
event: 'blocked',
},
'account.login.confirmedUnblockCode': {
group: GROUPS.login,
event: 'unblock_success',
},
'account.reset': {
group: GROUPS.login,
event: 'forgot_complete',
},
'account.signed': {
group: GROUPS.activity,
event: 'cert_signed',
},
'account.verified': {
group: GROUPS.registration,
event: 'email_confirmed',
},
'oauth.token.created': {
group: GROUPS.activity,
event: 'oauth_access_token_created',
},
'subscription.ended': {
group: GROUPS.sub,
event: 'subscription_ended',
},
'token.created': {
group: GROUPS.activity,
event: 'access_token_created',
minimal: true,
},
'verify.success': {
group: GROUPS.activity,
event: 'access_token_checked',
minimal: true,
},
};
const FUZZY_EVENTS = new Map([
[
/^email\.(\w+)\.bounced$/,
{
group: (eventCategory) =>
EMAIL_TYPES[eventCategory] ? GROUPS.email : null,
event: 'bounced',
},
],
[
/^email\.(\w+)\.sent$/,
{
group: (eventCategory) =>
EMAIL_TYPES[eventCategory] ? GROUPS.email : null,
event: 'sent',
},
],
[
/^flow\.complete\.(\w+)$/,
{
group: (eventCategory) => GROUPS[eventCategory],
event: 'complete',
},
],
]);
const ACCOUNT_RESET_COMPLETE = `${GROUPS.login} - forgot_complete`;
const LOGIN_COMPLETE = `${GROUPS.login} - complete`;
module.exports = (log, config) => {
if (!log || !config.oauth.clientIds) {
throw new TypeError('Missing argument');
}
const verificationReminders = require('../verification-reminders')(
log,
config
);
verificationReminders.keys.forEach((key) => {
EMAIL_TYPES[
`verificationReminder${key[0].toUpperCase()}${key.substr(1)}Email`
] = 'registration';
});
const subscriptionAccountReminders =
require('../subscription-account-reminders')(log, config);
subscriptionAccountReminders.keys.forEach((key) => {
EMAIL_TYPES[
`subscriptionAccountReminder${key[0].toUpperCase()}${key.substr(1)}Email`
] = 'subscription_account_finish_setup';
});
const transformEvent = initialize(
config.oauth.clientIds,
EVENTS,
FUZZY_EVENTS,
logger,
Container.has(StatsD) ? Container.get(StatsD) : undefined
);
return receiveEvent;
async function receiveEvent(
eventType,
request,
data = {},
metricsContext = {}
) {
const statsd = Container.get(StatsD);
if (!eventType || !request) {
log.error('amplitude.badArgument', {
err: 'Bad argument',
event: eventType,
hasRequest: !!request,
});
return;
}
const uid = data.uid || getFromToken(request, 'uid');
if (uid) {
request.app.metricsEventUid = uid;
}
let devices;
try {
// yes, this syntax is correct. request.app.devices is a promise.
devices = await request.app.devices;
} catch (e) {
// ignore the error
devices = {};
}
const { formFactor } = request.app.ua;
const service = getService(request, data, metricsContext);
const deviceId = getFromMetricsContext(
metricsContext,
'device_id',
request,
'deviceId'
);
const flowId = getFromMetricsContext(
metricsContext,
'flow_id',
request,
'flowId'
);
const flowBeginTime = getFromMetricsContext(
metricsContext,
'flowBeginTime',
request,
'flowBeginTime'
);
const productId = getFromMetricsContext(
metricsContext,
'product_id',
request,
'productId'
);
const planId = getFromMetricsContext(
metricsContext,
'plan_id',
request,
'planId'
);
if (eventType === 'flow.complete') {
// HACK: Push flowType into the event so it can be parsed as eventCategory
eventType += `.${metricsContext.flowType}`;
}
const event = {
type: eventType,
time: metricsContext.time || Date.now(),
};
if (config.amplitude.rawEvents) {
const wanted = [
'entrypoint_experiment',
'entrypoint_variation',
'entrypoint',
'experiments',
'location',
'newsletters',
'syncEngines',
'templateVersion',
'userPreferences',
'utm_campaign',
'utm_content',
'utm_medium',
'utm_source',
'utm_term',
];
const picked = wanted.reduce((acc, v) => {
if (data[v] !== undefined) {
acc[v] = data[v];
}
return acc;
}, {});
const { location } = request.app.geo;
const rawEvent = {
event,
context: {
...picked,
eventSource: 'auth',
version: VERSION,
deviceId,
devices,
emailDomain: data.email_domain,
emailTypes: EMAIL_TYPES,
flowBeginTime,
flowId,
formFactor,
lang: request.app.locale,
location,
planId,
productId,
service,
uid,
userAgent: request.headers?.['user-agent'],
},
};
log.info('rawAmplitudeData', rawEvent);
statsd.increment('amplitude.event.raw');
}
statsd.increment('amplitude.event');
const amplitudeEvent = transformEvent(event, {
...data,
devices,
formFactor,
uid,
deviceId,
flowId,
flowBeginTime,
productId,
planId,
lang: request.app.locale,
emailDomain: data.email_domain,
emailTypes: EMAIL_TYPES,
service,
version: VERSION,
...getOs(request),
...getBrowser(request),
...getLocation(request),
});
if (amplitudeEvent) {
log.amplitudeEvent(amplitudeEvent);
// HACK: Account reset returns a session token so emit login complete too
if (amplitudeEvent.event_type === ACCOUNT_RESET_COMPLETE) {
log.amplitudeEvent({
...amplitudeEvent,
event_type: LOGIN_COMPLETE,
time: amplitudeEvent.time + 1,
});
}
} else {
statsd.increment('amplitude.event.dropped');
}
}
};
module.exports.EMAIL_TYPES = EMAIL_TYPES;
function getFromToken(request, key) {
if (request.auth && request.auth.credentials) {
return request.auth.credentials[key];
}
}
function getFromMetricsContext(metricsContext, key, request, payloadKey) {
return (
metricsContext[key] ||
(request.payload &&
request.payload.metricsContext &&
request.payload.metricsContext[payloadKey])
);
}
function getOs(request) {
const { os, osVersion } = request.app.ua;
if (os) {
return { os, osVersion };
}
}
function getBrowser(request) {
const { browser, browserVersion } = request.app.ua;
if (browser) {
return { browser, browserVersion };
}
}
function getLocation(request) {
const { location } = request.app.geo;
if (location && (location.country || location.state)) {
return {
country: location.country,
region: location.state,
};
}
}
function getService(request, data, metricsContext) {
if (data.service) {
return data.service;
}
if (request.payload && request.payload.service) {
return request.payload.service;
}
if (request.query && request.query.service) {
return request.query.service;
}
return metricsContext.service;
}