From b3fe232e17cb8e10623efe215b28f2ff93b65061 Mon Sep 17 00:00:00 2001 From: Oli Evans Date: Mon, 25 Feb 2019 10:18:59 +0000 Subject: [PATCH] feat: add self-hosted, doNotTrack-aware, analytics (#930) - add countly analytics - honour doNotTrack. add tests - call-to-action where doNotTrack is enabled License: MIT Signed-off-by: Oli Evans --- package-lock.json | 5 + package.json | 1 + public/locales/en/settings.json | 18 +- public/locales/en/status.json | 5 + src/bundles/analytics.js | 163 ++++++++++++++++++ src/bundles/analytics.test.js | 102 +++++++++++ src/bundles/config-save.js | 2 +- src/bundles/index.js | 11 +- .../analytics-toggle/AnalyticsToggle.js | 38 ++++ src/components/ask/AskToEnable.js | 16 ++ src/components/checkbox/Checkbox.css | 8 +- src/components/checkbox/Checkbox.js | 11 +- .../language-modal/LanguageModal.js | 2 +- src/settings/SettingsPage.js | 18 +- src/status/StatusPage.js | 17 +- 15 files changed, 401 insertions(+), 16 deletions(-) create mode 100644 src/bundles/analytics.js create mode 100644 src/bundles/analytics.test.js create mode 100644 src/components/analytics-toggle/AnalyticsToggle.js create mode 100644 src/components/ask/AskToEnable.js diff --git a/package-lock.json b/package-lock.json index c2c1b53d0..87e9fb269 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4879,6 +4879,11 @@ } } }, + "countly-sdk-web": { + "version": "18.11.0", + "resolved": "https://registry.npmjs.org/countly-sdk-web/-/countly-sdk-web-18.11.0.tgz", + "integrity": "sha512-qSa0kUxAqLTIhSFRvk6bLgiEWaS6/UcPzPen/F9MF0PkVoww7z1F692m0zDgq37uHf9knih2SvJOx89f3auvCA==" + }, "create-ecdh": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", diff --git a/package.json b/package.json index ca5f1915b..7b4984a3c 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@tableflip/react-inspector": "^2.3.0", "brace": "^0.11.1", "chart.js": "^2.7.2", + "countly-sdk-web": "^18.11.0", "d3": "^5.7.0", "details-polyfill": "^1.1.0", "file-extension": "^4.0.5", diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 79cf200ba..efaef58b0 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -27,5 +27,21 @@ "autoAddScreenshots": "Auto add screenshots", "autoAddScreenshotsDescription": "Use <0>{ctrlKey} + <2>{altKey} + <3>S to take screenshots and add them to the repository.", "downloadCopiedHash": "Download copied hash", - "downloadCopiedHashDescription": "Use <0>{ctrlKey} + <2>{altKey} + <3>D to download the last copied hash." + "downloadCopiedHashDescription": "Use <0>{ctrlKey} + <2>{altKey} + <3>D to download the last copied hash.", + "AnalyticsToggle": { + "label": "Help improve this app by sending anonymous usage data", + "summary": "What data is collected?", + "paragraph1": "Protocol Labs hosts a <1>Countly instance to record anonymous usage data for this app.", + "paragraph2": "The information collected includes:", + "item0": "A random, generated device ID", + "item1": "Session duration", + "item2": "Country code & city from IP address. IP address is discarded", + "item3": "Operating system and version", + "item4": "Display resolution and density", + "item5": "Locale (browser language, e.g German)", + "item6": "Browser information", + "item7": "Which app sections are visited", + "item8": "App errors", + "paragraph3": "No CIDs, filenames, or other personal information are collected. We want metrics to show us which features are useful to help us prioritise what to work on next, and system configuration information to guide our testing." + } } diff --git a/public/locales/en/status.json b/public/locales/en/status.json index 70ac8046d..469e9c760 100644 --- a/public/locales/en/status.json +++ b/public/locales/en/status.json @@ -37,5 +37,10 @@ "header1": "Failed to connect to the API", "paragraph1": "<0>Start an IPFS daemon in a terminal:", "paragraph2": "<0>For more info on how to get started with IPFS you can <1>read the guide." + }, + "AskToEnable": { + "label": "Help improve this app by sending anonymous usage data", + "yesLabel": "Enable", + "noLabel": "No thanks" } } diff --git a/src/bundles/analytics.js b/src/bundles/analytics.js new file mode 100644 index 000000000..69632e734 --- /dev/null +++ b/src/bundles/analytics.js @@ -0,0 +1,163 @@ +import root from 'window-or-global' + +const IGNORE_ACTIONS = /^(FILES_FETCH_|FILES_WRITE_UPDATED)/ +const USER_ACTIONS = /^(CONFIG_SAVE_|FILES_|DESKTOP_)/ +const ASYNC_ACTIONS = /^(.+)_(STARTED|FINISHED|FAILED)$/ + +function getDoNotTrack () { + if (!root.navigator) return false + const value = root.doNotTrack || root.navigator.doNotTrack || root.navigator.msDoNotTrack + if (value === '1' || value === 'yes') { + return true + } + return false +} + +const createAnalyticsBundle = ({ + doNotTrack = getDoNotTrack(), + countlyUrl = 'https://countly.ipfs.io', + countlyAppKey, + appVersion, + appGitRevision, + debug = false +}) => { + return { + name: 'analytics', + + persistActions: ['ANALYTICS_ENABLED', 'ANALYTICS_DISABLED'], + + init: async (store) => { + if (!root.Countly) { + // lazy-load to simplify testing. + root.Countly = await import('countly-sdk-web') + } + const Countly = root.Countly + Countly.q = Countly.q || [] + + Countly.url = countlyUrl + Countly.app_key = countlyAppKey + Countly.app_version = appVersion + Countly.debug = debug + + // Don't track clicks as it can include full url. + // Countly.q.push(['track_clicks']); + // Countly.q.push(['track_links']) + Countly.q.push(['track_sessions']) + Countly.q.push(['track_pageview']) + Countly.q.push(['track_errors']) + + if (!store.selectAnalyticsEnabled()) { + Countly.q.push(['opt_out']) + Countly.ignore_visitor = true + } + + Countly.init() + + store.subscribeToSelectors(['selectRouteInfo'], ({ routeInfo }) => { + /* + By tracking the pattern rather than the window.location, we limit the info + we collect to just the app sections that are viewed, and avoid recording + specific CIDs or local repo paths that would contain personal information. + */ + if (root.Countly) { + root.Countly.q.push(['track_pageview', routeInfo.pattern]) + } + }) + }, + + // Record durations for user actions + getMiddleware: () => (store) => { + const EventMap = new Map() + return next => action => { + const res = next(action) + if (store.selectAnalyticsEnabled() && !IGNORE_ACTIONS.test(action.type) && USER_ACTIONS.test(action.type)) { + if (ASYNC_ACTIONS.test(action.type)) { + const [_, name, state] = ASYNC_ACTIONS.exec(action.type) // eslint-disable-line no-unused-vars + if (state === 'STARTED') { + EventMap.set(name, root.performance.now()) + } else { + const start = EventMap.get(name) + if (!start) { + EventMap.delete(name) + return + } + const durationInSeconds = (root.performance.now() - start) / 1000 + console.log('ASYNC', name, durationInSeconds) + root.Countly.q.push(['add_event', { + key: state === 'FAILED' ? action.type : name, + count: 1, + dur: durationInSeconds + }]) + } + } else { + root.Countly.q.push(['add_event', { + key: action.type, + count: 1 + }]) + } + } + return res + } + }, + + reducer: (state = { doNotTrack, lastEnabledAt: 0, lastDisabledAt: 0 }, action) => { + if (action.type === 'ANALYTICS_ENABLED') { + return { ...state, lastEnabledAt: Date.now() } + } + if (action.type === 'ANALYTICS_DISABLED') { + return { ...state, lastDisabledAt: Date.now() } + } + return state + }, + + selectAnalytics: (state) => state.analytics, + + /* + Use the users preference or their global doNotTrack setting. + */ + selectAnalyticsEnabled: (state) => { + const { lastEnabledAt, lastDisabledAt, doNotTrack } = state.analytics + // where never opted in or out, use their global tracking preference + if (!lastEnabledAt && !lastDisabledAt) { + return !doNotTrack + } + // otherwise return their most recent choice. + return lastEnabledAt > lastDisabledAt + }, + + /* + Ask the user if we may enable analytics if they have doNotTrack set + */ + selectAnalyticsAskToEnable: (state) => { + const { lastEnabledAt, lastDisabledAt, doNotTrack } = state.analytics + // user has not explicity chosen + if (!lastEnabledAt && !lastDisabledAt) { + // ask to enable if doNotTrack is true. + return doNotTrack + } + // user has already made an explicit choice; dont ask again. + return false + }, + + doToggleAnalytics: () => async ({ dispatch, store }) => { + const enable = !store.selectAnalyticsEnabled() + if (enable) { + store.doEnableAnalytics() + } else { + store.doDisableAnalytics() + } + }, + + doDisableAnalytics: () => async ({ dispatch, store }) => { + root.Countly.opt_out() + dispatch({ type: 'ANALYTICS_DISABLED' }) + }, + + doEnableAnalytics: () => async ({ dispatch, store }) => { + root.Countly.opt_in() + dispatch({ type: 'ANALYTICS_ENABLED' }) + } + } +} + +export default createAnalyticsBundle diff --git a/src/bundles/analytics.test.js b/src/bundles/analytics.test.js new file mode 100644 index 000000000..366c81b8e --- /dev/null +++ b/src/bundles/analytics.test.js @@ -0,0 +1,102 @@ +/* global it, expect, beforeEach, afterEach, jest */ +import { composeBundlesRaw } from 'redux-bundler' +import createAnalyticsBundle from './analytics' +import sleep from '../../test/helpers/sleep' + +beforeEach(() => { + global.Countly = { + opt_out: jest.fn(), + opt_in: jest.fn(), + init: jest.fn() + } +}) + +afterEach(() => { + delete global.Countly + if (global.navigator && global.navigator.hasOwnProperty('doNotTrack')) { + delete global.navigator.doNotTrack + } +}) + +function createStore (analyticsOpts = {}) { + return composeBundlesRaw( + { + name: 'mockRoutesBundle', + selectRouteInfo: () => ({}) + }, + createAnalyticsBundle(analyticsOpts) + )() +} + +it('should normalise the doNotTrack state from the navigator.doNotTrack value', () => { + let store = createStore() + // false if not set. + expect(store.selectAnalytics().doNotTrack).toBe(false) + global.navigator = { doNotTrack: '1' } + store = createStore() + expect(store.selectAnalytics().doNotTrack).toBe(true) + + global.navigator.doNotTrack = '0' + store = createStore() + expect(store.selectAnalytics().doNotTrack).toBe(false) +}) + +it('should enable analytics if doNotTrack is falsey', () => { + const store = createStore() + expect(store.selectAnalyticsEnabled()).toBe(true) +}) + +it('should disable analytics if doNotTrack is true', () => { + const store = createStore({ doNotTrack: true }) + expect(store.selectAnalyticsEnabled()).toBe(false) +}) + +it('should enable analytics if doNotTrack is true but user has explicitly enabled it', () => { + const store = createStore({ doNotTrack: true }) + store.doEnableAnalytics() + expect(store.selectAnalyticsEnabled()).toBe(true) +}) + +it('should disable analytics if doNotTrack is falsey but user has explicitly disabled it', () => { + const store = createStore({ doNotTrack: false }) + store.doDisableAnalytics() + expect(store.selectAnalyticsEnabled()).toBe(false) +}) + +it('should enable selectAnalyticsAskToEnable if doNotTrack is true and user has not explicity enabled or disabled it', () => { + const store = createStore({ doNotTrack: true }) + expect(store.selectAnalyticsAskToEnable()).toBe(true) +}) + +it('should disable selectAnalyticsAskToEnable if doNotTrack is true and user has explicity disabled it', () => { + const store = createStore({ doNotTrack: true }) + store.doDisableAnalytics() + expect(store.selectAnalyticsAskToEnable()).toBe(false) +}) + +it('should disable selectAnalyticsAskToEnable if doNotTrack is true and user has explicity enabled it', () => { + const store = createStore({ doNotTrack: true }) + store.doEnableAnalytics() + expect(store.selectAnalyticsAskToEnable()).toBe(false) +}) + +it('should disable selectAnalyticsAskToEnable if analytics are enabled', () => { + const store = createStore({ doNotTrack: false }) + expect(store.selectAnalyticsAskToEnable()).toBe(false) +}) + +it('should toggle analytics', async (done) => { + const store = createStore({ doNotTrack: false }) + expect(store.selectAnalyticsEnabled()).toBe(true) + + store.doToggleAnalytics() + expect(store.selectAnalyticsEnabled()).toBe(false) + + // we calc enabled state from time diff between lastEnabledAt and lastDisabledAt, so need a pause + await sleep() + + store.doToggleAnalytics() + expect(store.selectAnalyticsEnabled()).toBe(true) + + done() +}) diff --git a/src/bundles/config-save.js b/src/bundles/config-save.js index 736301e5e..33cc76df7 100644 --- a/src/bundles/config-save.js +++ b/src/bundles/config-save.js @@ -29,7 +29,7 @@ const bundle = { const obj = JSON.parse(configStr) await getIpfs().config.replace(obj) } catch (err) { - return dispatch({ type: 'CONFIG_SAVE_ERRORED', error: err }) + return dispatch({ type: 'CONFIG_SAVE_FAILED', error: err }) } await store.doMarkConfigAsOutdated() dispatch({ type: 'CONFIG_SAVE_FINISHED' }) diff --git a/src/bundles/index.js b/src/bundles/index.js index 9165e1cb8..9d5e24eb0 100644 --- a/src/bundles/index.js +++ b/src/bundles/index.js @@ -20,6 +20,10 @@ import identityBundle from './identity' import bundleCache from '../lib/bundle-cache' import ipfsDesktop from './ipfs-desktop' import repoStats from './repo-stats' +import createAnalyticsBundle from './analytics' + +const COUNTLY_KEY_WEBUI = '8fa213e6049bff23b08e5f5fbac89e7c27397612' +const COUNTLY_KEY_DESKTOP = '47fbb3db3426d2ae32b3b65fe40c564063d8b55d' export default composeBundles( createCacheBundle(bundleCache.set), @@ -69,5 +73,10 @@ export default composeBundles( connectedBundle, retryInitBundle, ipfsDesktop, - repoStats + repoStats, + createAnalyticsBundle({ + countlyAppKey: window.ipfsDesktop ? COUNTLY_KEY_DESKTOP : COUNTLY_KEY_WEBUI, + appVersion: process.env.REACT_APP_VERSION, + appGitRevision: process.env.REACT_APP_GIT_REV + }) ) diff --git a/src/components/analytics-toggle/AnalyticsToggle.js b/src/components/analytics-toggle/AnalyticsToggle.js new file mode 100644 index 000000000..f2bfa4665 --- /dev/null +++ b/src/components/analytics-toggle/AnalyticsToggle.js @@ -0,0 +1,38 @@ +import React from 'react' +import { Trans } from 'react-i18next' +import Checkbox from '../checkbox/Checkbox' +import Details from '../details/Details' + +const AnalyticsToggle = ({ doToggleAnalytics, analyticsEnabled, t }) => { + // Simplify fetching a list of i18n keys. + const items = Array(9).fill(1) + return ( + + + {t('AnalyticsToggle.label')} + + } /> +
+
+

+ + IPFS hosts a Countly instance to record anonymous usage data for this app. + +

+

{t('AnalyticsToggle.paragraph2')}

+
    + { items.map((_, i) => ( +
  • + {t(`AnalyticsToggle.item${i}`)} +
  • + ))} +
+

{t(`AnalyticsToggle.paragraph3`)}

+
+
+
+ ) +} + +export default AnalyticsToggle diff --git a/src/components/ask/AskToEnable.js b/src/components/ask/AskToEnable.js new file mode 100644 index 000000000..654c50bce --- /dev/null +++ b/src/components/ask/AskToEnable.js @@ -0,0 +1,16 @@ +import React from 'react' +import Button from '../button/Button' + +const AskToEnable = ({ className, label, yesLabel, noLabel, onYes, onNo }) => { + return ( +
+ {label} + + + + +
+ ) +} + +export default AskToEnable diff --git a/src/components/checkbox/Checkbox.css b/src/components/checkbox/Checkbox.css index 127930516..7ed5667b2 100644 --- a/src/components/checkbox/Checkbox.css +++ b/src/components/checkbox/Checkbox.css @@ -1,16 +1,16 @@ -.Checkbox span:first-of-type { +.Checkbox > span:first-of-type { background-color: #DDE6EB; } -.Checkbox input { +.Checkbox > input { left: -99999px; } -.Checkbox input:checked ~ span:first-of-type svg{ +.Checkbox > input:checked ~ span:first-of-type svg { opacity: 1; } -.Checkbox input:disabled ~ span:first-of-type { +.Checkbox > input:disabled ~ span:first-of-type { cursor: not-allowed; opacity: 0.5; } diff --git a/src/components/checkbox/Checkbox.js b/src/components/checkbox/Checkbox.js index 017935aa5..3694a58fd 100644 --- a/src/components/checkbox/Checkbox.js +++ b/src/components/checkbox/Checkbox.js @@ -5,6 +5,9 @@ import './Checkbox.css' const Checkbox = ({ className, label, disabled, checked, onChange, ...props }) => { className = `Checkbox dib sans-serif ${className}` + if (!disabled) { + className += ' pointer' + } const change = (event) => { onChange(event.target.checked) @@ -13,17 +16,19 @@ const Checkbox = ({ className, label, disabled, checked, onChange, ...props }) = return ( ) } Checkbox.propTypes = { className: PropTypes.string, - label: PropTypes.string, + label: PropTypes.node, disabled: PropTypes.bool, checked: PropTypes.bool, onChange: PropTypes.func diff --git a/src/components/language-selector/language-modal/LanguageModal.js b/src/components/language-selector/language-modal/LanguageModal.js index a0dc1ad25..4b7d34fa2 100644 --- a/src/components/language-selector/language-modal/LanguageModal.js +++ b/src/components/language-selector/language-modal/LanguageModal.js @@ -40,7 +40,7 @@ const LanguageModal = ({ t, tReady, onLeave, link, className, ...props }) => { LanguageModal.propTypes = { onLeave: PropTypes.func.isRequired, t: PropTypes.func.isRequired, - tReady: PropTypes.bool.isRequired + tReady: PropTypes.bool } LanguageModal.defaultProps = { diff --git a/src/settings/SettingsPage.js b/src/settings/SettingsPage.js index 46c2cedc6..301d0832e 100644 --- a/src/settings/SettingsPage.js +++ b/src/settings/SettingsPage.js @@ -8,6 +8,7 @@ import Tick from '../icons/GlyphSmallTick' import Box from '../components/box/Box' import Button from '../components/button/Button' import LanguageSelector from '../components/language-selector/LanguageSelector' +import AnalyticsToggle from '../components/analytics-toggle/AnalyticsToggle' import JsonEditor from './editor/JsonEditor' import DesktopSettings from './DesktopSettings' import Title from './Title' @@ -18,7 +19,7 @@ export const SettingsPage = ({ t, tReady, isConfigBlocked, isLoading, isSaving, hasSaveFailed, hasSaveSucceded, hasErrors, hasLocalChanges, hasExternalChanges, isIpfsDesktop, - config, onChange, onReset, onSave, editorKey + config, onChange, onReset, onSave, editorKey, analyticsEnabled, doToggleAnalytics }) => (
@@ -32,6 +33,11 @@ export const SettingsPage = ({ {t('language')} + +
+ Analytics + +
@@ -141,7 +147,7 @@ const SettingsInfo = ({ t, isConfigBlocked, hasExternalChanges, hasSaveFailed, h ) } return ( -

+

{t('ipfsConfigDescription')}

) @@ -217,7 +223,7 @@ export class SettingsPageContainer extends React.Component { } render () { - const { t, tReady, isConfigBlocked, configIsLoading, configLastError, configIsSaving, configSaveLastSuccess, configSaveLastError, isIpfsDesktop } = this.props + const { t, tReady, isConfigBlocked, configIsLoading, configLastError, configIsSaving, configSaveLastSuccess, configSaveLastError, isIpfsDesktop, analyticsEnabled, doToggleAnalytics } = this.props const { hasErrors, hasLocalChanges, hasExternalChanges, editableConfig, editorKey } = this.state const hasSaveSucceded = this.isRecent(configSaveLastSuccess) const hasSaveFailed = this.isRecent(configSaveLastError) @@ -239,7 +245,9 @@ export class SettingsPageContainer extends React.Component { onChange={this.onChange} onReset={this.onReset} onSave={this.onSave} - isIpfsDesktop={isIpfsDesktop} /> + isIpfsDesktop={isIpfsDesktop} + analyticsEnabled={analyticsEnabled} + doToggleAnalytics={doToggleAnalytics} /> ) } } @@ -255,6 +263,8 @@ export default connect( 'selectConfigSaveLastSuccess', 'selectConfigSaveLastError', 'selectIsIpfsDesktop', + 'selectAnalyticsEnabled', + 'doToggleAnalytics', 'doSaveConfig', TranslatedSettingsPage ) diff --git a/src/status/StatusPage.js b/src/status/StatusPage.js index ada545786..1c1cbb74d 100644 --- a/src/status/StatusPage.js +++ b/src/status/StatusPage.js @@ -9,8 +9,9 @@ import NodeInfoAdvanced from './NodeInfoAdvanced' import NodeBandwidthChart from './NodeBandwidthChart' import NetworkTraffic from './NetworkTraffic' import Box from '../components/box/Box' +import AskToEnable from '../components/ask/AskToEnable' -const StatusPage = ({ t, ipfsConnected }) => { +const StatusPage = ({ t, ipfsConnected, analyticsAskToEnable, doEnableAnalytics, doDisableAnalytics }) => { return (
@@ -33,6 +34,17 @@ const StatusPage = ({ t, ipfsConnected }) => {
+ { ipfsConnected && analyticsAskToEnable + ? + : null + }
@@ -49,5 +61,8 @@ const StatusPage = ({ t, ipfsConnected }) => { export default connect( 'selectIpfsConnected', + 'selectAnalyticsAskToEnable', + 'doEnableAnalytics', + 'doDisableAnalytics', translate('status')(StatusPage) )