-
Notifications
You must be signed in to change notification settings - Fork 498
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <oli@tableflip.io>
- Loading branch information
Showing
15 changed files
with
401 additions
and
16 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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={ | ||
<span className='fw5 f6'> | ||
{t('AnalyticsToggle.label')} | ||
</span> | ||
} /> | ||
<div className='f6 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 |
Oops, something went wrong.