// 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/. const Experiment = require('./lib/testpilot/experiment'); const experiment = new Experiment(); /** * 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