Merge pull request #163 from mozilla/testpilot-metrics-117

for #117: start telemetry with testpilot-metrics
This commit is contained in:
Andrea Marchesini 2017-02-15 17:37:56 +01:00 committed by GitHub
commit fe1decef4c
6 changed files with 481 additions and 8 deletions

1
.eslintignore Normal file
View file

@ -0,0 +1 @@
testpilot-metrics.js

View file

@ -114,7 +114,6 @@ of a `testpilottest` telemetry ping for each scenario.
```js
{
"uuid": <uuid>,
"userContextId": <userContextId>,
"event": "add-container"
}
```

146
index.js
View file

@ -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");
},

View file

@ -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"

333
testpilot-metrics.js Normal file
View file

@ -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

View file

@ -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();