This repository has been archived by the owner. It is now read-only.
Permalink
Cannot retrieve contributors at this time
706 lines (621 sloc)
19.7 KB
| /* 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(); | |
| */ | |
| 'use strict'; | |
| const $ = require('jquery'); | |
| const _ = require('underscore'); | |
| const Cocktail = require('cocktail'); | |
| const Constants = require('./constants'); | |
| const Backbone = require('backbone'); | |
| const Duration = require('duration'); | |
| const Environment = require('./environment'); | |
| const Flow = require('../models/flow'); | |
| const NotifierMixin = require('./channels/notifier-mixin'); | |
| const speedTrap = require('speed-trap').default; | |
| const Strings = require('./strings'); | |
| const xhr = require('./xhr'); | |
| // Speed trap is a singleton, convert it | |
| // to an instantiable function. | |
| const SpeedTrap = function () {}; | |
| SpeedTrap.prototype = speedTrap; | |
| const ALLOWED_FIELDS = [ | |
| 'broker', | |
| 'context', | |
| 'deviceId', | |
| 'duration', | |
| 'emailDomain', | |
| 'entrypoint', | |
| 'events', | |
| 'experiments', | |
| 'flowBeginTime', | |
| 'flowId', | |
| 'flushTime', | |
| 'initialView', | |
| 'isSampledUser', | |
| 'lang', | |
| 'marketing', | |
| 'migration', | |
| 'navigationTiming', | |
| 'numStoredAccounts', | |
| 'referrer', | |
| 'screen', | |
| 'service', | |
| 'startTime', | |
| 'timers', | |
| 'uid', | |
| 'uniqueUserId', | |
| 'utm_campaign', | |
| 'utm_content', | |
| 'utm_medium', | |
| 'utm_source', | |
| 'utm_term' | |
| ]; | |
| var DEFAULT_INACTIVITY_TIMEOUT_MS = new Duration('2m').milliseconds(); | |
| var NOT_REPORTED_VALUE = 'none'; | |
| var UNKNOWN_CAMPAIGN_ID = 'unknown'; | |
| // 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 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 = {}) { | |
| this._speedTrap = new SpeedTrap(); | |
| 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._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._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._migration = options.migration || NOT_REPORTED_VALUE; | |
| this._numStoredAccounts = options.numStoredAccounts || ''; | |
| this._referrer = this._window.document.referrer || 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; | |
| // if navigationTiming is supported, the baseTime will be from | |
| // navigationTiming.navigationStart, otherwise Date.now(). | |
| this._startTime = options.startTime || this._speedTrap.baseTime; | |
| this._uid = options.uid || NOT_REPORTED_VALUE; | |
| this._uniqueUserId = options.uniqueUserId || NOT_REPORTED_VALUE; | |
| 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 = _.bind(this.flush, this, true); | |
| $(this._window).on('unload', this._flush); | |
| // iOS will not send events once the window is in the background, | |
| // meaning the `unload` handler is ineffective. Send events on blur | |
| // instead, so events are not lost when a user goes to verify their | |
| // email. | |
| $(this._window).on('blur', this._flush); | |
| // Set the initial inactivity timeout to clear navigation timing data. | |
| this._resetInactivityFlushTimeout(); | |
| }, | |
| destroy () { | |
| $(this._window).off('unload', this._flush); | |
| $(this._window).off('blur', this._flush); | |
| this._clearInactivityFlushTimeout(); | |
| }, | |
| notifications: { | |
| /* eslint-disable sorting/sort-object-props */ | |
| 'flow.initialize': '_initializeFlowModel', | |
| 'flow.event': '_logFlowEvent', | |
| 'set-email-domain': '_setEmailDomain', | |
| 'set-uid': '_setUid', | |
| 'clear-uid': '_clearUid', | |
| 'once!view-shown': '_setInitialView' | |
| /* eslint-enable sorting/sort-object-props */ | |
| }, | |
| /** | |
| * @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 Flow({ | |
| metrics: this, | |
| sentryMetrics: this._sentryMetrics, | |
| window: this._window | |
| }); | |
| if (flowModel.has('flowId')) { | |
| this._flowModel = flowModel; | |
| } | |
| }, | |
| /** | |
| * @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 = ''; | |
| 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, | |
| experiments: flattenHashIntoArrayOfObjects(this._activeExperiments), | |
| flowBeginTime: flowData.flowBeginTime, | |
| flowId: flowData.flowId, | |
| flushTime: Date.now(), | |
| initialView: this._initialViewName, | |
| isSampledUser: this._isSampledUser, | |
| lang: this._lang, | |
| marketing: flattenHashIntoArrayOfObjects(this._marketingImpressions), | |
| migration: this._migration, | |
| numStoredAccounts: this._numStoredAccounts, | |
| referrer: this._referrer, | |
| screen: { | |
| clientHeight: this._clientHeight, | |
| clientWidth: this._clientWidth, | |
| devicePixelRatio: this._devicePixelRatio, | |
| height: this._screenHeight, | |
| width: this._screenWidth | |
| }, | |
| service: this._service, | |
| startTime: this._startTime, | |
| uid: this._uid, | |
| uniqueUserId: this._uniqueUserId, | |
| 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) => { | |
| // navigationTiming is sent in the first flush, no need to re-send it. | |
| if (this._lastFlushedData && key === 'navigationTiming') { | |
| return false; | |
| } | |
| return ! _.isUndefined(value) && value !== ''; | |
| }); | |
| }, | |
| _send (data, isPageUnloading) { | |
| var url = this._collector + '/metrics'; | |
| var 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: url | |
| }).then(function () { | |
| // Boolean return values imitate the behaviour of sendBeacon | |
| return true; | |
| }, function () { | |
| return false; | |
| }); | |
| }, | |
| /** | |
| * Log an event | |
| * | |
| * @param {String} eventName | |
| */ | |
| logEvent (eventName) { | |
| this._resetInactivityFlushTimeout(); | |
| this.events.capture(eventName); | |
| }, | |
| /** | |
| * 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 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, e.g., the | |
| * /sms screen. The /sms screen can be displayed in either the | |
| * signup or verification tab, and we want to be able to | |
| * differentiate between the two. | |
| * | |
| * 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 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; | |
| }, | |
| isCollectionEnabled () { | |
| return this._isSampledUser; | |
| }, | |
| getFlowEventMetadata () { | |
| const metadata = (this._flowModel && this._flowModel.attributes) || {}; | |
| return { | |
| deviceId: metadata.deviceId, | |
| entrypoint: marshallProperty(this._entrypoint), | |
| flowBeginTime: metadata.flowBegin, | |
| flowId: metadata.flowId, | |
| utmCampaign: marshallProperty(this._utmCampaign), | |
| utmContent: marshallProperty(this._utmContent), | |
| utmMedium: marshallProperty(this._utmMedium), | |
| utmSource: marshallProperty(this._utmSource), | |
| utmTerm: marshallProperty(this._utmTerm) | |
| }; | |
| }, | |
| getFlowModel (flowModel) { | |
| return this._flowModel; | |
| }, | |
| /** | |
| * 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; | |
| } | |
| }, | |
| _setUid (uid) { | |
| if (uid) { | |
| this._uid = uid; | |
| } | |
| }, | |
| _clearUid () { | |
| this._uid = NOT_REPORTED_VALUE; | |
| } | |
| }); | |
| Cocktail.mixin( | |
| Metrics, | |
| NotifierMixin | |
| ); | |
| module.exports = Metrics; |