diff --git a/.gitignore b/.gitignore index 64fd84b..f363242 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ node_modules *.zip *.sw? +npm-debug.log diff --git a/docs/metrics.md b/docs/metrics.md new file mode 100644 index 0000000..4be5ae6 --- /dev/null +++ b/docs/metrics.md @@ -0,0 +1,122 @@ +# METRICS + +## Data Analysis + +The collected data will primarily be used to answer the following questions. +Images are used for visualization and are not composed of actual data. + +### Do people use this? + +What is the overall engagement of Snooze Tabs? **This is the standard Daily +Active User (DAU) and Monthly Active User (MAU) analysis.** This captures data +from the people who have the add-on installed, regardless of whether they are +actively interacting with it. + +### Immediate Questions + +TBD + +### Follow-up Questions + +TBD + +## Data Collection + +### Server Side +There is currently no server side component to Snooze Tabs. + +### Client Side +Snooze Tabs will use Test Pilot's Telemetry wrapper with no batching of data. +Details of when pings are sent are below, along with examples of the `payload` +portion of a `testpilottest` telemetry ping for each scenario. + +* The user opens the snooze panel +```js +{ "event": "panel-opened" } +``` + +* The user snoozes a tab, choosing either a pre-defined time or a custom time +```js +{ + "event": "snoozed", + "snooze_time": 1484345836165, + "snooze_time_type": "tomorrow" +} +``` + +* A snoozed tab wakes up as a live tab +```js +{ + "event": "woken", + "snooze_time": 1484345836165, + "snooze_time_type": "tomorrow" +} +``` + +* A previously snoozed tab is focused +```js +{ + "event": "focused", + "snooze_time": 1484345836165, + "snooze_time_type": "tomorrow" +} +``` + +* A previously snoozed tab is closed without having been focused +```js +{ + "event": "closed-unfocused", + "snooze_time": 1484345836165, + "snooze_time_type": "tomorrow" +} +``` + +* A previously snoozed tab is snoozed again after waking, but not tracked after browser restart or if the tab was closed and reopened +```js +{ + "event": "resnoozed", + "snooze_time": 1484345836165, + "snooze_time_type": "tomorrow" +} +``` + +* A snoozed tab is cancelled from the management panel +```js +{ + "event": "cancelled", + "snooze_time": 1484345836165, + "snooze_time_type": "tomorrow" +} +``` + +* A snoozed tab is updated from the management panel +```js +{ + "event": "updated", + "snooze_time": 1484345836165, + "snooze_time_type": "tomorrow" +} +``` + +* A snoozed tab is clicked from the management panel +```js +{ + "event": "clicked", + "snooze_time": 1484345836165, + "snooze_time_type": "tomorrow" +} +``` + +A Redshift schema for the payload: + +```lua +local schema = { +-- column name field type length attributes field name + {"event", "VARCHAR", 255, nil, "Fields[payload.event]"}, + {"snooze_time", "INTEGER", nil, nil, "Fields[payload.snooze_time]"}, + {"snooze_time_type", "VARCHAR", 255, nil, "Fields[payload.snooze_time_type]"}, +} +``` + +All Mozilla data is kept by default for 180 days and in accordance with our +privacy policies. diff --git a/package.json b/package.json index b8cb3c0..f8a06c6 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ }, "scripts": { "start": "npm run build && npm-run-all --parallel test watch run storybook", - "start-win": "npm run build && npm run watch", + "start-win": "npm run build && npm-run-all --parallel test watch", "build": "npm run clean && npm-run-all --parallel copy:assets bundle:*", "run": "web-ext run -s dist --firefox=nightly", "clean": "rm -rf dist && shx mkdir -p dist/popup", @@ -73,7 +73,7 @@ "storybook": "start-storybook -s ./dist -p 6006", "build-storybook": "build-storybook", "test": "npm run lint && npm run test:js", - "test:js": "mocha --compilers js:babel-register --require test/.setup.js --recursive" + "test:js": "mocha --compilers js:babel-register --recursive" }, "dependencies": { "classnames": "^2.2.5", diff --git a/src/background.js b/src/background.js index de4c9b8..e69272c 100644 --- a/src/background.js +++ b/src/background.js @@ -8,6 +8,7 @@ import moment from 'moment'; import { NEXT_OPEN, PICK_TIME, times, timeForId } from './lib/times'; +import Metrics from './lib/metrics'; const DEBUG = (process.env.NODE_ENV === 'development'); const WAKE_ALARM_NAME = 'snooze-wake-alarm'; @@ -43,6 +44,7 @@ function updateButtonForTab(tabId, changeInfo) { function init() { log('init()'); + Metrics.init(window.BroadcastChannel, browser.tabs); browser.alarms.onAlarm.addListener(handleWake); browser.notifications.onClicked.addListener(handleNotificationClick); browser.runtime.onMessage.addListener(handleMessage); @@ -91,14 +93,25 @@ const idForItem = item => `${item.time}-${item.url}`; const messageOps = { schedule: message => { + Metrics.scheduleSnoozedTab(message); const toSave = {}; toSave[idForItem(message)] = message; return browser.storage.local.set(toSave).then(updateWakeAndBookmarks); }, - cancel: message => - browser.storage.local.remove(idForItem(message)).then(updateWakeAndBookmarks), - update: message => - messageOps.cancel(message.old).then(() => messageOps.schedule(message.updated)) + cancel: message => { + Metrics.cancelSnoozedTab(message); + return browser.storage.local.remove(idForItem(message)).then(updateWakeAndBookmarks); + }, + update: message => { + Metrics.updateSnoozedTab(message); + return messageOps.cancel(message.old).then(() => messageOps.schedule(message.updated)); + }, + click: message => { + Metrics.clickSnoozedTab(message); + }, + panelOpened: () => { + Metrics.panelOpened(); + } }; function syncBookmarks(items) { @@ -175,6 +188,7 @@ function handleWake() { windowId: windowIds.includes(item.windowId) ? item.windowId : undefined }; return browser.tabs.create(createProps).then(tab => { + Metrics.tabWoken(item, tab); browser.tabs.executeScript(tab.id, { 'code': ` function flip(newUrl) { diff --git a/src/lib/components/MainPanel.js b/src/lib/components/MainPanel.js index e1a450a..2f489d1 100644 --- a/src/lib/components/MainPanel.js +++ b/src/lib/components/MainPanel.js @@ -57,7 +57,7 @@ export default class MainPanel extends React.Component { return; } const [time, ] = timeForId(moment(), item.id); - scheduleSnoozedTab(time); + scheduleSnoozedTab(time, item.id); } closeTimeSelect() { @@ -67,6 +67,6 @@ export default class MainPanel extends React.Component { confirmTimeSelect(dateChoice) { const { scheduleSnoozedTab } = this.props; if (!dateChoice) { return; } - scheduleSnoozedTab(dateChoice); + scheduleSnoozedTab(dateChoice, PICK_TIME); } } diff --git a/src/lib/metrics.js b/src/lib/metrics.js index f325e97..ac1843d 100644 --- a/src/lib/metrics.js +++ b/src/lib/metrics.js @@ -1,4 +1,103 @@ +// Track recent tabs woken to see whether they're later closed or focused +let unseenWakeHistory = {}; +let seenWakeHistory = {}; +// BroadcastChannel for sending metrics pings to Test Pilot add-on +let pingChannel = null; + +// Name of the metrics BroadcastChannel expected by the Test Pilot add-on +const TESTPILOT_TELEMETRY_CHANNEL = 'testpilot-telemetry'; + +const DEBUG = (process.env.NODE_ENV === 'development'); + +function log(...args) { + if (DEBUG) { console.log('SnoozeTabs (Metrics):', ...args); } // eslint-disable-line no-console +} export default { + init(BroadcastChannel, tabs) { + unseenWakeHistory = {}; + seenWakeHistory = {}; + pingChannel = new BroadcastChannel(TESTPILOT_TELEMETRY_CHANNEL); + tabs.onActivated.addListener(this.handleTabActivated.bind(this)); + tabs.onRemoved.addListener(this.handleTabRemoved.bind(this)); + log('init()'); + }, + + postMessage(message) { + log('postMessage', message); + return (!pingChannel) ? null : pingChannel.postMessage(message); + }, + + handleTabActivated(activeInfo) { + const tabId = activeInfo.tabId; + if (!(tabId in unseenWakeHistory)) { return; } + + const item = unseenWakeHistory[tabId]; + delete unseenWakeHistory[tabId]; + seenWakeHistory[tabId] = item; + + this.wokenTabFocused(item); + }, + + handleTabRemoved(tabId/*, removeInfo */) { + if (tabId in seenWakeHistory) { + delete seenWakeHistory[tabId]; + } + + if (!(tabId in unseenWakeHistory)) { return; } + + const item = unseenWakeHistory[tabId]; + delete unseenWakeHistory[tabId]; + + this.wokenTabClosed(item); + }, + + panelOpened() { + this.postMessage({ event: 'panel-opened' }); + }, + + _commonTabPostMessage(event, item) { + this.postMessage({ + event, + snooze_time: item.time, + snooze_time_type: item.timeType + }); + }, + + clickSnoozedTab(item) { + this._commonTabPostMessage('clicked', item); + }, + + cancelSnoozedTab(item) { + this._commonTabPostMessage('cancelled', item); + }, + + scheduleSnoozedTab(item) { + const tabId = item.tabId; + if (tabId in seenWakeHistory || tabId in unseenWakeHistory) { + delete seenWakeHistory[tabId]; + delete unseenWakeHistory[tabId]; + this._commonTabPostMessage('resnoozed', item); + } else { + this._commonTabPostMessage('snoozed', item); + } + }, + + updateSnoozedTab(item) { + this._commonTabPostMessage('updated', item); + }, + + tabWoken(item, tab) { + unseenWakeHistory[tab.id] = item; + this._commonTabPostMessage('woken', item); + }, + + wokenTabFocused(item) { + this._commonTabPostMessage('focused', item); + }, + + wokenTabClosed(item) { + this._commonTabPostMessage('closed-unfocused', item); + } }; diff --git a/src/popup/snooze-content.js b/src/popup/snooze-content.js index e78d71b..2f5dc00 100644 --- a/src/popup/snooze-content.js +++ b/src/popup/snooze-content.js @@ -29,7 +29,7 @@ function log(...args) { if (DEBUG) { console.log('SnoozeTabs (FE):', ...args); } // eslint-disable-line no-console } -function scheduleSnoozedTab(time) { +function scheduleSnoozedTab(time, timeType) { browser.tabs.query({currentWindow: true}).then(tabs => { let addBlank = true; const closers = []; @@ -45,8 +45,10 @@ function scheduleSnoozedTab(time) { op: 'schedule', message: { 'time': time.valueOf(), + 'timeType': timeType, 'title': tab.title || 'Tab woke up…', 'url': tab.url, + 'tabId': tab.id, 'windowId': tab.windowId, 'icon': tab.favIconUrl } @@ -74,6 +76,10 @@ function openSnoozedTab(item) { active: true, url: item.url }); + browser.runtime.sendMessage({ + op: 'click', + message: item + }); } function cancelSnoozedTab(item) { @@ -140,6 +146,7 @@ function init() { }); fetchEntries(); render(); + browser.runtime.sendMessage({ op: 'panelOpened' }); } init(); diff --git a/test/.setup.js b/test/.setup.js deleted file mode 100644 index d7ee8b7..0000000 --- a/test/.setup.js +++ /dev/null @@ -1,24 +0,0 @@ -// Global setup for all tests - -// We need jsdom for enzyme mount()'ed components -// see also: https://github.com/airbnb/enzyme/blob/master/docs/guides/jsdom.md#using-enzyme-with-jsdom -// see also: https://github.com/lelandrichardson/enzyme-example-mocha/blob/master/test/.setup.js - -/* -var jsdom = require('jsdom').jsdom; - -var exposedProperties = ['window', 'navigator', 'document']; - -global.document = jsdom(''); -global.window = document.defaultView; -Object.keys(document.defaultView).forEach((property) => { - if (typeof global[property] === 'undefined') { - exposedProperties.push(property); - global[property] = document.defaultView[property]; - } -}); - -global.navigator = { - userAgent: 'node.js' -}; -*/ diff --git a/test/lib/metrics-test.js b/test/lib/metrics-test.js index 0300dff..bfba292 100644 --- a/test/lib/metrics-test.js +++ b/test/lib/metrics-test.js @@ -1,10 +1,148 @@ +/* global describe, beforeEach, it */ + import { expect } from 'chai'; import sinon from 'sinon'; +import { PICK_TIME } from '../../src/lib/times'; import Metrics from '../../src/lib/metrics'; +function mockBroadcastChannel(onNew) { + return function(name) { + this.name = name; + this.postMessage = sinon.spy(); + onNew(this); + return this; + }; +} + describe('Metrics', () => { - it('should exist', () => { - expect(true).to.be.true; + let item; + let channel; + let browser; + + const BroadcastChannel = mockBroadcastChannel(obj => channel = obj); + + const assertTabMessagePosted = (event, item) => { + expect(channel.postMessage.called).to.be.true; + const msg = channel.postMessage.lastCall.args[0]; + expect(msg).to.deep.equal({ + event, + snooze_time: item.time, + snooze_time_type: item.timeType + }); + }; + + beforeEach(() => { + item = { + time: 8675309, + timeType: PICK_TIME, + url: 'https://example.com/bar' + }; + channel = null; + browser = { + tabs: { + onActivated: { addListener: sinon.spy() }, + onRemoved: { addListener: sinon.spy() } + } + }; + Metrics.init(BroadcastChannel, browser.tabs); + }); + + it('should initialize a BroadcastChannel for testpilot-telemetry', () => { + expect(channel).to.exist; + expect(channel.name).to.equal('testpilot-telemetry'); + expect(browser.tabs.onActivated.addListener.called).to.be.true; + expect(browser.tabs.onRemoved.addListener.called).to.be.true; + }); + + it('should measure each time the snooze panel is opened', () => { + Metrics.panelOpened(); + const msg = channel.postMessage.lastCall.args[0]; + expect(msg).to.deep.equal({ event: 'panel-opened' }); + }); + + it('should measure each time a user chooses to snooze', () => { + Metrics.scheduleSnoozedTab(item); + assertTabMessagePosted('snoozed', item); + }); + + it('should measure snooze cancellations', () => { + Metrics.cancelSnoozedTab(item); + assertTabMessagePosted('cancelled', item); + }); + + it('should measure snooze updates', () => { + Metrics.updateSnoozedTab(item); + assertTabMessagePosted('updated', item); + }); + + it('should measure clicks on currently snoozed tabs', () => { + Metrics.clickSnoozedTab(item); + assertTabMessagePosted('clicked', item); + }); + + it('should measure the rate at which snooze tabs wake up', () => { + const tab = { + id: 123, + url: item.url + }; + Metrics.tabWoken(item, tab); + assertTabMessagePosted('woken', item); + }); + + it('should measure if users focus previously snoozed tabs', () => { + const tab = { + id: 123, + url: item.url + }; + Metrics.tabWoken(item, tab); + expect(channel.postMessage.callCount).to.equal(1); + + const handleTabActivated = browser.tabs.onActivated.addListener.lastCall.args[0]; + + // Unrecognized tab ID shouldn't fire a new metrics event. + handleTabActivated({ tabId: 456, windowId: 454 }); + expect(channel.postMessage.callCount).to.equal(1); + + // But, the ID of a previously woken tab should fire a new event! + handleTabActivated({ tabId: tab.id, windowId: 234 }); + expect(channel.postMessage.callCount).to.equal(2); + + assertTabMessagePosted('focused', item); + }); + + it('should measure if users close a previously snoozed tab without refocusing', () => { + const tab = { + id: 123, + url: item.url + }; + Metrics.tabWoken(item, tab); + expect(channel.postMessage.callCount).to.equal(1); + + const handleTabRemoved = browser.tabs.onRemoved.addListener.lastCall.args[0]; + + // Unrecognized tab ID shouldn't fire a new metrics event. + handleTabRemoved(456); + expect(channel.postMessage.callCount).to.equal(1); + + // But, the ID of a previously woken tab should fire a new event! + handleTabRemoved(tab.id); + expect(channel.postMessage.callCount).to.equal(2); + + assertTabMessagePosted('closed-unfocused', item); + }); + + it('should measure if users re-snooze a tab', () => { + const tab = { + id: 123, + url: item.url + }; + item.tabId = tab.id; + + Metrics.tabWoken(item, tab); + expect(channel.postMessage.callCount).to.equal(1); + + Metrics.scheduleSnoozedTab(item); + assertTabMessagePosted('resnoozed', item); }); });