-
Notifications
You must be signed in to change notification settings - Fork 489
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
Changes from all commits
ca74271
f9871ec
eede7a7
186a508
c7f5a37
9d6158e
5282741
dd9c2d5
0e8be7e
ae87e10
60a021b
75f0844
6cbbba4
669df2f
a0a2d36
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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 | ||
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 |
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() | ||
}) |
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={ | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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'> | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
<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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❤️