Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add self-hosted, doNotTrack-aware, analytics #930

Merged
merged 15 commits into from
Feb 25, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 17 additions & 1 deletion public/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,21 @@
"autoAddScreenshots": "Auto add screenshots",
"autoAddScreenshotsDescription": "Use <0>{ctrlKey}</0> + <2>{altKey}</2> + <3>S</3> to take screenshots and add them to the repository.",
"downloadCopiedHash": "Download copied hash",
"downloadCopiedHashDescription": "Use <0>{ctrlKey}</0> + <2>{altKey}</2> + <3>D</3> to download the last copied hash."
"downloadCopiedHashDescription": "Use <0>{ctrlKey}</0> + <2>{altKey}</2> + <3>D</3> 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</1> 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."
}
}
5 changes: 5 additions & 0 deletions public/locales/en/status.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,10 @@
"header1": "Failed to connect to the API",
"paragraph1": "<0>Start an IPFS daemon in a terminal:</0>",
"paragraph2": "<0>For more info on how to get started with IPFS you can <1>read the guide</1>.</0>"
},
"AskToEnable": {
"label": "Help improve this app by sending anonymous usage data",
"yesLabel": "Enable",
"noLabel": "No thanks"
}
}
163 changes: 163 additions & 0 deletions src/bundles/analytics.js
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

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
fsdiogo marked this conversation as resolved.
Show resolved Hide resolved
}
// 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
102 changes: 102 additions & 0 deletions src/bundles/analytics.test.js
Original file line number Diff line number Diff line change
@@ -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()
})
2 changes: 1 addition & 1 deletion src/bundles/config-save.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
11 changes: 10 additions & 1 deletion src/bundles/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
})
)
38 changes: 38 additions & 0 deletions src/components/analytics-toggle/AnalyticsToggle.js
Original file line number Diff line number Diff line change
@@ -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 (
<React.Fragment>
<Checkbox className='dib bg-white pa3' onChange={doToggleAnalytics} checked={analyticsEnabled} label={
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<Checkbox className='dib bg-white pa3' onChange={doToggleAnalytics} checked={analyticsEnabled} label={
<Checkbox className='dib pa3' onChange={doToggleAnalytics} checked={analyticsEnabled} label={

I prefer this without the white background to blend with the rest of the Settings, but I'll leave it up to you!

<span className='fw5 f6'>
{t('AnalyticsToggle.label')}
</span>
} />
<div className='f6 charcoal lh-copy mw7'>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<div className='f6 charcoal lh-copy mw7'>
<div className='f6 ml3 charcoal lh-copy mw7'>

<Details summaryText={t('AnalyticsToggle.summary')} className='pt3'>
<p>
<Trans i18nKey='AnalyticsToggle.paragraph1'>
IPFS hosts a <a className='link blue' href='https://count.ly/'>Countly</a> instance to record anonymous usage data for this app.
</Trans>
</p>
<p>{t('AnalyticsToggle.paragraph2')}</p>
<ul>
{ items.map((_, i) => (
<li key={`analytics-item-${i}`}>
{t(`AnalyticsToggle.item${i}`)}
</li>
))}
</ul>
<p>{t(`AnalyticsToggle.paragraph3`)}</p>
</Details>
</div>
</React.Fragment>
)
}

export default AnalyticsToggle
Loading