packages/fxa-content-server/app/scripts/lib/metrics.js (535 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/. */
/*
* A metrics module!
*
* An instantiated metrics object has two primary APIs:
*
* metrics.logEvent(<event_name>);
* metrics.startTimer(<timer_name>)/metrics.stopTimer(<timer_name);
*
* Metrics are automatically sent to the server on window.unload
* but can also be sent by calling metrics.flush();
*/
import $ from 'jquery';
import _ from 'underscore';
import Cocktail from 'cocktail';
import Constants from './constants';
import Backbone from 'backbone';
import Duration from 'duration';
import Environment from './environment';
import FlowModel from '../models/flow';
import NotifierMixin from './channels/notifier-mixin';
import speedTrap from 'fxa-shared/speed-trap';
import Strings from './strings';
import SubscriptionModel from 'models/subscription';
import xhr from './xhr';
import {
MetricValidator,
MetricErrorReporter,
} from 'fxa-shared/metrics/validate';
import Validate from '../lib/validate';
// Speed trap is a singleton, convert it
// to an instantiable function.
const SpeedTrap = function () {};
SpeedTrap.prototype = speedTrap;
// Some integrations, such as Fx for iOS, close the WebView
// as soon as a login notification is sent to the browser, preventing
// events from being properly flushed. Instead of waiting for an
// unload or for an activity timeout, flush every time one of these
// events comes in.
const IMMEDIATE_FLUSH_EVENTS = /^(?:screen\..*|.*\.(?:complete|success))$/;
const ALLOWED_FIELDS = [
'broker',
'context',
'deviceId',
'duration',
'emailDomain',
'entrypoint',
'entrypoint_experiment',
'entrypoint_variation',
'events',
'experiments',
'flowBeginTime',
'flowId',
'flushTime',
'initialView',
'isSampledUser',
'lang',
'marketing',
'newsletters',
'numStoredAccounts',
'planId',
'productId',
'reason',
'referrer',
'screen',
'service',
'settingsVersion',
'syncEngines',
'startTime',
'timers',
'uid',
'uniqueUserId',
'userPreferences',
'utm_campaign',
'utm_content',
'utm_medium',
'utm_source',
'utm_term',
];
var DEFAULT_INACTIVITY_TIMEOUT_MS = new Duration('10s').milliseconds();
var NOT_REPORTED_VALUE = 'none';
var UNKNOWN_CAMPAIGN_ID = 'unknown';
const INVALID_UTM = 'invalid';
// convert a hash of metrics impressions into an array of objects.
function flattenHashIntoArrayOfObjects(hashTable) {
return _.reduce(
hashTable,
function (memo, key) {
return memo.concat(
_.map(key, function (value) {
return value;
})
);
},
[]
);
}
function marshallFlowEvent(eventName, viewName) {
if (!viewName) {
return `flow.${eventName}`;
}
// Strip out the `oauth.` prefix if present because
// OAuthiness is already encoded in the service property.
return `flow.${viewName.replace(/^oauth\./, '')}.${eventName}`;
}
function marshallProperty(property) {
if (property && property !== NOT_REPORTED_VALUE) {
return property;
}
}
function marshallUtmProperty(property) {
if (property && property !== NOT_REPORTED_VALUE) {
if (Validate.isUtmValid(property)) {
return property;
}
return INVALID_UTM;
}
}
function marshallEmailDomain(email) {
if (!email) {
return;
}
const domain = email.split('@')[1];
if (Constants.POPULAR_EMAIL_DOMAINS[domain]) {
return domain;
}
return Constants.OTHER_EMAIL_DOMAIN;
}
function Metrics(options = {}) {
// Supplying a custom start time is a good way to create invalid metrics. We
// are deprecating this option.
if (options.startTime !== undefined) {
throw new Error('Supplying an external start time is no longer supported!');
}
this._speedTrap = new SpeedTrap(options);
this._speedTrap.init();
// `timers` and `events` are part of the public API
this.timers = this._speedTrap.timers;
this.events = this._speedTrap.events;
this._window = options.window || window;
this._activeExperiments = {};
this._brokerType = options.brokerType || NOT_REPORTED_VALUE;
this._maxEventOffset = options.maxEventOffset;
this._clientHeight = options.clientHeight || NOT_REPORTED_VALUE;
this._clientWidth = options.clientWidth || NOT_REPORTED_VALUE;
// by default, send the metrics to the content server.
this._collector = options.collector || '';
this._context = options.context || Constants.CONTENT_SERVER_CONTEXT;
this._devicePixelRatio = options.devicePixelRatio || NOT_REPORTED_VALUE;
this._emailDomain = NOT_REPORTED_VALUE;
this._entrypoint = options.entrypoint || NOT_REPORTED_VALUE;
this._entrypointExperiment =
options.entrypointExperiment || NOT_REPORTED_VALUE;
this._entrypointVariation = options.entrypointVariation || NOT_REPORTED_VALUE;
this._env = options.environment || new Environment(this._window);
this._eventMemory = {};
this._inactivityFlushMs =
options.inactivityFlushMs || DEFAULT_INACTIVITY_TIMEOUT_MS;
// All user metrics are sent to the backend. Data is only
// reported to metrics if `isSampledUser===true`.
this._isSampledUser = options.isSampledUser || false;
this._lang = options.lang || 'unknown';
this._marketingImpressions = {};
this._numStoredAccounts = options.numStoredAccounts || '';
this._referrer = this._window.document.referrer || NOT_REPORTED_VALUE;
this._newsletters = NOT_REPORTED_VALUE;
this._screenHeight = options.screenHeight || NOT_REPORTED_VALUE;
this._screenWidth = options.screenWidth || NOT_REPORTED_VALUE;
this._sentryMetrics = options.sentryMetrics;
this._service = options.service || NOT_REPORTED_VALUE;
this._startTime = this._speedTrap.baseTime;
this._syncEngines = options.syncEngines || [];
this._uid = options.uid || NOT_REPORTED_VALUE;
this._metricsEnabled = options.metricsEnabled ?? true;
this._uniqueUserId = options.uniqueUserId;
this._userPreferences = {};
this._utmCampaign = options.utmCampaign || NOT_REPORTED_VALUE;
this._utmContent = options.utmContent || NOT_REPORTED_VALUE;
this._utmMedium = options.utmMedium || NOT_REPORTED_VALUE;
this._utmSource = options.utmSource || NOT_REPORTED_VALUE;
this._utmTerm = options.utmTerm || NOT_REPORTED_VALUE;
this._xhr = options.xhr || xhr;
this.initialize(options);
}
_.extend(Metrics.prototype, Backbone.Events, {
ALLOWED_FIELDS: ALLOWED_FIELDS,
initialize() {
this._flush = () => this.flush(true);
$(this._window).on('unload', this._flush);
// The latest Safari/iOS do not always handle `unload` events, and it is now
// recommended to use `visibilitychange` and `pagehide`.
// Ref: https://www.ctrl.blog/entry/safari-beacon-issues.html
$(this._window).on('blur', this._flush);
$(this._window).on('visibilitychange', this._flush);
$(this._window).on('pagehide', this._flush);
// Set the initial inactivity timeout to clear navigation timing data.
this._resetInactivityFlushTimeout();
this._initializeSubscriptionModel();
},
destroy() {
$(this._window).off('unload', this._flush);
$(this._window).off('blur', this._flush);
$(this._window).off('visibilitychange', this._flush);
$(this._window).off('pagehide', this._flush);
this._clearInactivityFlushTimeout();
},
notifications: {
'flow.initialize': '_initializeFlowModel',
'flow.event': '_logFlowEvent',
'set-email-domain': '_setEmailDomain',
'set-sync-engines': '_setSyncEngines',
'set-uid': '_setUid',
'clear-uid': '_clearUid',
'subscription.initialize': '_initializeSubscriptionModel',
'once!view-shown': '_setInitialView',
},
/**
* @private
* Initialize the flow model. If it's already been initalized, do nothing.
* Initialization may fail if the required flow properties can't be found,
* either in the DOM or the resume token.
*/
_initializeFlowModel() {
if (this._flowModel) {
return;
}
const flowModel = new FlowModel({
metrics: this,
sentryMetrics: this._sentryMetrics,
window: this._window,
});
if (flowModel.has('flowId')) {
this._flowModel = flowModel;
}
},
/**
* @private
* Initialise the subscription model.
*
* @param {Object} [model] model to initialise with.
* If unset, a fresh model is created.
*/
_initializeSubscriptionModel(model) {
if (model && model.has('productId')) {
this._subscriptionModel = model;
} else {
this._subscriptionModel = new SubscriptionModel(
{},
{
window: this._window,
}
);
}
},
/**
* @private
* Log a flow event. If there is no flow model, do nothing.
*
* @param {Object} data
* @param {String} data.event The name of the event.
* @param {String} [data.viewName] The name of the view, to be
* interpolated in the event name. If unset, the event is
* logged without a view name.
* @param {Boolean} [data.once] If set, emit this event via
* the `logEventOnce` method. Defaults to `false`.
*/
_logFlowEvent(data) {
if (!this._flowModel) {
// If there is no flow model, we're not in a recognised flow and
// we should not emit the event. This would be the case if a user
// lands on `/settings`, for instance. Only views that mixin the
// `flow-events-mixin` will initialise the flow model.
return;
}
const viewName = data.viewName && this.addViewNamePrefix(data.viewName);
const eventName = marshallFlowEvent(data.event, viewName);
if (data.once) {
this.logEventOnce(eventName);
} else {
this.logEvent(eventName);
}
},
/**
* Set the initial view name and emit the loaded event.
*
* @param {View} view
*/
_setInitialView(view) {
this._initialViewName = view.viewName;
this.logEventOnce('loaded');
},
/**
* Send the collected data to the backend.
*
* @param {String} isPageUnloading
* @returns {Promise}
*/
flush(isPageUnloading) {
// Inactivity timer is restarted when the next event/timer comes in.
// This avoids sending empty result sets if the tab is
// just sitting there open with no activity.
this._clearInactivityFlushTimeout();
var filteredData = this.getFilteredData();
if (!this._isFlushRequired(filteredData, this._lastFlushedData)) {
return Promise.resolve();
}
this._lastFlushedData = filteredData;
this._speedTrap.events.clear();
this._speedTrap.timers.clear();
// numStoredAccounts should only be counted once by the backend
// for this user. After a flush, unset the value so it is not
// reported again.
this._numStoredAccounts = '';
// Create a sanitizer and check the data. This will report issues to sentry
// and keep track of critical errors that may have been encountered.
const reporter = new MetricErrorReporter(this._sentryMetrics);
const validator = new MetricValidator(reporter, {
maxEventOffset: this._maxEventOffset,
// These are optional, but we will do this to ensure backwards compatibility
isUtmValid: Validate.isUtmValid,
isDeviceIdValid: Validate.isDeviceIdValid,
});
validator.sanitizeDeviceId(filteredData);
validator.sanitizeEvents(filteredData);
validator.sanitizeUtmParams(filteredData);
// For now, if any data is coerced and resulted in a critical error being
// captured do not send the metric. Depending on the metrics captured in
// sentry we may want to revise what is considered critical.
if (reporter.critical > 0) {
return;
}
const send = () => this._send(filteredData, isPageUnloading);
return (
send()
// Retry once in case of failure, then give up
.then((sent) => sent || send())
);
},
/**
* Check if a flush is required for the given `data`. A flush is
* required if any data has changed since the last flush.
*
* @param {Object} data - potential data to flush
* @param {Object} lastFlushedData - last data that was flushed.
* @returns {Boolean}
* @private
*/
_isFlushRequired(data, lastFlushedData) {
if (!lastFlushedData) {
return true;
}
// Only check fields that are in the new payload. `data` could be
// a subset of `_lastFlushedData`, in which case no flush should occur.
return _.any(data, (value, key) => {
// these keys are distinct every flush attempt, ignore.
if (key === 'duration' || key === 'flushTime') {
return false;
// events should only cause a flush if there are events to send.
} else if (key === 'events' && !value.length) {
return false;
// timers should only cause a flush if there are timers to send.
} else if (key === 'timers' && !value.length) {
return false;
}
// _.isEqual does a deep comparision of objects and arrays.
return !_.isEqual(lastFlushedData[key], value);
});
},
_clearInactivityFlushTimeout() {
clearTimeout(this._inactivityFlushTimeout);
},
_resetInactivityFlushTimeout() {
this._clearInactivityFlushTimeout();
this._inactivityFlushTimeout = setTimeout(() => {
this.logEvent('inactivity.flush');
this.flush();
}, this._inactivityFlushMs);
},
/**
* Get all the data, whether it's allowed to be sent or not.
*
* @returns {Object}
*/
getAllData() {
const loadData = this._speedTrap.getLoad();
const unloadData = this._speedTrap.getUnload();
const flowData = this.getFlowEventMetadata();
const allData = _.extend({}, loadData, unloadData, {
broker: this._brokerType,
context: this._context,
deviceId: flowData.deviceId || NOT_REPORTED_VALUE,
emailDomain: this._emailDomain,
entrypoint: this._entrypoint,
entrypoint_experiment: this._entrypointExperiment, //eslint-disable-line camelcase
entrypoint_variation: this._entrypointVariation, //eslint-disable-line camelcase
experiments: flattenHashIntoArrayOfObjects(this._activeExperiments),
flowBeginTime: flowData.flowBeginTime,
flowId: flowData.flowId,
flushTime: this._speedTrap.now(),
initialView: this._initialViewName,
isSampledUser: this._isSampledUser,
lang: this._lang,
marketing: flattenHashIntoArrayOfObjects(this._marketingImpressions),
numStoredAccounts: this._numStoredAccounts,
newsletters: this._newsletters,
// planId and productId are optional so we can physically remove
// them from the payload instead of sending NOT_REPORTED_VALUE
planId: this._subscriptionModel.get('planId') || undefined,
productId: this._subscriptionModel.get('productId') || undefined,
referrer: this._referrer,
screen: {
clientHeight: this._clientHeight,
clientWidth: this._clientWidth,
devicePixelRatio: this._devicePixelRatio,
height: this._screenHeight,
width: this._screenWidth,
},
service: this._service,
settingsVersion: 'old',
startTime: this._startTime,
syncEngines: this._syncEngines,
uid: this._uid,
uniqueUserId: this._uniqueUserId,
userPreferences: this._userPreferences,
utm_campaign: this._utmCampaign, //eslint-disable-line camelcase
utm_content: this._utmContent, //eslint-disable-line camelcase
utm_medium: this._utmMedium, //eslint-disable-line camelcase
utm_source: this._utmSource, //eslint-disable-line camelcase
utm_term: this._utmTerm, //eslint-disable-line camelcase
});
// Create a deep copy of the data so that any modifications to contained
// objects or arrays do not affect the returned copy of the data.
return JSON.parse(JSON.stringify(allData));
},
/**
* Get the filtered data.
* Filtered data is data that is allowed to be sent,
* that is defined and not an empty string.
*
* @returns {Object}
*/
getFilteredData() {
var allowedData = _.pick(this.getAllData(), ALLOWED_FIELDS);
return _.pick(allowedData, (value, key) => {
return !_.isUndefined(value) && value !== '';
});
},
/**
* Get a value from filtered data
*
* @returns {*}
*/
getFilteredValue(key) {
return this.getFilteredData()[key];
},
_send(data, isPageUnloading) {
if (!this._metricsEnabled) {
return Promise.resolve(true);
}
const url = `${this._collector}/metrics`;
const payload = JSON.stringify(data);
if (this._env.hasSendBeacon()) {
// Always use sendBeacon if it is available because:
// 1. it works asynchronously, even on unload.
// 2. user agents SHOULD make "multiple attempts to transmit the
// data in presence of transient network or server errors".
return Promise.resolve().then(() => {
return this._window.navigator.sendBeacon(url, payload);
});
}
// XHR is a fallback option because synchronous XHR has been deprecated,
// but we must call it synchronously in the unload case.
return (
this._xhr
.ajax({
async: !isPageUnloading,
contentType: 'application/json',
data: payload,
type: 'POST',
url,
})
// Boolean return values imitate the behaviour of sendBeacon
.then(
() => true,
() => false
)
);
},
/**
* Log an event
*
* @param {String} eventName
*/
logEvent(eventName) {
this._resetInactivityFlushTimeout();
this.events.capture(eventName);
if (IMMEDIATE_FLUSH_EVENTS.test(eventName)) {
this.flush();
}
},
/**
* Log an event only if it never happened before during this page load.
*
* @param {String} eventName
*/
logEventOnce(eventName) {
if (!this._eventMemory[eventName]) {
this.logEvent(eventName);
this._eventMemory[eventName] = true;
}
},
/**
* Marks some event already logged in metrics memory.
*
* Used in conjunction with `logEventOnce` when we know that some event was already logged elsewhere.
* Helps avoid event duplication.
*
* @param {String} eventName
*/
markEventLogged: function (eventName) {
this._eventMemory[eventName] = true;
},
/**
* Start a timer
*
* @param {String} timerName
*/
startTimer(timerName) {
this._resetInactivityFlushTimeout();
this.timers.start(timerName);
},
/**
* Stop a timer
*
* @param {String} timerName
*/
stopTimer(timerName) {
this._resetInactivityFlushTimeout();
this.timers.stop(timerName);
},
/**
* Log an error.
*
* @param {Error} error
*/
logError(error) {
this.logEvent(this.errorToId(error));
},
/**
* Convert an error to an identifier that can be used for logging.
*
* @param {Error} error
* @returns {String}
*/
errorToId(error) {
// Prefer context to viewName for the context identifier.
let context = error.context;
if (!context) {
if (error.viewName) {
context = this.addViewNamePrefix(error.viewName);
} else {
context = 'unknown context';
}
}
var id = Strings.interpolate('error.%s.%s.%s', [
context,
error.namespace || 'unknown namespace',
error.errno || String(error),
]);
return id;
},
/**
* Set the `service` parameter to use for all future metrics.
* This is useful in cases where don't learn the appropriate
* service value until after the app has been initialized.
*
* @param {String} [service] The service identifier
*/
setService(service) {
this._service = service || NOT_REPORTED_VALUE;
},
/**
* Set the view name prefix for metrics that contain a viewName.
* This is used to differentiate between flows when the same
* URL can appear in more than one place in the flow.
*
* This prefix is prepended to the view name anywhere a view
* name is used.
*
* @param {String} [viewNamePrefix='']
*/
setViewNamePrefix(viewNamePrefix = '') {
this._viewNamePrefix = viewNamePrefix;
},
/**
* Add the view name prefix to `viewName`.
*
* @param {String} viewName
* @returns {String}
*/
addViewNamePrefix(viewName) {
if (this._viewNamePrefix) {
return `${this._viewNamePrefix}.${viewName}`;
}
return viewName;
},
/**
* Log a view
*
* @param {String} viewName
*/
logView(viewName) {
// `screen.` is a legacy artifact from when each View was a screen.
// The identifier is kept to avoid updating all metrics queries.
this.logEvent(`screen.${this.addViewNamePrefix(viewName)}`);
},
/**
* Log an event with the view name as a prefix
*
* @param {String} viewName
* @param {String} eventName
*/
logViewEvent(viewName, eventName) {
this.logEvent(`${this.addViewNamePrefix(viewName)}.${eventName}`);
},
/**
* Log when an experiment is shown to the user
*
* @param {String} choice - type of experiment
* @param {String} group - the experiment group (treatment or control)
*/
logExperiment(choice, group) {
this._logFlowEvent({
event: `experiment.${choice}.${group}`,
once: true,
});
if (!choice || !group) {
return;
}
var experiments = this._activeExperiments;
if (!experiments[choice]) {
experiments[choice] = {};
}
experiments[choice][group] = {
choice: choice,
group: group,
};
},
/**
* Log when a user preference is updated. Example, two step authentication,
* adding recovery email or account recovery key.
*
* @param {String} prefName - name of preference, typically view name
* @param {Boolean} value - value of preference
*/
logUserPreferences(prefName, value) {
this._userPreferences[prefName] = !!value;
},
/**
* Log subscribed newsletters for a user.
*
* @param {Array} newsletters - Array of newsletters that user belongs to
*/
logNewsletters(newsletters) {
this._newsletters = newsletters;
},
/**
* Log when a marketing snippet is shown to the user
*
* @param {String} campaignId - marketing campaign id
* @param {String} url - url of marketing link
*/
logMarketingImpression(campaignId, url) {
campaignId = campaignId || UNKNOWN_CAMPAIGN_ID;
var impressions = this._marketingImpressions;
if (!impressions[campaignId]) {
impressions[campaignId] = {};
}
impressions[campaignId][url] = {
campaignId: campaignId,
clicked: false,
url: url,
};
},
/**
* Log whether the user clicked on a marketing link
*
* @param {String} campaignId - marketing campaign id
* @param {String} url - URL clicked.
*/
logMarketingClick(campaignId, url) {
campaignId = campaignId || UNKNOWN_CAMPAIGN_ID;
var impression = this.getMarketingImpression(campaignId, url);
if (impression) {
impression.clicked = true;
}
},
getMarketingImpression(campaignId, url) {
var impressions = this._marketingImpressions;
return impressions[campaignId] && impressions[campaignId][url];
},
setBrokerType(brokerType) {
this._brokerType = brokerType || NOT_REPORTED_VALUE;
},
isCollectionEnabled() {
return this._isSampledUser;
},
getFlowEventMetadata() {
const metadata = (this._flowModel && this._flowModel.attributes) || {};
const subscriptionMetadata =
(this._subscriptionModel && this._subscriptionModel.attributes) || {};
return {
deviceId: metadata.deviceId,
entrypoint: marshallProperty(this._entrypoint),
entrypointExperiment: marshallProperty(this._entrypointExperiment),
entrypointVariation: marshallProperty(this._entrypointVariation),
flowBeginTime: metadata.flowBegin,
flowId: metadata.flowId,
utmCampaign: marshallUtmProperty(this._utmCampaign),
utmContent: marshallUtmProperty(this._utmContent),
utmMedium: marshallUtmProperty(this._utmMedium),
utmSource: marshallUtmProperty(this._utmSource),
utmTerm: marshallUtmProperty(this._utmTerm),
productId: subscriptionMetadata.productId || undefined,
planId: subscriptionMetadata.planId || undefined,
};
},
getFlowModel() {
return this._flowModel;
},
getSubscriptionModel() {
return this._subscriptionModel;
},
/**
* Log the number of stored accounts
*
* @param {Number} numStoredAccounts
*/
logNumStoredAccounts(numStoredAccounts) {
this._numStoredAccounts = numStoredAccounts;
},
_setEmailDomain(email) {
const domain = marshallEmailDomain(email);
if (domain) {
this._emailDomain = domain;
}
},
_setSyncEngines(engines) {
if (engines) {
this._syncEngines = engines;
}
},
_setUid(uid, metricsEnabled) {
if (uid) {
this._uid = uid;
}
if (typeof metricsEnabled !== 'undefined') {
this._metricsEnabled = !!metricsEnabled;
}
},
_setPlanProductId({ planId, productId }) {
if (planId) {
this._planId = planId;
}
if (productId) {
this._productId = productId;
}
},
_clearUid() {
this._uid = NOT_REPORTED_VALUE;
},
});
Cocktail.mixin(Metrics, NotifierMixin);
export default Metrics;