333 lines
12 KiB
JavaScript
333 lines
12 KiB
JavaScript
// 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/.
|
|
|
|
/**
|
|
* Class that represents a metrics event broker. Events are sent to Google
|
|
* Analytics if the `tid` parameter is set. Events are sent to Mozilla's
|
|
* data pipeline via the Test Pilot add-on. No metrics code changes are
|
|
* needed when the experiment is added to or removed from Test Pilot.
|
|
* @constructor
|
|
* @param {string} $0.id - addon ID, e.g. '@testpilot-addon'. See https://mdn.io/add_on_id.
|
|
* @param {string} $0.version - addon version, e.g. '1.0.2'.
|
|
* @param {string} [$0.uid] - unique identifier for a specific instance of an addon.
|
|
* Optional, but required to send events to Google Analytics. Sent to Google Analytics
|
|
* but not Mozilla services.
|
|
* @param {string} [$0.tid] - Google Analytics tracking ID. Optional, but required
|
|
* to send events to Google Analytics.
|
|
* @param {string} [$0.type=webextension] - addon type. one of: 'webextension',
|
|
* 'sdk', 'bootstrapped'.
|
|
* @param {boolean} [$0.debug=false] - if true, enables logging. Note that this
|
|
* value can be changed on a running instance, by modifying its `debug` property.
|
|
* @throws {SyntaxError} If the required properties are missing, or if the
|
|
* 'type' property is unrecognized.
|
|
* @throws {Error} if initializing the transports fails.
|
|
*/
|
|
function Metrics({id, version, uid, tid = null, type = 'webextension', debug = false}) {
|
|
if (!id) {
|
|
throw new SyntaxError(`'id' property is required.`);
|
|
} else if (!version) {
|
|
throw new SyntaxError(`'version' property is required.`);
|
|
} else if (tid && !uid) {
|
|
throw new SyntaxError(`'uid' property is required to send events to Google Analytics.`);
|
|
}
|
|
|
|
if (!['webextension', 'sdk', 'bootstrapped'].includes(type)) {
|
|
throw new SyntaxError(`'type' property must be one of: 'webextension', 'sdk', or 'bootstrapped'`);
|
|
}
|
|
Object.assign(this, {id, uid, version, tid, type, debug});
|
|
|
|
// The test pilot add-on uses its own nsIObserverService topic for sending
|
|
// pings to Telemetry. Otherwise, the topic is based on add-on type.
|
|
if (id === '@testpilot-addon') {
|
|
this.topic = 'testpilot';
|
|
} else if (type === 'webextension') {
|
|
this.topic = 'testpilot-telemetry';
|
|
} else {
|
|
this.topic = 'testpilottest';
|
|
}
|
|
|
|
// NOTE: order is important here. _initTransports uses console.log, which may
|
|
// not be available before _initConsole has run.
|
|
this._initConsole();
|
|
this._initTransports();
|
|
|
|
this.sendEvent = this.sendEvent.bind(this);
|
|
|
|
this._log(`Initialized topic to ${this.topic}`);
|
|
if (!tid) {
|
|
this._log(`Google Analytics disabled: 'tid' value not passed to constructor.`);
|
|
} else {
|
|
this._log(`Google Analytics enabled for Tracking ID ${tid}.`);
|
|
}
|
|
this._log('Constructor finished successfully.');
|
|
}
|
|
Metrics.prototype = {
|
|
/**
|
|
* Sends an event to the Mozilla data pipeline (and Google Analytics, if
|
|
* a `tid` was passed to the constructor). Note: to avoid breaking callers,
|
|
* if sending the event fails, no Errors will be thrown. Instead, the message
|
|
* will be silently dropped, and, if debug mode is enabled, an error will be
|
|
* logged to the Browser Console.
|
|
*
|
|
* If you want to pass extra fields to GA, or use a GA hit type other than
|
|
* `Event`, you can transform the output data object yourself using the
|
|
* `transform` parameter. You will need to add Custom Dimensions to GA for any
|
|
* extra fields: https://support.google.com/analytics/answer/2709828. Note
|
|
* that, by convention, the `variant` argument is mapped to the first Custom
|
|
* Dimension (`cd1`) when constructing the GA Event hit.
|
|
*
|
|
* Note: the data object format is currently different for each experiment,
|
|
* and should be defined based on the result of conversations with the Mozilla
|
|
* data team.
|
|
*
|
|
* A suggested default format is:
|
|
* @param {string} [$0.method] - What is happening? e.g. `click`
|
|
* @param {string} [$0.object] - What is being affected? e.g. `home-button-1`
|
|
* @param {string} [$0.category=interactions] - If you want to add a category
|
|
* for easy reporting later. e.g. `mainmenu`
|
|
* @param {string} [$0.variant=null] - An identifying string if you're running
|
|
* different variants. e.g. `cohort-A`
|
|
* @param {function} [transform] - Transform function used to alter the
|
|
* parameters sent to GA. The `transform` function signature is
|
|
* `transform(input, output)`, where `input` is the object passed to
|
|
* `sendEvent` (excluding `transform`), and `output` is the default GA
|
|
* object generated by the `_gaTransform` method. The `transform` function
|
|
* should return an object whose keys are GA Measurement Protocol parameters.
|
|
* The returned object will be form encoded and sent to GA.
|
|
*/
|
|
sendEvent: function(params = {}, transform) {
|
|
const args = this._clone(params);
|
|
args.object = params.object || null;
|
|
args.category = params.category || 'interactions';
|
|
args.variant = params.variant || null;
|
|
|
|
this._log(`sendEvent called with method = ${args.method}, object = ${args.object}, category = ${args.category}, variant = ${args.variant}.`);
|
|
|
|
const clientData = this._clone(args);
|
|
const gaData = this._clone(args);
|
|
if (!clientData) {
|
|
this._error(`Unable to process data object. Dropping packet.`);
|
|
return;
|
|
}
|
|
this._sendToClient(clientData);
|
|
|
|
if (this.tid && this.uid) {
|
|
const defaultEvent = this._gaTransform(gaData);
|
|
|
|
let userEvent;
|
|
if (transform) {
|
|
userEvent = transform.call(null, gaData, defaultEvent);
|
|
}
|
|
|
|
this._gaSend(userEvent || defaultEvent);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Clone a data object by serializing / deserializing it.
|
|
* @private
|
|
* @param {object} o - Object to be cloned.
|
|
* @returns A clone of the object, or `null` if cloning failed.
|
|
*/
|
|
_clone: function(o) {
|
|
let cloned;
|
|
try {
|
|
cloned = JSON.parse(JSON.stringify(o));
|
|
} catch (ex) {
|
|
this._error(`Unable to clone object: ${ex}.`);
|
|
return null;
|
|
}
|
|
return cloned;
|
|
},
|
|
|
|
/**
|
|
* Sends an event to the Mozilla data pipeline via the Test Pilot add-on.
|
|
* Uses BroadcastChannel for WebExtensions, and nsIObserverService for other
|
|
* add-on types.
|
|
* @private
|
|
* @param {object} params - Entire object sent to `sendEvent`.
|
|
*/
|
|
_sendToClient: function(params) {
|
|
if (this.type === 'webextension') {
|
|
this._channel.postMessage(params);
|
|
this._log(`Sent client message via postMessage: ${params}`);
|
|
} else {
|
|
let stringified;
|
|
|
|
try {
|
|
stringified = JSON.stringify(params);
|
|
} catch(ex) {
|
|
this._error(`Unable to serialize metrics event: ${ex}`);
|
|
return;
|
|
}
|
|
|
|
const subject = {
|
|
wrappedJSObject: {
|
|
observersModuleSubjectWrapper: true,
|
|
object: this.id
|
|
}
|
|
};
|
|
|
|
try {
|
|
Services.obs.notifyObservers(subject, 'testpilot::send-metric', stringified);
|
|
this._log(`Sent client message via nsIObserverService: ${stringified}`);
|
|
} catch (ex) {
|
|
this._error(`Failed to send nsIObserver client ping: ${ex}`);
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Transforms `sendEvent()` arguments into a Google Analytics `Event` hit.
|
|
* @private
|
|
* @param {string} method - see `sendEvent` docs
|
|
* @param {string} [object] - see `sendEvent` docs
|
|
* @param {string} category - see `sendEvent` docs. Note that `category` is
|
|
* required here, assuming the default value was filled in by `sendEvent()`.
|
|
* @param {string} variant - see `sendEvent` docs. Note that `variant` is
|
|
* required here, assuming the default value was filled in by `sendEvent()`.
|
|
*/
|
|
_gaTransform: function({method, object, category, variant}) {
|
|
const data = {
|
|
v: 1,
|
|
an: this.id,
|
|
av: this.version,
|
|
tid: this.tid,
|
|
uid: this.uid,
|
|
t: 'event',
|
|
ec: category,
|
|
ea: method
|
|
};
|
|
if (object) {
|
|
data.el = object;
|
|
}
|
|
if (variant) {
|
|
data.cd1 = variant;
|
|
}
|
|
return data;
|
|
},
|
|
|
|
/**
|
|
* Encodes and sends an event message to Google Analytics.
|
|
* @private
|
|
* @param {object} msg - An object whose keys correspond to parameters in the
|
|
* Google Analytics Measurement Protocol.
|
|
*/
|
|
_gaSend: function(msg) {
|
|
const encoded = this._formEncode(msg);
|
|
const GA_URL = 'https://ssl.google-analytics.com/collect';
|
|
if (this.type === 'webextension') {
|
|
navigator.sendBeacon(GA_URL, encoded);
|
|
} else {
|
|
// SDK and bootstrapped types might not have a window reference, so get
|
|
// the sendBeacon DOM API from the hidden window.
|
|
Services.appShell.hiddenDOMWindow.navigator.sendBeacon(GA_URL, encoded);
|
|
}
|
|
this._log(`Sent GA message: ${encoded}`);
|
|
},
|
|
|
|
/**
|
|
* URL encodes an object. Encodes spaces as '%20', not '+', following the
|
|
* GA docs.
|
|
*
|
|
* @example
|
|
* // returns 'a=b&foo=b%20ar'
|
|
* metrics._formEncode({a: 'b', foo: 'b ar'});
|
|
* @private
|
|
* @param {Object} obj - Any JS object
|
|
* @returns {string}
|
|
*/
|
|
_formEncode: function(obj) {
|
|
const params = [];
|
|
if (!obj) { return ''; }
|
|
Object.keys(obj).forEach(item => {
|
|
const encoded = encodeURIComponent(item) + '=' + encodeURIComponent(obj[item]);
|
|
params.push(encoded);
|
|
});
|
|
return params.join('&');
|
|
},
|
|
|
|
/**
|
|
* Initializes transports used for sending messages. For WebExtensions,
|
|
* creates a `BroadcastChannel` (transport for client pings). WebExtensions
|
|
* use navigator.sendBeacon for GA transport, and they always have access
|
|
* to DOM APIs, so there's no setup work required. For other types, loads
|
|
* `Services.jsm`, which exposes the nsIObserverService (transport for client
|
|
* pings), and exposes the navigator.sendBeacon API (GA transport) via the
|
|
* appShell service's hidden window.
|
|
* @private
|
|
* @throws {Error} if transport setup unexpectedly fails
|
|
*/
|
|
_initTransports: function() {
|
|
if (this.type === 'webextension') {
|
|
try {
|
|
this._channel = new BroadcastChannel(this.topic);
|
|
} catch(ex) {
|
|
throw new Error(`Unable to create BroadcastChannel: ${ex}`);
|
|
}
|
|
} else if (this.type === 'sdk') {
|
|
try {
|
|
const { Cu } = require('chrome');
|
|
Cu.import('resource://gre/modules/Services.jsm');
|
|
} catch(ex) {
|
|
throw new Error(`Unable to load Services.jsm: ${ex}`);
|
|
}
|
|
} else { /* this.type === 'bootstrapped' */
|
|
try {
|
|
Components.utils.import('resource://gre/modules/Services.jsm');
|
|
} catch(ex) {
|
|
throw new Error(`Unable to load Services.jsm: ${ex}`);
|
|
}
|
|
}
|
|
this._log('Successfully initialized transports.');
|
|
},
|
|
|
|
/**
|
|
* Initializes a console for 'bootstrapped' add-ons.
|
|
* @private
|
|
*/
|
|
_initConsole: function() {
|
|
if (this.type === 'bootstrapped') {
|
|
try {
|
|
Components.utils.import('resource://gre/modules/Console.jsm');
|
|
this._log('Successfully initialized console.');
|
|
} catch(ex) {
|
|
throw new Error(`Unable to initialize console: ${ex}`);
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Logs messages to the console. Only enabled if `this.debug` is truthy.
|
|
* @private
|
|
* @param {string} msg - A message
|
|
*/
|
|
_log: function(msg) {
|
|
if (this.debug) {
|
|
console.log(msg); // eslint-disable-line no-console
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Logs errors to the console. Only enabled if `this.debug` is truthy.
|
|
* @private
|
|
* @param {string} msg - An error message
|
|
*/
|
|
_error: function(msg) {
|
|
if (this.debug) {
|
|
console.error(msg); // eslint-disable-line no-console
|
|
}
|
|
}
|
|
};
|
|
|
|
// WebExtensions don't support CommonJS module style, so 'module' might not be
|
|
// defined.
|
|
if (typeof module !== 'undefined') {
|
|
module.exports = Metrics;
|
|
}
|
|
|
|
// Export the Metrics constructor in Gecko JSM style, for legacy addons
|
|
// that use the JSM loader. See also: https://mdn.io/jsm/using
|
|
const EXPORTED_SYMBOLS = ['Metrics']; // eslint-disable-line no-unused-vars
|