diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..9b27377 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +testpilot-metrics.js diff --git a/docs/metrics.md b/docs/metrics.md index fab09f6..50e9728 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -114,7 +114,6 @@ of a `testpilottest` telemetry ping for each scenario. ```js { "uuid": , - "userContextId": , "event": "add-container" } ``` diff --git a/index.js b/index.js index ab37202..8ad4d3d 100644 --- a/index.js +++ b/index.js @@ -35,6 +35,7 @@ const PREFS = [ const { attachTo, detachFrom } = require("sdk/content/mod"); const { ContextualIdentityService } = require("resource://gre/modules/ContextualIdentityService.jsm"); const { getFavicon } = require("sdk/places/favicon"); +const Metrics = require("./testpilot-metrics"); const { modelFor } = require("sdk/model/core"); const prefService = require("sdk/preferences/service"); const self = require("sdk/self"); @@ -42,11 +43,13 @@ const ss = require("sdk/simple-storage"); const { Style } = require("sdk/stylesheet/style"); const tabs = require("sdk/tabs"); const tabsUtils = require("sdk/tabs/utils"); +const uuid = require("sdk/util/uuid"); const { viewFor } = require("sdk/view/core"); const webExtension = require("sdk/webextension"); const windows = require("sdk/windows"); const windowUtils = require("sdk/window/utils"); + // ---------------------------------------------------------------------------- // ContainerService @@ -61,7 +64,8 @@ const ContainerService = { if (installation) { const object = { version: 1, - prefs: {} + prefs: {}, + metricsUUID: uuid.uuid().toString(), }; PREFS.forEach(pref => { @@ -77,6 +81,8 @@ const ContainerService = { prefService.set(pref[0], pref[1]); }); + this._metricsUUID = ss.storage.savedConfiguration.metricsUUID; + // Message routing // only these methods are allowed. We have a 1:1 mapping between messages @@ -95,6 +101,7 @@ const ContainerService = { "removeIdentity", "updateIdentity", "getPreference", + "sendTelemetryPayload" ]; // Map of identities. @@ -159,10 +166,65 @@ const ContainerService = { }).catch(() => { throw new Error("WebExtension startup failed. Unable to continue."); }); + + this._sendEvent = new Metrics({ + type: "sdk", + id: self.id, + version: self.version + }).sendEvent; + }, // utility methods + _containerTabCount(userContextId) { + // Returns the total of open and hidden tabs with this userContextId + let containerTabsCount = 0; + containerTabsCount += this._identitiesState[userContextId].openTabs; + containerTabsCount += this._identitiesState[userContextId].hiddenTabs.length; + return containerTabsCount; + }, + + _totalContainerTabsCount() { + // Returns the number of total open tabs across ALL containers + let totalContainerTabsCount = 0; + for (const userContextId in this._identitiesState) { + totalContainerTabsCount += this._identitiesState[userContextId].openTabs; + } + return totalContainerTabsCount; + }, + + _totalNonContainerTabsCount() { + // Returns the number of open tabs NOT IN a container + let totalNonContainerTabsCount = 0; + for (const tab of tabs) { + if (this._getUserContextIdFromTab(tab) === 0) { + ++totalNonContainerTabsCount; + } + } + return totalNonContainerTabsCount; + }, + + _containersCounts() { + let containersCounts = { // eslint-disable-line prefer-const + "shown": 0, + "hidden": 0, + "total": 0 + }; + for (const userContextId in this._identitiesState) { + if (this._identitiesState[userContextId].openTabs > 0) { + ++containersCounts.shown; + ++containersCounts.total; + continue; + } else if (this._identitiesState[userContextId].hiddenTabs.length > 0) { + ++containersCounts.hidden; + ++containersCounts.total; + continue; + } + } + return containersCounts; + }, + _convert(identity) { // Let's convert the known colors to their color names. return { @@ -291,6 +353,17 @@ const ContainerService = { ss.storage.identitiesData = this._identitiesState; }, + sendTelemetryPayload(args = {}) { + // when pings come from popup, delete "method" prop + delete args.method; + let payload = { // eslint-disable-line prefer-const + "uuid": this._metricsUUID + }; + Object.assign(payload, args); + + this._sendEvent(payload); + }, + // Tabs management hideTabs(args) { @@ -303,6 +376,16 @@ const ContainerService = { return Promise.resolve(null); } + const containersCounts = this._containersCounts(); + this.sendTelemetryPayload({ + "event": "hide-tabs", + "userContextId": args.userContextId, + "clickedContainerTabCount": this._containerTabCount(args.userContextId), + "shownContainersCount": containersCounts.shown, + "hiddenContainersCount": containersCounts.hidden, + "totalContainersCount": containersCounts.total + }); + const tabsToClose = []; this._containerTabIterator(args.userContextId, tab => { @@ -337,6 +420,16 @@ const ContainerService = { return Promise.resolve(null); } + const containersCounts = this._containersCounts(); + this.sendTelemetryPayload({ + "event": "show-tabs", + "userContextId": args.userContextId, + "clickedContainerTabCount": this._containerTabCount(args.userContextId), + "shownContainersCount": containersCounts.shown, + "hiddenContainersCount": containersCounts.hidden, + "totalContainersCount": containersCounts.total + }); + const promises = []; for (let object of this._identitiesState[args.userContextId].hiddenTabs) { // eslint-disable-line prefer-const @@ -351,6 +444,13 @@ const ContainerService = { }, sortTabs() { + const containersCounts = this._containersCounts(); + this.sendTelemetryPayload({ + "event": "sort-tabs", + "shownContainersCount": containersCounts.shown, + "totalContainerTabsCount": this._totalContainerTabsCount(), + "totalNonContainerTabsCount": this._totalNonContainerTabsCount() + }); return new Promise(resolve => { for (let window of windows.browserWindows) { // eslint-disable-line prefer-const // First the pinned tabs, then the normal ones. @@ -460,6 +560,12 @@ const ContainerService = { return; } + this.sendTelemetryPayload({ + "event": "move-tabs-to-window", + "userContextId": args.userContextId, + "clickedContainerTabCount": this._containerTabCount(args.userContextId), + }); + // Let's create a list of the tabs. const list = []; this._containerTabIterator(args.userContextId, tab => { @@ -500,9 +606,17 @@ const ContainerService = { openTab(args) { return this._recentBrowserWindow().then(browserWin => { - let userContextId = 0; - if ("userContextId" in args) { - userContextId = args.userContextId; + const userContextId = ("userContextId" in args) ? args.userContextId : 0; + const source = ("source" in args) ? args.source : null; + + // Only send telemetry for tabs opened by UI - i.e., not via showTabs + if (source) { + this.sendTelemetryPayload({ + "event": "open-tab", + "eventSource": source, + "userContextId": userContextId, + "clickedContainerTabCount": this._containerTabCount(userContextId) + }); } const tab = browserWin.gBrowser.addTab(args.url || DEFAULT_TAB, { userContextId }); @@ -538,6 +652,10 @@ const ContainerService = { }, createIdentity(args) { + this.sendTelemetryPayload({ + "event": "add-container", + }); + for (let arg of [ "name", "color", "icon"]) { // eslint-disable-line prefer-const if (!(arg in args)) { return Promise.reject("createIdentity must be called with " + arg + " argument."); @@ -563,6 +681,11 @@ const ContainerService = { return Promise.reject("updateIdentity must be called with userContextId argument."); } + this.sendTelemetryPayload({ + "event": "edit-container", + "userContextId": args.userContextId + }); + const identity = ContextualIdentityService.getIdentityFromId(args.userContextId); for (let arg of [ "name", "color", "icon"]) { // eslint-disable-line prefer-const if ((arg in args)) { @@ -589,6 +712,11 @@ const ContainerService = { return Promise.reject("removeIdentity must be called with userContextId argument."); } + this.sendTelemetryPayload({ + "event": "delete-container", + "userContextId": args.userContextId + }); + const tabsToClose = []; this._containerTabIterator(args.userContextId, tab => { tabsToClose.push(tab); @@ -837,7 +965,10 @@ ContainerWindow.prototype = { menuItemElement.setAttribute("data-identity-color", identity.color); menuItemElement.addEventListener("command", (e) => { - ContainerService.openTab({userContextId: identity.userContextId}); + ContainerService.openTab({ + userContextId: identity.userContextId, + source: "tab-bar" + }); e.stopPropagation(); }); @@ -870,7 +1001,10 @@ ContainerWindow.prototype = { _configureFileMenu() { return this._configureMenu("menu_newUserContext", null, e => { const userContextId = parseInt(e.target.getAttribute("data-usercontextid"), 10); - ContainerService.openTab({ userContextId }); + ContainerService.openTab({ + userContextId: userContextId, + source: "file-menu" + }); }, "_fileMenuElements"); }, diff --git a/package.json b/package.json index 3f7fb24..55aadfc 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "jpm": "^1.2.2", "npm-run-all": "^4.0.0", "stylelint": "^7.7.1", - "stylelint-config-standard": "^15.0.1" + "stylelint-config-standard": "^15.0.1", + "testpilot-metrics": "^2.1.0" }, "engines": { "firefox": ">=50.0" diff --git a/testpilot-metrics.js b/testpilot-metrics.js new file mode 100644 index 0000000..f68aae3 --- /dev/null +++ b/testpilot-metrics.js @@ -0,0 +1,333 @@ +// 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 diff --git a/webextension/js/popup.js b/webextension/js/popup.js index 1414c05..49775dc 100644 --- a/webextension/js/popup.js +++ b/webextension/js/popup.js @@ -203,6 +203,10 @@ Logic.registerPanel(P_CONTAINERS_LIST, { }); document.querySelector("#edit-containers-link").addEventListener("click", () => { + browser.runtime.sendMessage({ + method: "sendTelemetryPayload", + event: "edit-containers" + }); Logic.showPanel(P_CONTAINERS_EDIT); }); @@ -246,6 +250,7 @@ Logic.registerPanel(P_CONTAINERS_LIST, { return browser.runtime.sendMessage({ method: "openTab", userContextId: identity.userContextId, + source: "pop-up" }); }).then(() => { window.close();