packages/fxa-shared/metrics/amplitude.ts (559 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/. */ import Ajv from 'ajv'; import pick from 'lodash.pick'; import { ParsedUserAgentProperties, ParsedUa, ParsedOs, } from '../lib/user-agent'; import { Location } from '../connected-services/models/Location'; import { ILogger } from '../log'; import { StatsD } from 'hot-shots'; type AmplitudeEventGroup = typeof GROUPS; type AmplitudeEventGroupKey = keyof AmplitudeEventGroup; type AmplitudeEventFuzzyEventGroupMapFn = (category: string) => string; type AmplitudeEventFuzzyEventNameMapFn = ( category: string, target: string ) => string; type EventData = { [key: string]: any }; const ajv = new Ajv({ allErrors: true }); const amplitudeSchema = require('./amplitude-event.1.schema.json'); const validateAmplitudeEvent = ajv.compile(amplitudeSchema); const DAY = 1000 * 60 * 60 * 24; const WEEK = DAY * 7; const FOUR_WEEKS = WEEK * 4; const GROUPS = { activity: 'fxa_activity', branding: 'fxa_branding', button: 'fxa_rp_button', connectDevice: 'fxa_connect_device', email: 'fxa_email', emailFirst: 'fxa_email_first', login: 'fxa_login', newsletters: 'fxa_newsletter', notify: 'fxa_notify', registration: 'fxa_reg', rp: 'fxa_rp', settings: 'fxa_pref', sub: 'fxa_subscribe', subCancel: 'fxa_subscribe_cancel', subManage: 'fxa_subscribe_manage', subPayManage: 'fxa_pay_manage', subPaySetup: 'fxa_pay_setup', subPayAccountSetup: 'fxa_pay_account_setup', subPaySubChange: 'fxa_pay_subscription_change', subSupport: 'fxa_subscribe_support', subCoupon: 'fxa_subscribe_coupon', thirdPartyAuth: 'fxa_third_party_auth', qrConnectDevice: 'fxa_qr_connect_device', }; const CONNECT_DEVICE_FLOWS = { 'app-store': 'store_buttons', install_from: 'store_buttons', pair: 'pairing', signin_from: 'signin', }; const EVENT_PROPERTIES = { [GROUPS.activity]: NOP, [GROUPS.button]: NOP, [GROUPS.connectDevice]: mapConnectDeviceFlow, [GROUPS.email]: mapEmailType, [GROUPS.emailFirst]: NOP, [GROUPS.login]: NOP, [GROUPS.newsletters]: NOP, [GROUPS.notify]: NOP, [GROUPS.registration]: mapDomainValidationResult, [GROUPS.rp]: NOP, [GROUPS.settings]: mapSettingsEventProperties, [GROUPS.sub]: mapSubscriptionEventProperties, [GROUPS.subCancel]: NOP, [GROUPS.subManage]: NOP, [GROUPS.subPayManage]: NOP, [GROUPS.subPaySetup]: mapSubscriptionPaymentEventProperties, [GROUPS.subPayAccountSetup]: mapSubscriptionPaymentEventProperties, [GROUPS.subPaySubChange]: mapSubscriptionChangeEventProperties, [GROUPS.subSupport]: NOP, [GROUPS.subCoupon]: NOP, [GROUPS.qrConnectDevice]: NOP, [GROUPS.thirdPartyAuth]: NOP, [GROUPS.branding]: NOP, }; function NOP() {} function mapConnectDeviceFlow( eventType: string, eventCategory: string, eventTarget: string ) { // @ts-ignore const connect_device_flow = CONNECT_DEVICE_FLOWS[eventCategory]; if (connect_device_flow) { const result: { connect_device_flow: string; connect_device_os?: string } = { connect_device_flow }; if (eventTarget) { result.connect_device_os = eventTarget; } return result; } return; } function mapEmailType( eventType: string, eventCategory: string, eventTarget: string, data: EventData ) { const email_type = data.emailTypes[eventCategory]; if (email_type) { const result: { [key: string]: string } = { email_type, email_provider: data.emailDomain, }; const { templateVersion } = data; if (templateVersion) { result.email_template = eventCategory; result.email_version = templateVersion; } return result; } return; } function mapSettingsEventProperties(...args: [string, string]) { return { ...mapDisconnectReason(...args), }; } function mapDisconnectReason(eventType: string, eventCategory: string) { if (eventType === 'disconnect_device' && eventCategory) { return { reason: eventCategory }; } return; } function mapDomainValidationResult( eventType: string, eventCategory: string, eventTarget: string, data: EventData ) { // This function is called for all fxa_reg event types, only add the event // properties for the results pertaining to domain_validation_result. if (eventType === 'domain_validation_result' && eventCategory) { return { validation_result: eventCategory }; } return; } function mapSubscriptionEventProperties( eventType: string, eventCategory: string, eventTarget: string, data: EventData ) { if (data) { const keys = [ 'country_code_source', 'payment_provider', 'plan_id', 'product_id', 'provider_event_id', 'subscription_id', 'voluntary_cancellation', ]; return pick(data, keys); } return; } function mapSubscriptionChangeEventProperties( eventType: string, eventCategory: string, eventTarget: string, data: EventData ) { if (data) { const properties: { [key: string]: string } = {}; if (data.previousPlanId) { properties['previous_plan_id'] = data.previousPlanId; } if (data.previousProductId) { properties['previous_product_id'] = data.previousProductId; } if (data.subscriptionId) { properties['subscription_id'] = data.subscriptionId; } if (data.subscribed_plan_ids) { properties['subscribed_plan_ids'] = data.subscribed_plan_ids; } if (data.country_code_source) { properties['country_code_source'] = data.country_code_source; } if (data.error_id) { properties['error_id'] = data.error_id; } return properties; } return; } function mapSubscriptionPaymentEventProperties( eventType: string, eventCategory: string, eventTarget: string, data: EventData ) { if (data) { const properties: { [key: string]: string } = {}; if (data.country_code_source) { properties['country_code_source'] = data.country_code_source; } if (data.checkoutType) { properties['checkout_type'] = data.checkoutType; } if (data.subscribed_plan_ids) { properties['subscribed_plan_ids'] = data.subscribed_plan_ids; } if (data.error_id) { properties['error_id'] = data.error_id; } if (data.other) { properties['other'] = data.other; } return properties; } return undefined; } function validate(event: { [key: string]: any }) { if (!validateAmplitudeEvent(event)) { throw new Error( `Invalid data: ${ajv.errorsText(validateAmplitudeEvent.errors, { dataVar: 'event', })}` ); } return true; } export const amplitude = { EVENT_PROPERTIES, GROUPS, mapBrowser, mapFormFactor, mapLocation, mapOs, mapUserAgentProperties, toSnakeCase, validate, /** * 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 definition value is itself an object with `group` * and `event` string properties, with an optional `minimal` * property that can be set to `true` to only report * uid, service, and version. * * @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. * @param {StatsD} statsd An optional statsd client. * @param {} * * @returns {Function} The mapper function. */ initialize( services: { [key: string]: string }, events: { [key: string]: { group: AmplitudeEventGroupKey | Function; event: string | AmplitudeEventFuzzyEventNameMapFn; minimal?: boolean; }; }, fuzzyEvents: Map< RegExp, { group: AmplitudeEventGroupKey | AmplitudeEventFuzzyEventGroupMapFn; event: string | AmplitudeEventFuzzyEventNameMapFn; } >, log?: ILogger, statsd?: StatsD ) { /** * 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: { [key: string]: any }, data: EventData) => { try { 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; if (typeof eventType === 'function') { eventType = eventType(eventCategory, eventTarget); if (!eventType) { return; } } let eventGroup = mapping.group; if (typeof eventGroup === 'function') { eventGroup = eventGroup(eventCategory); if (!eventGroup) { return; } } let version; try { // @ts-ignore version = /([0-9]+)\.([0-9]+)$/.exec(data.version)[0]; } catch (err) {} // minimal data should be enabled for routes used by internal // services like profile-server and token-server if (mapping.minimal) { data = { uid: data.uid, service: data.service, version: data.version, }; } 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: version, language: data.lang, country_code: data.countryCode, country: data.country, region: data.region, os_name: data.os, os_version: data.osVersion, device_model: data.formFactor, event_properties: mapEventProperties( eventType, eventGroup as string, eventCategory as string, eventTarget as string, data ), user_properties: mapUserProperties( eventGroup as string, eventCategory as string, data ), }); } } catch (err) { statsd?.increment('fxa.amplitude.transform.error'); log?.error(err); } return; }; function mapEventProperties( eventType: string, eventGroup: string, eventCategory: string, eventTarget: string, data: EventData ) { const { serviceName, clientId } = getServiceNameAndClientId(data); if (typeof EVENT_PROPERTIES[eventGroup] !== 'function') { throw new Error(`Unknown event group: ${eventGroup}`); } return Object.assign( pruneUnsetValues({ service: serviceName, oauth_client_id: clientId, country_code_source: data.country_code_source, // TODO: Delete data.plan_id and data.product_id after the camel-cased // equivalents have been in place for at least one train. plan_id: data.planId || data.plan_id, product_id: data.productId || data.product_id, payment_provider: data.paymentProvider || data.payment_provider, promotion_code: data.promotionCode, provider_event_id: data.provider_event_id, subscription_id: data.subscription_id, voluntary_cancellation: data.voluntary_cancellation, }), EVENT_PROPERTIES[eventGroup]( eventType, eventCategory, eventTarget, data ) ); } function getServiceNameAndClientId(data: EventData) { 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: string, eventCategory: string, data: EventData ) { return Object.assign( pruneUnsetValues({ entrypoint: data.entrypoint, entrypoint_experiment: data.entrypoint_experiment, entrypoint_variation: data.entrypoint_variation, 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_referrer: data.utm_referrer, utm_source: data.utm_source, utm_term: data.utm_term, }), mapAppendProperties(data), mapSyncDevices(data), mapSyncEngines(data), mapNewsletters(data) ); } function mapAppendProperties(data: EventData) { const servicesUsed = mapServicesUsed(data); const experiments = mapExperiments(data); const userPreferences = mapUserPreferences(data); if (servicesUsed || experiments || userPreferences) { return { $append: Object.assign( {}, servicesUsed, experiments, userPreferences ), }; } return; } function mapServicesUsed(data: EventData) { const { serviceName } = getServiceNameAndClientId(data); if (serviceName) { return { fxa_services_used: serviceName, }; } return; } }, }; function pruneUnsetValues(data: EventData) { const result: Partial<EventData> = {}; Object.keys(data).forEach((key) => { const value = data[key]; if (value || value === false) { result[key] = value; } }); return result; } function mapExperiments(data: EventData) { const { experiments } = data; if (Array.isArray(experiments) && experiments.length > 0) { return { experiments: experiments.map( (e) => `${toSnakeCase(e.choice)}_${toSnakeCase(e.group)}` ), }; } return; } function mapUserPreferences(data: EventData) { const { userPreferences } = data; // Don't send user preferences metric if there are none! if (!userPreferences || Object.keys(userPreferences).length === 0) { return; } const formattedUserPreferences: { [key: string]: any } = {}; for (const pref in userPreferences) { formattedUserPreferences[toSnakeCase(pref)] = userPreferences[pref]; } return formattedUserPreferences; } function toSnakeCase(string: 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: EventData) { const { devices } = data; if (Array.isArray(devices)) { return { sync_device_count: devices.length, sync_active_devices_day: countDevices( devices as [{ [key: string]: any }], DAY ), sync_active_devices_week: countDevices( devices as [{ [key: string]: any }], WEEK ), sync_active_devices_month: countDevices( devices as [{ [key: string]: any }], FOUR_WEEKS ), }; } return; } function countDevices(devices: [{ [key: string]: any }], period: number) { return devices.filter( (device) => device.lastAccessTime >= Date.now() - period ).length; } function mapSyncEngines(data: EventData) { const { syncEngines: sync_engines } = data; if (Array.isArray(sync_engines) && sync_engines.length > 0) { return { sync_engines }; } return; } function mapNewsletters(data: EventData) { let { newsletters } = data; if (newsletters) { newsletters = newsletters.map((newsletter: string) => { return toSnakeCase(newsletter); }); return { newsletters, newsletter_state: 'subscribed' }; } return; } function mapBrowser(userAgent: ParsedUserAgentProperties) { return mapUserAgentProperties(userAgent, 'ua', 'browser', 'browserVersion'); } function mapOs(userAgent: ParsedUserAgentProperties) { return mapUserAgentProperties(userAgent, 'os', 'os', 'osVersion'); } function mapUserAgentProperties( userAgent: Omit<ParsedUserAgentProperties, 'userAgent'>, key: keyof Omit<ParsedUserAgentProperties, 'userAgent'>, familyProperty: string, versionProperty: string ) { const group = userAgent[key]; const { family } = group; if (family && family !== 'Other') { return { [familyProperty]: family, [versionProperty]: (group as ParsedUa | ParsedOs).toVersionString(), }; } return; } function mapFormFactor(userAgent: ParsedUserAgentProperties) { const { brand, family: formFactor } = userAgent.device; if (brand && formFactor && brand !== 'Generic') { return { formFactor }; } return; } function mapLocation(location: Location) { if (location && (location.country || location.state)) { return { country: location.country, region: location.state, }; } return; } export default amplitude;