Skip to content

Commit 42eed02

Browse files
SgtPookiwhizzzkidlidel
authored
feat: add telemetry to companion (#1117)
* feat: add types for state * tmp * feat: ipfs-companion tracks views and sessions * Update add-on/src/lib/telemetry.js Co-authored-by: Nishant Arora <[email protected]> * Update add-on/src/lib/telemetry.js Co-authored-by: Nishant Arora <[email protected]> * Update add-on/src/lib/telemetry.js Co-authored-by: Nishant Arora <[email protected]> * Update add-on/_locales/en/messages.json Co-authored-by: Marcin Rataj <[email protected]> * Update add-on/_locales/en/messages.json Co-authored-by: Marcin Rataj <[email protected]> * Update add-on/_locales/en/messages.json Co-authored-by: Marcin Rataj <[email protected]> * Update add-on/_locales/en/messages.json Co-authored-by: Marcin Rataj <[email protected]> * chore: fix options and state typings * chore: use debug logger * fix(lint): remove unused method * fix(lint): run 'npm run fix:lint' * chore: build and lint success * chore(types): fix type errors * chore: add docs/telemetry/COLLECTED_DATA.md see ipfs-shipyard/ignite-metrics#35 * chore: update old metric group names in logConsent * chore: clean up UI * chore: use ignite-metrics from npm * chore: update ignite-metrics and some types * fix(tests): tests dont fail on countly-sdk-web import * fix: build * chore: temporarily use updated ignite-metrics * chore: use deployed ignite-metrics version * chore: address PR comments * use latest ignite-metrics library * don't use singleton function for grabbing metricsProvider * ensure metrics initialize and update properly * chore: pin ignite-metrics dependency * chore(lint): fix lint errors * fix: use browser.runtime.sendMessage * Telemetry messages are passed between contexts using browser.runtime * Upgraded to @ipfs-shipyard/[email protected] * Updated consent handling from state to be more explicit --------- Co-authored-by: Nishant Arora <[email protected]> Co-authored-by: Marcin Rataj <[email protected]>
1 parent 7f11f86 commit 42eed02

File tree

23 files changed

+4336
-403
lines changed

23 files changed

+4336
-403
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,6 @@
1515
/coverage
1616
/.nyc_output
1717
/add-on/manifest.json
18+
19+
.DS_Store
20+
.vscode

add-on/_locales/en/messages.json

+40
Original file line numberDiff line numberDiff line change
@@ -710,5 +710,45 @@
710710
"page_landingWelcome_projects_title": {
711711
"message": "Related Projects",
712712
"description": "Projects section title (page_landingWelcome_projects_title)"
713+
},
714+
"option_header_telemetry": {
715+
"message": "Telemetry",
716+
"description": "A section header on the Preferences screen (option_header_telemetry)"
717+
},
718+
"option_telemetry_disclaimer": {
719+
"message": "We're collecting minimal telemetry data to improve and prioritize our work. Please consent to the collection of these metrics to assist in our efforts!",
720+
"description": "Disclaimer about telemetry collection in the telemetry section on the Preferences screen (option_telemetry_disclaimer)"
721+
},
722+
"option_telemetryGroupMinimal_title": {
723+
"message": "Feature Telemetry",
724+
"description": "A title for the 'minimal' grouping of metrics we collect (option_telemetryGroupMinimal_title)"
725+
},
726+
"option_telemetryGroupMinimal_description": {
727+
"message": "Collect basic feature and usage metrics to help maintainers to prioritize work on the most useful features.",
728+
"description": "A description for the 'minimal' grouping of metrics we collect (option_telemetryGroupMinimal_description)"
729+
},
730+
"option_telemetryGroupMarketing_title": {
731+
"message": "Marketing title",
732+
"description": "A title for the 'marketing' grouping of metrics we collect (option_telemetryGroupMarketing_title)"
733+
},
734+
"option_telemetryGroupMarketing_description": {
735+
"message": "Marketing description",
736+
"description": "A description for the 'marketing' grouping of metrics we collect (option_telemetryGroupMarketing_description)"
737+
},
738+
"option_telemetryGroupPerformance_title": {
739+
"message": "Performance title",
740+
"description": "A title for the 'performance' grouping of metrics we collect (option_telemetryGroupPerformance_title)"
741+
},
742+
"option_telemetryGroupPerformance_description": {
743+
"message": "Performance description",
744+
"description": "A description for the 'performance' grouping of metrics we collect (option_telemetryGroupPerformance_description)"
745+
},
746+
"option_telemetryGroupTracking_title": {
747+
"message": "Tracking title",
748+
"description": "A title for the 'tracking' grouping of metrics we collect (option_telemetryGroupTracking_title)"
749+
},
750+
"option_telemetryGroupTracking_description": {
751+
"message": "Tracking description",
752+
"description": "A description for the 'tracking' grouping of metrics we collect (option_telemetryGroupTracking_description)"
713753
}
714754
}

add-on/src/background/background.html

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
<meta charset="utf-8">
33
<script src="/dist/bundles/ipfs.bundle.js"></script>
44
<script src="/dist/bundles/backgroundPage.bundle.js"></script>
5+
<body></body>

add-on/src/background/background.js

+2
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ browser.runtime.setUninstallURL(getUninstallURL(browser))
1313

1414
// init add-on after all libs are loaded
1515
document.addEventListener('DOMContentLoaded', async () => {
16+
browser.runtime.sendMessage({ telemetry: { trackView: 'background' } })
1617
// setting debug namespaces require page reload to get applied
1718
const debugNs = (await browser.storage.local.get({ logNamespaces: optionDefaults.logNamespaces })).logNamespaces
1819
if (debugNs !== localStorage.debug) {
1920
localStorage.debug = debugNs
2021
window.location.reload()
2122
}
2223
// init inlined to read updated localStorage.debug
24+
// @ts-expect-error - TS does not know about window.ipfsCompanion
2325
window.ipfsCompanion = await createIpfsCompanion()
2426
})

add-on/src/landing-pages/welcome/page.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const renderCompanionLogo = (i18n, isIpfsOnline) => {
5555

5656
return html`
5757
<div class="mt4 mb2 flex flex-column justify-center items-center transition-all ${stateUnknown && 'state-unknown'}">
58-
${logo({ path: logoPath, size: logoSize, isIpfsOnline: isIpfsOnline })}
58+
${logo({ path: logoPath, size: logoSize, isIpfsOnline })}
5959
<p class="montserrat mt3 mb0 f2">${i18n.getMessage('page_landingWelcome_logo_title')}</p>
6060
</div>
6161
`

add-on/src/landing-pages/welcome/store.js

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default function createWelcomePageStore (i18n, runtime) {
99
state.webuiRootUrl = null
1010
let port
1111
emitter.on('DOMContentLoaded', async () => {
12+
browser.runtime.sendMessage({ telemetry: { trackView: 'welcome' } })
1213
emitter.emit('render')
1314
port = runtime.connect({ name: 'browser-action-port' })
1415
port.onMessage.addListener(async (message) => {

add-on/src/lib/ipfs-client/brave.js

+33-33
Original file line numberDiff line numberDiff line change
@@ -16,39 +16,39 @@ const waitFor = (f, t) => pWaitFor(f, { interval: tickMs, timeout: t || Infinity
1616
// wrapper for chrome.ipfs.* that gets us closer to ergonomics of promise-based browser.*
1717
export const brave = hasBraveChromeIpfs()
1818
? Object.freeze({
19-
// This is the main check - returns true only in Brave and only when
20-
// feature flag is enabled brave://flags and can be used for high level UI
21-
// decisions such as showing custom node type on Preferences
22-
getIPFSEnabled: async () =>
23-
Boolean(await promisifyBraveCheck(chrome.ipfs.getIPFSEnabled)),
24-
25-
// Obtains a string representation of the resolve method
26-
// method is one of the following strings:
27-
// "ask" uses a gateway but also prompts them to install a local node
28-
// "gateway" uses a gateway but also prompts them to install a local node
29-
// "local" uses a gateway but also prompts them to install a local node
30-
// "disabled" disabled by the user
31-
// "undefined" everything else (IPFS feature flag is not enabled, error etc)
32-
getResolveMethodType: async () =>
33-
String(await promisifyBraveCheck(chrome.ipfs.getResolveMethodType)),
34-
35-
// Obtains the config contents of the local IPFS node
36-
// Returns undefined if missing for any reason
37-
getConfig: async () =>
38-
await promisifyBraveCheck(chrome.ipfs.getConfig),
39-
40-
// Returns true if binary is present
41-
getExecutableAvailable: async () =>
42-
Boolean(await promisifyBraveCheck(chrome.ipfs.getExecutableAvailable)),
43-
44-
// Attempts to start the daemon and returns true if finished
45-
launch: async () =>
46-
Boolean(await promisifyBraveCheck(chrome.ipfs.launch)),
47-
48-
// Attempts to stop the daemon and returns true if finished
49-
shutdown: async () =>
50-
Boolean(await promisifyBraveCheck(chrome.ipfs.shutdown))
51-
})
19+
// This is the main check - returns true only in Brave and only when
20+
// feature flag is enabled brave://flags and can be used for high level UI
21+
// decisions such as showing custom node type on Preferences
22+
getIPFSEnabled: async () =>
23+
Boolean(await promisifyBraveCheck(chrome.ipfs.getIPFSEnabled)),
24+
25+
// Obtains a string representation of the resolve method
26+
// method is one of the following strings:
27+
// "ask" uses a gateway but also prompts them to install a local node
28+
// "gateway" uses a gateway but also prompts them to install a local node
29+
// "local" uses a gateway but also prompts them to install a local node
30+
// "disabled" disabled by the user
31+
// "undefined" everything else (IPFS feature flag is not enabled, error etc)
32+
getResolveMethodType: async () =>
33+
String(await promisifyBraveCheck(chrome.ipfs.getResolveMethodType)),
34+
35+
// Obtains the config contents of the local IPFS node
36+
// Returns undefined if missing for any reason
37+
getConfig: async () =>
38+
await promisifyBraveCheck(chrome.ipfs.getConfig),
39+
40+
// Returns true if binary is present
41+
getExecutableAvailable: async () =>
42+
Boolean(await promisifyBraveCheck(chrome.ipfs.getExecutableAvailable)),
43+
44+
// Attempts to start the daemon and returns true if finished
45+
launch: async () =>
46+
Boolean(await promisifyBraveCheck(chrome.ipfs.launch)),
47+
48+
// Attempts to stop the daemon and returns true if finished
49+
shutdown: async () =>
50+
Boolean(await promisifyBraveCheck(chrome.ipfs.shutdown))
51+
})
5252
: undefined
5353

5454
export async function init (browser, opts) {

add-on/src/lib/ipfs-companion.js

+22-4
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import createRuntimeChecks from './runtime-checks.js'
2323
import { createContextMenus, findValueForContext, contextMenuCopyAddressAtPublicGw, contextMenuCopyRawCid, contextMenuCopyCanonicalAddress, contextMenuViewOnGateway, contextMenuCopyPermalink, contextMenuCopyCidAddress } from './context-menus.js'
2424
import { registerSubdomainProxy } from './http-proxy.js'
2525
import { runPendingOnInstallTasks } from './on-installed.js'
26+
import { handleConsentFromState, startSession, endSession, trackView } from './telemetry.js'
2627
const log = debug('ipfs-companion:main')
2728
log.error = debug('ipfs-companion:main:error')
2829

@@ -33,6 +34,7 @@ export default async function init () {
3334
// INIT
3435
// ===================================================================
3536
let ipfs // ipfs-api instance
37+
/** @type {import('../types.js').CompanionState} */
3638
let state // avoid redundant API reads by utilizing local cache of various states
3739
let dnslinkResolver
3840
let ipfsPathValidator
@@ -55,8 +57,11 @@ export default async function init () {
5557
runtime = await createRuntimeChecks(browser)
5658
state = initState(options)
5759
notify = createNotifier(getState)
60+
// ensure consent is set properly on app init
61+
handleConsentFromState(state)
5862

5963
if (state.active) {
64+
startSession()
6065
// It's ok for this to fail, node might be unavailable or mis-configured
6166
try {
6267
ipfs = await initIpfsClient(browser, state)
@@ -167,6 +172,15 @@ export default async function init () {
167172
const result = validIpfsOrIpns(path) ? resolveToPublicUrl(path) : null
168173
return Promise.resolve({ pubGwUrlForIpfsOrIpnsPath: result })
169174
}
175+
if (request.telemetry) {
176+
return Promise.resolve(onTelemetryMessage(request.telemetry, sender))
177+
}
178+
}
179+
180+
function onTelemetryMessage (request, sender) {
181+
if (request.trackView) {
182+
return trackView(request.trackView)
183+
}
170184
}
171185

172186
// PORTS (connection-based messaging)
@@ -366,11 +380,11 @@ export default async function init () {
366380
// https://github.com/ipfs-shipyard/ipfs-companion/issues/398
367381
if (runtime.isFirefox && ipfsPathValidator.isIpfsPageActionsContext(url)) {
368382
if (sameGateway(url, state.gwURL) || sameGateway(url, state.apiURL)) {
369-
await browser.pageAction.setIcon({ tabId: tabId, path: '/icons/ipfs-logo-on.svg' })
370-
await browser.pageAction.setTitle({ tabId: tabId, title: browser.i18n.getMessage('pageAction_titleIpfsAtCustomGateway') })
383+
await browser.pageAction.setIcon({ tabId, path: '/icons/ipfs-logo-on.svg' })
384+
await browser.pageAction.setTitle({ tabId, title: browser.i18n.getMessage('pageAction_titleIpfsAtCustomGateway') })
371385
} else {
372-
await browser.pageAction.setIcon({ tabId: tabId, path: '/icons/ipfs-logo-off.svg' })
373-
await browser.pageAction.setTitle({ tabId: tabId, title: browser.i18n.getMessage('pageAction_titleIpfsAtPublicGateway') })
386+
await browser.pageAction.setIcon({ tabId, path: '/icons/ipfs-logo-off.svg' })
387+
await browser.pageAction.setTitle({ tabId, title: browser.i18n.getMessage('pageAction_titleIpfsAtPublicGateway') })
374388
}
375389
await browser.pageAction.show(tabId)
376390
}
@@ -554,6 +568,8 @@ export default async function init () {
554568
await registerSubdomainProxy(getState, runtime)
555569
shouldRestartIpfsClient = true
556570
shouldStopIpfsClient = !state.active
571+
// Any time the extension switches active state, start or stop the current session.
572+
state.active ? startSession() : endSession()
557573
break
558574
case 'ipfsNodeType':
559575
if (change.oldValue !== braveNodeType && change.newValue === braveNodeType) {
@@ -620,6 +636,8 @@ export default async function init () {
620636
break
621637
}
622638
}
639+
// ensure consent is set properly on state changes
640+
handleConsentFromState(state)
623641

624642
if ((state.active && shouldRestartIpfsClient) || shouldStopIpfsClient) {
625643
try {

add-on/src/lib/notifier.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ export default function createNotifier (getState) {
2121
return await browser.notifications.create({
2222
type: 'basic',
2323
iconUrl: browser.runtime.getURL('icons/ipfs-logo-on.svg'),
24-
title: title,
25-
message: message
24+
title,
25+
message
2626
})
2727
} catch (err) {
2828
log.error('failed to create a notification', err)

add-on/src/lib/options.js

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
'use strict'
22

3-
import { isIPv4, isIPv6 } from 'is-ip'
43
import isFQDN from 'is-fqdn'
4+
import { isIPv4, isIPv6 } from 'is-ip'
55

6+
/**
7+
* @type {Readonly<import('../types.js').CompanionOptions>}
8+
*/
69
export const optionDefaults = Object.freeze({
710
active: true, // global ON/OFF switch, overrides everything else
811
ipfsNodeType: 'external',
@@ -31,7 +34,12 @@ export const optionDefaults = Object.freeze({
3134
importDir: '/ipfs-companion-imports/%Y-%M-%D_%h%m%s/',
3235
useLatestWebUI: false,
3336
dismissedUpdate: null,
34-
openViaWebUI: true
37+
openViaWebUI: true,
38+
telemetryGroupMinimal: true,
39+
telemetryGroupPerformance: false,
40+
telemetryGroupUx: false,
41+
telemetryGroupFeedback: false,
42+
telemetryGroupLocation: false
3543
})
3644

3745
function buildDefaultIpfsNodeConfig () {

add-on/src/lib/state.js

+19-9
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,38 @@
11
'use strict'
22
/* eslint-env browser, webextensions */
3-
4-
import { safeURL, isHostname } from './options.js'
3+
// @ts-check
4+
import { isHostname, safeURL } from './options.js'
55

66
export const offlinePeerCount = -1
7+
8+
/**
9+
*
10+
* @param {import('../types.js').CompanionOptions} options
11+
* @param {Partial<import('../types.js').CompanionOptions>} [overrides]
12+
* @returns {import('../types.js').CompanionState}
13+
*/
714
export function initState (options, overrides) {
815
// we store options and some pregenerated values to avoid async storage
916
// reads and minimize performance impact on overall browsing experience
17+
/**
18+
* @type {Partial<import('../types.js').CompanionState & import('../types.js').CompanionOptions>}
19+
*/
1020
const state = Object.assign({}, options)
1121
// generate some additional values
1222
state.peerCount = offlinePeerCount
1323
state.pubGwURL = safeURL(options.publicGatewayUrl)
14-
state.pubGwURLString = state.pubGwURL.toString()
24+
state.pubGwURLString = state.pubGwURL?.toString()
1525
delete state.publicGatewayUrl
1626
state.pubSubdomainGwURL = safeURL(options.publicSubdomainGatewayUrl)
17-
state.pubSubdomainGwURLString = state.pubSubdomainGwURL.toString()
27+
state.pubSubdomainGwURLString = state.pubSubdomainGwURL?.toString()
1828
delete state.publicSubdomainGatewayUrl
1929
state.redirect = options.useCustomGateway
2030
delete state.useCustomGateway
2131
state.apiURL = safeURL(options.ipfsApiUrl, { useLocalhostName: false }) // go-ipfs returns 403 if IP is beautified to 'localhost'
22-
state.apiURLString = state.apiURL.toString()
32+
state.apiURLString = state.apiURL?.toString()
2333
delete state.ipfsApiUrl
2434
state.gwURL = safeURL(options.customGatewayUrl, { useLocalhostName: state.useSubdomains })
25-
state.gwURLString = state.gwURL.toString()
35+
state.gwURLString = state.gwURL?.toString()
2636
delete state.customGatewayUrl
2737
state.dnslinkPolicy = String(options.dnslinkPolicy) === 'false' ? false : options.dnslinkPolicy
2838

@@ -32,9 +42,9 @@ export function initState (options, overrides) {
3242
try {
3343
const hostname = isHostname(url) ? url : new URL(url).hostname
3444
// opt-out has more weight, we also match parent domains
35-
const disabledDirectlyOrIndirectly = state.disabledOn.some(optout => hostname.endsWith(optout))
45+
const disabledDirectlyOrIndirectly = state.disabledOn?.some(optout => hostname.endsWith(optout))
3646
// ..however direct opt-in should overwrite parent's opt-out
37-
const enabledDirectly = state.enabledOn.some(optin => optin === hostname)
47+
const enabledDirectly = state.enabledOn?.some(optin => optin === hostname)
3848
return !(disabledDirectlyOrIndirectly && !enabledDirectly)
3949
} catch (_) {
4050
return false
@@ -55,5 +65,5 @@ export function initState (options, overrides) {
5565
})
5666
// apply optional overrides
5767
if (overrides) Object.assign(state, overrides)
58-
return state
68+
return /** @type {import('../types.js').CompanionState} */(state)
5969
}

add-on/src/lib/telemetry.js

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import MetricsProvider from '@ipfs-shipyard/ignite-metrics/vanilla'
2+
import debug from 'debug'
3+
4+
const log = debug('ipfs-companion:telemetry')
5+
6+
const metricsProvider = new MetricsProvider({
7+
appKey: '393f72eb264c28a1b59973da1e0a3938d60dc38a',
8+
autoTrack: false,
9+
storageProvider: null
10+
})
11+
12+
/**
13+
*
14+
* @param {import('../types.js').CompanionState} state
15+
* @returns {void}
16+
*/
17+
export function handleConsentFromState (state) {
18+
const telemetryGroups = {
19+
minimal: state?.telemetryGroupMinimal || false,
20+
performance: state?.telemetryGroupPerformance || false,
21+
ux: state?.telemetryGroupUx || false,
22+
feedback: state?.telemetryGroupFeedback || false,
23+
location: state?.telemetryGroupLocation || false
24+
}
25+
for (const [groupName, isEnabled] of Object.entries(telemetryGroups)) {
26+
if (isEnabled) {
27+
log(`Adding consent for '${groupName}'`)
28+
metricsProvider.addConsent(groupName)
29+
} else {
30+
log(`Removing consent for '${groupName}'`)
31+
metricsProvider.removeConsent(groupName)
32+
}
33+
}
34+
}
35+
36+
const ignoredViewsRegex = []
37+
export function trackView (view) {
38+
log('trackView called for view: ', view)
39+
metricsProvider.trackView(view, ignoredViewsRegex)
40+
}
41+
42+
export const startSession = (...args) => metricsProvider.startSession(...args)
43+
export const endSession = (...args) => metricsProvider.endSession(...args)

0 commit comments

Comments
 (0)