Skip to content

Commit 8b24f62

Browse files
brad-deckerwhymarrhGudahtt
authored
add segment implementation of metametrics (MetaMask#9382)
Co-authored-by: Whymarrh Whitby <[email protected]> Co-authored-by: Mark Stacey <[email protected]>
1 parent 9391eac commit 8b24f62

File tree

10 files changed

+441
-7
lines changed

10 files changed

+441
-7
lines changed

Diff for: .metamaskrc.dist

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
; Extra environment variables
22
INFURA_PROJECT_ID=00000000000
3+
SEGMENT_WRITE_KEY=

Diff for: README.md

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ To learn how to contribute to the MetaMask project itself, visit our [Internal D
1818
- Install dependencies: `yarn`
1919
- Copy the `.metamaskrc.dist` file to `.metamaskrc`
2020
- Replace the `INFURA_PROJECT_ID` value with your own personal [Infura Project ID](https://infura.io/docs).
21+
- If debugging MetaMetrics, you'll need to add a value for `SEGMENT_WRITE_KEY` [Segment write key](https://segment.com/docs/connections/find-writekey/).
2122
- Build the project to the `./dist/` folder with `yarn dist`.
2223
- Optionally, to start a development build (e.g. with logging and file watching) run `yarn start` instead.
2324
- To start the [React DevTools](https://github.com/facebook/react-devtools) and [Redux DevTools Extension](http://extension.remotedev.io)

Diff for: development/build/scripts.js

+10
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const { makeStringTransform } = require('browserify-transform-tools')
1717

1818
const conf = require('rc')('metamask', {
1919
INFURA_PROJECT_ID: process.env.INFURA_PROJECT_ID,
20+
SEGMENT_WRITE_KEY: process.env.SEGMENT_WRITE_KEY,
2021
})
2122

2223
const packageJSON = require('../../package.json')
@@ -316,6 +317,14 @@ function createScriptTasks ({ browserPlatforms, livereload }) {
316317
throw new Error('Missing SENTRY_DSN environment variable')
317318
}
318319

320+
// When we're in the 'production' environment we will use a specific key only set in CI
321+
// Otherwise we'll use the key from .metamaskrc or from the environment variable. If
322+
// the value of SEGMENT_WRITE_KEY that we envify is undefined then no events will be tracked
323+
// in the build. This is intentional so that developers can contribute to MetaMask without
324+
// inflating event volume.
325+
const SEGMENT_PROD_WRITE_KEY = opts.testing ? undefined : process.env.SEGMENT_PROD_WRITE_KEY
326+
const SEGMENT_DEV_WRITE_KEY = opts.testing ? undefined : conf.SEGMENT_WRITE_KEY
327+
319328
// Inject variables into bundle
320329
bundler.transform(envify({
321330
METAMASK_DEBUG: opts.devMode,
@@ -333,6 +342,7 @@ function createScriptTasks ({ browserPlatforms, livereload }) {
333342
? '00000000000000000000000000000000'
334343
: conf.INFURA_PROJECT_ID
335344
),
345+
SEGMENT_WRITE_KEY: environment === 'production' ? SEGMENT_PROD_WRITE_KEY : SEGMENT_DEV_WRITE_KEY,
336346
}), {
337347
global: true,
338348
})

Diff for: package.json

+1
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
"@sentry/integrations": "^5.11.1",
8383
"@zxing/library": "^0.8.0",
8484
"abortcontroller-polyfill": "^1.4.0",
85+
"analytics-node": "^3.4.0-beta.2",
8586
"await-semaphore": "^0.1.1",
8687
"bignumber.js": "^4.1.0",
8788
"bip39": "^2.2.0",

Diff for: ui/app/contexts/metametrics.new.js

+231
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* This file is intended to be renamed to metametrics.js once the conversion is complete.
3+
* MetaMetrics is our own brand, and should remain aptly named regardless of the underlying
4+
* metrics system. This file implements Segment analytics tracking.
5+
*/
6+
import React, { useRef, Component, createContext, useEffect, useCallback } from 'react'
7+
import { useSelector } from 'react-redux'
8+
import PropTypes from 'prop-types'
9+
import { useLocation, matchPath, useRouteMatch } from 'react-router-dom'
10+
import { captureException, captureMessage } from '@sentry/browser'
11+
12+
import { omit } from 'lodash'
13+
import {
14+
getCurrentNetworkId,
15+
} from '../selectors/selectors'
16+
17+
import { getEnvironmentType } from '../../../app/scripts/lib/util'
18+
import {
19+
sendCountIsTrackable,
20+
segment,
21+
METAMETRICS_ANONYMOUS_ID,
22+
} from '../helpers/utils/metametrics.util'
23+
import { PATH_NAME_MAP } from '../helpers/constants/routes'
24+
import { getCurrentLocale } from '../ducks/metamask/metamask'
25+
import { txDataSelector } from '../selectors'
26+
27+
export const MetaMetricsContext = createContext(() => {
28+
captureException(
29+
Error(`MetaMetrics context function was called from a react node that is not a descendant of a MetaMetrics context provider`),
30+
)
31+
})
32+
33+
const PATHS_TO_CHECK = Object.keys(PATH_NAME_MAP)
34+
35+
function useSegmentContext () {
36+
const match = useRouteMatch({ path: PATHS_TO_CHECK, exact: true, strict: true })
37+
const locale = useSelector(getCurrentLocale)
38+
const txData = useSelector(txDataSelector) || {}
39+
const confirmTransactionOrigin = txData.origin
40+
41+
const referrer = confirmTransactionOrigin ? {
42+
url: confirmTransactionOrigin,
43+
} : undefined
44+
45+
let version = global.platform.getVersion()
46+
if (process.env.METAMASK_ENVIRONMENT !== 'production') {
47+
version = `${version}-${process.env.METAMASK_ENVIRONMENT}`
48+
}
49+
50+
const page = match ? {
51+
path: match.path,
52+
title: PATH_NAME_MAP[match.path],
53+
url: match.path,
54+
} : undefined
55+
56+
return {
57+
app: {
58+
version,
59+
name: 'MetaMask Extension',
60+
},
61+
locale: locale.replace('_', '-'),
62+
page,
63+
referrer,
64+
userAgent: window.navigator.userAgent,
65+
}
66+
}
67+
68+
export function MetaMetricsProvider ({ children }) {
69+
const network = useSelector(getCurrentNetworkId)
70+
const metaMetricsId = useSelector((state) => state.metamask.metaMetricsId)
71+
const participateInMetaMetrics = useSelector((state) => state.metamask.participateInMetaMetrics)
72+
const metaMetricsSendCount = useSelector((state) => state.metamask.metaMetricsSendCount)
73+
const location = useLocation()
74+
const context = useSegmentContext()
75+
76+
// Used to prevent double tracking page calls
77+
const previousMatch = useRef()
78+
79+
/**
80+
* Anytime the location changes, track a page change with segment.
81+
* Previously we would manually track changes to history and keep a
82+
* reference to the previous url, but with page tracking we can see
83+
* which page the user is on and their navigation path.
84+
*/
85+
useEffect(() => {
86+
const environmentType = getEnvironmentType()
87+
if (
88+
(participateInMetaMetrics === null && location.pathname.startsWith('/initialize')) ||
89+
participateInMetaMetrics
90+
) {
91+
// Events that happen during initialization before the user opts into MetaMetrics will be anonymous
92+
const idTrait = metaMetricsId ? 'userId' : 'anonymousId'
93+
const idValue = metaMetricsId ?? METAMETRICS_ANONYMOUS_ID
94+
const match = matchPath(location.pathname, { path: PATHS_TO_CHECK, exact: true, strict: true })
95+
if (
96+
match &&
97+
previousMatch.current !== match.path &&
98+
// If we're in a popup or notification we don't want the initial home route to track
99+
!(
100+
(environmentType === 'popup' || environmentType === 'notification') &&
101+
match.path === '/' &&
102+
previousMatch.current === undefined
103+
)
104+
) {
105+
const { path, params } = match
106+
const name = PATH_NAME_MAP[path]
107+
segment.page({
108+
[idTrait]: idValue,
109+
name,
110+
properties: {
111+
// We do not want to send addresses or accounts in any events
112+
// Some routes include these as params.
113+
params: omit(params, ['account', 'address']),
114+
network,
115+
environment_type: environmentType,
116+
},
117+
context,
118+
})
119+
} else if (location.pathname !== '/confirm-transaction') {
120+
// We have more specific pages for each type of transaction confirmation
121+
// The user lands on /confirm-transaction first, then is redirected based on
122+
// the contents of state.
123+
captureMessage(`${location.pathname} would have issued a page track event to segment, but no route match was found`)
124+
}
125+
previousMatch.current = match?.path
126+
}
127+
}, [location, context, network, metaMetricsId, participateInMetaMetrics])
128+
129+
/**
130+
* track a metametrics event using segment
131+
* e.g metricsEvent({ event: 'Unlocked MetaMask', category: 'Navigation' })
132+
*
133+
* @param {object} config - configuration object for the event to track
134+
* @param {string} config.event - event name to track
135+
* @param {string} config.category - category to associate event to
136+
* @param {boolean} [config.isOptIn] - happened during opt in/out workflow
137+
* @param {object} [config.properties] - object of custom values to track, snake_case
138+
* @param {number} [config.revenue] - amount of currency that event creates in revenue for MetaMask
139+
* @param {string} [config.currency] - ISO 4127 format currency for events with revenue, defaults to US dollars
140+
* @param {number} [config.value] - Abstract "value" that this event has for MetaMask.
141+
* @return {undefined}
142+
*/
143+
const trackEvent = useCallback(
144+
(config = {}) => {
145+
const { event, category, isOptIn = false, properties = {}, revenue, value, currency } = config
146+
if (!event) {
147+
// Event name is required for tracking an event
148+
throw new Error('MetaMetrics trackEvent function must be provided a payload with an "event" key')
149+
}
150+
if (!category) {
151+
// Category must be supplied for every tracking event
152+
throw new Error('MetaMetrics events must be provided a category')
153+
}
154+
const environmentType = getEnvironmentType()
155+
156+
let excludeMetaMetricsId = config.excludeMetaMetricsId ?? false
157+
158+
// This is carried over from the old implementation, and will likely need
159+
// to be updated to work with the new tracking plan. I think we should use
160+
// a config setting for this instead of trying to match the event name
161+
const isSendFlow = Boolean(event.match(/^send|^confirm/u))
162+
if (isSendFlow && !sendCountIsTrackable(metaMetricsSendCount + 1)) {
163+
excludeMetaMetricsId = true
164+
}
165+
const idTrait = excludeMetaMetricsId ? 'anonymousId' : 'userId'
166+
const idValue = excludeMetaMetricsId ? METAMETRICS_ANONYMOUS_ID : metaMetricsId
167+
168+
if (participateInMetaMetrics || isOptIn) {
169+
segment.track({
170+
[idTrait]: idValue,
171+
event,
172+
properties: {
173+
...omit(properties, ['revenue', 'currency', 'value']),
174+
revenue,
175+
value,
176+
currency,
177+
category,
178+
network,
179+
environment_type: environmentType,
180+
},
181+
context,
182+
})
183+
}
184+
185+
return undefined
186+
}, [
187+
context,
188+
network,
189+
metaMetricsId,
190+
metaMetricsSendCount,
191+
participateInMetaMetrics,
192+
],
193+
)
194+
195+
return (
196+
<MetaMetricsContext.Provider value={trackEvent}>
197+
{children}
198+
</MetaMetricsContext.Provider>
199+
)
200+
}
201+
202+
MetaMetricsProvider.propTypes = { children: PropTypes.node }
203+
204+
export class LegacyMetaMetricsProvider extends Component {
205+
static propTypes = {
206+
children: PropTypes.node,
207+
}
208+
209+
static defaultProps = {
210+
children: undefined,
211+
}
212+
213+
static contextType = MetaMetricsContext
214+
215+
static childContextTypes = {
216+
// This has to be different than the type name for the old metametrics file
217+
// using the same name would result in whichever was lower in the tree to be
218+
// used.
219+
trackEvent: PropTypes.func,
220+
}
221+
222+
getChildContext () {
223+
return {
224+
trackEvent: this.context,
225+
}
226+
}
227+
228+
render () {
229+
return this.props.children
230+
}
231+
}

Diff for: ui/app/helpers/constants/routes.js

+58
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,63 @@ const SIGNATURE_REQUEST_PATH = '/signature-request'
5454
const DECRYPT_MESSAGE_REQUEST_PATH = '/decrypt-message-request'
5555
const ENCRYPTION_PUBLIC_KEY_REQUEST_PATH = '/encryption-public-key-request'
5656

57+
// Used to pull a convenient name for analytics tracking events. The key must
58+
// be react-router ready path, and can include params such as :id for popup windows
59+
const PATH_NAME_MAP = {
60+
[DEFAULT_ROUTE]: 'Home',
61+
[UNLOCK_ROUTE]: 'Unlock Page',
62+
[LOCK_ROUTE]: 'Lock Page',
63+
[`${ASSET_ROUTE}/:asset`]: `Asset Page`,
64+
[SETTINGS_ROUTE]: 'Settings Page',
65+
[GENERAL_ROUTE]: 'General Settings Page',
66+
[ADVANCED_ROUTE]: 'Advanced Settings Page',
67+
[SECURITY_ROUTE]: 'Security Settings Page',
68+
[ABOUT_US_ROUTE]: 'About Us Page',
69+
[ALERTS_ROUTE]: 'Alerts Settings Page',
70+
[NETWORKS_ROUTE]: 'Network Settings Page',
71+
[CONTACT_LIST_ROUTE]: 'Contact List Settings Page',
72+
[`${CONTACT_EDIT_ROUTE}/:address`]: 'Edit Contact Settings Page',
73+
[CONTACT_ADD_ROUTE]: 'Add Contact Settings Page',
74+
[`${CONTACT_VIEW_ROUTE}/:address`]: 'View Contact Settings Page',
75+
[CONTACT_MY_ACCOUNTS_ROUTE]: 'My Accounts List Settings Page',
76+
[`${CONTACT_MY_ACCOUNTS_VIEW_ROUTE}/:account`]: 'View Account Settings Page',
77+
[`${CONTACT_MY_ACCOUNTS_EDIT_ROUTE}/:account`]: 'Edit Account Settings Page',
78+
[REVEAL_SEED_ROUTE]: 'Reveal Seed Page',
79+
[MOBILE_SYNC_ROUTE]: 'Sync With Mobile Page',
80+
[RESTORE_VAULT_ROUTE]: 'Restore Vault Page',
81+
[ADD_TOKEN_ROUTE]: 'Add Token Page',
82+
[CONFIRM_ADD_TOKEN_ROUTE]: 'Confirm Add Token Page',
83+
[CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE]: 'Confirm Add Suggested Token Page',
84+
[NEW_ACCOUNT_ROUTE]: 'New Account Page',
85+
[IMPORT_ACCOUNT_ROUTE]: 'Import Account Page',
86+
[CONNECT_HARDWARE_ROUTE]: 'Connect Hardware Wallet Page',
87+
[SEND_ROUTE]: 'Send Page',
88+
[`${CONNECT_ROUTE}/:id`]: 'Connect To Site Confirmation Page',
89+
[`${CONNECT_ROUTE}/:id${CONNECT_CONFIRM_PERMISSIONS_ROUTE}`]: 'Grant Connected Site Permissions Confirmation Page',
90+
[CONNECTED_ROUTE]: 'Sites Connected To This Account Page',
91+
[CONNECTED_ACCOUNTS_ROUTE]: 'Accounts Connected To This Site Page',
92+
[`${CONFIRM_TRANSACTION_ROUTE}/:id${CONFIRM_TOKEN_METHOD_PATH}`]: 'Confirm Token Method Transaction Page',
93+
[`${CONFIRM_TRANSACTION_ROUTE}/:id${CONFIRM_SEND_ETHER_PATH}`]: 'Confirm Send Ether Transaction Page',
94+
[`${CONFIRM_TRANSACTION_ROUTE}/:id${CONFIRM_SEND_TOKEN_PATH}`]: 'Confirm Send Token Transaction Page',
95+
[`${CONFIRM_TRANSACTION_ROUTE}/:id${CONFIRM_DEPLOY_CONTRACT_PATH}`]: 'Confirm Deploy Contract Transaction Page',
96+
[`${CONFIRM_TRANSACTION_ROUTE}/:id${CONFIRM_APPROVE_PATH}`]: 'Confirm Approve Transaction Page',
97+
[`${CONFIRM_TRANSACTION_ROUTE}/:id${CONFIRM_TRANSFER_FROM_PATH}`]: 'Confirm Transfer From Transaction Page',
98+
[`${CONFIRM_TRANSACTION_ROUTE}/:id${SIGNATURE_REQUEST_PATH}`]: 'Signature Request Page',
99+
[`${CONFIRM_TRANSACTION_ROUTE}/:id${DECRYPT_MESSAGE_REQUEST_PATH}`]: 'Decrypt Message Request Page',
100+
[`${CONFIRM_TRANSACTION_ROUTE}/:id${ENCRYPTION_PUBLIC_KEY_REQUEST_PATH}`]: 'Encryption Public Key Request Page',
101+
[INITIALIZE_ROUTE]: 'Initialization Page',
102+
[INITIALIZE_WELCOME_ROUTE]: 'Install Welcome Page',
103+
[INITIALIZE_UNLOCK_ROUTE]: 'Initialization Unlock page',
104+
[INITIALIZE_CREATE_PASSWORD_ROUTE]: 'Initialization Create Password Page',
105+
[INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE]: 'Initialization Import Account With Seed Phrase Page',
106+
[INITIALIZE_SELECT_ACTION_ROUTE]: 'Initialization Choose Restore or New Account Page',
107+
[INITIALIZE_SEED_PHRASE_ROUTE]: 'Initialization Seed Phrase Page',
108+
[INITIALIZE_BACKUP_SEED_PHRASE_ROUTE]: 'Initialization Backup Seed Phrase Page',
109+
[INITIALIZE_END_OF_FLOW_ROUTE]: 'End of Initialization Page',
110+
[INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE]: 'Initialization Confirm Seed Phrase Page',
111+
[INITIALIZE_METAMETRICS_OPT_IN_ROUTE]: 'MetaMetrics Opt In Page',
112+
}
113+
57114
export {
58115
DEFAULT_ROUTE,
59116
ALERTS_ROUTE,
@@ -108,4 +165,5 @@ export {
108165
CONNECT_CONFIRM_PERMISSIONS_ROUTE,
109166
CONNECTED_ROUTE,
110167
CONNECTED_ACCOUNTS_ROUTE,
168+
PATH_NAME_MAP,
111169
}

Diff for: ui/app/helpers/utils/metametrics.util.js

+25
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint camelcase: 0 */
22

33
import ethUtil from 'ethereumjs-util'
4+
import Analytics from 'analytics-node'
45

56
const inDevelopment = process.env.METAMASK_DEBUG || process.env.IN_TEST
67

@@ -73,6 +74,8 @@ const customDimensionsNameIdMap = {
7374
[METAMETRICS_CUSTOM_VERSION]: 11,
7475
}
7576

77+
export const METAMETRICS_ANONYMOUS_ID = '0x0000000000000000'
78+
7679
function composeUrlRefParamAddition (previousPath, confirmTransactionOrigin) {
7780
const externalOrigin = confirmTransactionOrigin && confirmTransactionOrigin !== 'metamask'
7881
return `&urlref=${externalOrigin ? 'EXTERNAL' : encodeURIComponent(`${METAMETRICS_TRACKING_URL}${previousPath}`)}`
@@ -232,3 +235,25 @@ const trackableSendCounts = {
232235
export function sendCountIsTrackable (sendCount) {
233236
return Boolean(trackableSendCounts[sendCount])
234237
}
238+
239+
const flushAt = inDevelopment ? 1 : undefined
240+
241+
const segmentNoop = {
242+
track () {
243+
// noop
244+
},
245+
page () {
246+
// noop
247+
},
248+
identify () {
249+
// noop
250+
},
251+
}
252+
253+
// We do not want to track events on development builds unless specifically
254+
// provided a SEGMENT_WRITE_KEY. This also holds true for test environments and
255+
// E2E, which is handled in the build process by never providing the SEGMENT_WRITE_KEY
256+
// which process.env.IN_TEST is true
257+
export const segment = process.env.SEGMENT_WRITE_KEY
258+
? new Analytics(process.env.SEGMENT_WRITE_KEY, { flushAt })
259+
: segmentNoop

0 commit comments

Comments
 (0)