A Metamask fork with Infura removed and default networks editable
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ciphermask/shared/modules/metametrics.js

303 lines
12 KiB

import Analytics from 'analytics-node'
import { merge, omit, pick } from 'lodash'
// flushAt controls how many events are sent to segment at once. Segment
// will hold onto a queue of events until it hits this number, then it sends
// them as a batch. This setting defaults to 20, but that is too high for
// notification workflows. We also cannot send each event as singular payloads
// because it seems to bombard segment and potentially cause event loss.
// I chose 5 here because it is sufficiently high enough to optimize our network
// requests, while also being low enough to be reasonable.
const flushAt = process.env.METAMASK_ENVIRONMENT === 'production' ? 5 : 1
// flushInterval controls how frequently the queue is flushed to segment.
// This happens regardless of the size of the queue. The default setting is
// 10,000ms (10 seconds). This default is absurdly high for our typical user
// flow through confirmations. I have chosen 10 ms here because it works really
// well with our wrapped track function. The track function returns a promise
// that is only fulfilled when it has been sent to segment. A 10 ms delay is
// negligible to the user, but allows us to properly batch events that happen
// in rapid succession.
const flushInterval = 10
export const METAMETRICS_ANONYMOUS_ID = '0x0000000000000000'
const segmentNoop = {
track(_, callback = () => undefined) {
// Need to call the callback so that environments without a segment id still
// resolve the promise from trackMetaMetricsEvent
return callback()
},
page() {
// noop
},
identify() {
// noop
},
}
/**
* Used to determine whether or not to attach a user's metametrics id
* to events that include on-chain data. This helps to prevent identifying
* a user by being able to trace their activity on etherscan/block exploring
*/
const trackableSendCounts = {
1: true,
10: true,
30: true,
50: true,
100: true,
250: true,
500: true,
1000: true,
2500: true,
5000: true,
10000: true,
25000: true,
}
export function sendCountIsTrackable(sendCount) {
return Boolean(trackableSendCounts[sendCount])
}
const isDevOrTestEnvironment = Boolean(
process.env.METAMASK_DEBUG || process.env.IN_TEST,
)
// This allows us to overwrite the metric destination for testing purposes
const host = process.env.SEGMENT_HOST ?? undefined
// We do not want to track events on development builds unless specifically
// provided a SEGMENT_WRITE_KEY. This also holds true for test environments and
// E2E, which is handled in the build process by never providing the SEGMENT_WRITE_KEY
// when process.env.IN_TEST is truthy
export const segment =
!process.env.SEGMENT_WRITE_KEY || (isDevOrTestEnvironment && !host)
? segmentNoop
: new Analytics(process.env.SEGMENT_WRITE_KEY, {
host,
flushAt,
flushInterval,
})
export const segmentLegacy =
!process.env.SEGMENT_LEGACY_WRITE_KEY || (isDevOrTestEnvironment && !host)
? segmentNoop
: new Analytics(process.env.SEGMENT_LEGACY_WRITE_KEY, {
host,
flushAt,
flushInterval,
})
/**
* We attach context to every meta metrics event that help to qualify our analytics.
* This type has all optional values because it represents a returned object from a
* method call. Ideally app and userAgent are defined on every event. This is confirmed
* in the getTrackMetaMetricsEvent function, but still provides the consumer a way to
* override these values if necessary.
* @typedef {Object} MetaMetricsContext
* @property {Object} app
* @property {string} app.name - the name of the application tracking the event
* @property {string} app.version - the version of the application
* @property {string} userAgent - the useragent string of the user
* @property {Object} [page] - an object representing details of the current page
* @property {string} [page.path] - the path of the current page (e.g /home)
* @property {string} [page.title] - the title of the current page (e.g 'home')
* @property {string} [page.url] - the fully qualified url of the current page
* @property {Object} [referrer] - for metamask, this is the dapp that triggered an interaction
* @property {string} [referrer.url] - the origin of the dapp issuing the notification
*/
/**
* page and referrer from the MetaMetricsContext are very dynamic in nature and may be
* provided as part of the initial context payload when creating the trackMetaMetricsEvent function,
* or at the event level when calling the trackMetaMetricsEvent function.
* @typedef {Pick<MetaMetricsContext, 'page' | 'referrer'>} MetaMetricsDynamicContext
*/
/**
* @typedef {import('../../app/scripts/lib/enums').EnvironmentType} EnvironmentType
*/
/**
* @typedef {Object} MetaMetricsRequiredState
* @property {bool} participateInMetaMetrics - has the user opted into metametrics
* @property {string} [metaMetricsId] - the user's metaMetricsId, if they have opted in
* @property {MetaMetricsDynamicContext} context - context about the event
* @property {string} chainId - the chain id of the current network
* @property {string} locale - the locale string of the current user
* @property {string} network - the name of the current network
* @property {EnvironmentType} environmentType - environment that the event happened in
* @property {string} [metaMetricsSendCount] - number of transactions sent, used to add metametricsId
* intermittently to events with onchain data attached to them used to protect identity of users.
*/
/**
* @typedef {Object} MetaMetricsEventPayload
* @property {string} event - event name to track
* @property {string} category - category to associate event to
* @property {boolean} [isOptIn] - happened during opt in/out workflow
* @property {object} [properties] - object of custom values to track, snake_case
* @property {object} [sensitiveProperties] - Object of sensitive values to track, snake_case.
* These properties will be sent in an additional event that excludes the user's metaMetricsId.
* @property {number} [revenue] - amount of currency that event creates in revenue for MetaMask
* @property {string} [currency] - ISO 4127 format currency for events with revenue, defaults to US dollars
* @property {number} [value] - Abstract "value" that this event has for MetaMask.
* @property {boolean} [excludeMetaMetricsId] - whether to exclude the user's metametrics id for anonymity
* @property {string} [metaMetricsId] - an override for the metaMetricsId in the event one is created as part
* of an asynchronous workflow, such as awaiting the result of the metametrics opt-in function that generates the
* user's metametrics id.
* @property {boolean} [matomoEvent] - is this event a holdover from matomo that needs further migration?
* when true, sends the data to a special segment source that marks the event data as not conforming to our
* ideal schema
* @property {MetaMetricsDynamicContext} [eventContext] - additional context to attach to event
*/
/**
* Returns a function for tracking Segment events.
*
* @param {string} metamaskVersion - The current version of the MetaMask extension.
* @param {() => MetaMetricsRequiredState} getDynamicState - A function returning required fields
* @returns {(payload: MetaMetricsEventPayload) => Promise<void>} function to track an event
*/
export function getTrackMetaMetricsEvent(metamaskVersion, getDynamicState) {
const version =
process.env.METAMASK_ENVIRONMENT === 'production'
? metamaskVersion
: `${metamaskVersion}-${process.env.METAMASK_ENVIRONMENT}`
return function trackMetaMetricsEvent({
event,
category,
isOptIn,
properties = {},
sensitiveProperties,
revenue,
currency,
value,
metaMetricsId: metaMetricsIdOverride,
excludeMetaMetricsId: excludeId,
matomoEvent = false,
eventContext = {},
}) {
if (!event || !category) {
throw new Error('Must specify event and category.')
}
// Uses recursion to track a duplicate event with sensitive properties included,
// but metaMetricsId excluded
if (sensitiveProperties) {
if (excludeId === true) {
throw new Error(
'sensitiveProperties was specified in an event payload that also set the excludeMetaMetricsId flag',
)
}
trackMetaMetricsEvent({
event,
category,
isOptIn,
properties: merge(sensitiveProperties, properties),
revenue,
currency,
value,
excludeMetaMetricsId: true,
matomoEvent,
eventContext,
})
}
const {
participateInMetaMetrics,
context: providedContext,
metaMetricsId,
environmentType,
chainId,
locale,
network,
metaMetricsSendCount,
} = getDynamicState()
let excludeMetaMetricsId = excludeId ?? false
// This is carried over from the old implementation, and will likely need
// to be updated to work with the new tracking plan. I think we should use
// a config setting for this instead of trying to match the event name
const isSendFlow = Boolean(event.match(/^send|^confirm/u))
if (
isSendFlow &&
metaMetricsSendCount &&
!sendCountIsTrackable(metaMetricsSendCount + 1)
) {
excludeMetaMetricsId = true
}
if (!participateInMetaMetrics && !isOptIn) {
return Promise.resolve()
}
/** @type {MetaMetricsContext} */
const context = {
app: {
name: 'MetaMask Extension',
version,
},
userAgent: window.navigator.userAgent,
...pick(providedContext, ['page', 'referrer']),
...pick(eventContext, ['page', 'referrer']),
}
const trackOptions = {
event,
properties: {
// These values are omitted from properties because they have special meaning
// in segment. https://segment.com/docs/connections/spec/track/#properties.
// to avoid accidentally using these inappropriately, you must add them as top
// level properties on the event payload. We also exclude locale to prevent consumers
// from overwriting this context level property. We track it as a property
// because not all destinations map locale from context.
...omit(properties, ['revenue', 'locale', 'currency', 'value']),
revenue,
value,
currency,
category,
network,
locale,
chain_id: chainId,
environment_type: environmentType,
},
context,
}
// If we are tracking sensitive data we will always use the anonymousId property
// as well as our METAMETRICS_ANONYMOUS_ID. This prevents us from associating potentially
// identifiable information with a specific id. During the opt in flow we will track all
// events, but do so with the anonymous id. The one exception to that rule is after the
// user opts in to MetaMetrics. When that happens we receive back the user's new MetaMetrics
// id before it is fully persisted to state. To avoid a race condition we explicitly pass the
// new id to the track method. In that case we will track the opt in event to the user's id.
// In all other cases we use the metaMetricsId from state.
if (excludeMetaMetricsId) {
trackOptions.anonymousId = METAMETRICS_ANONYMOUS_ID
} else if (isOptIn && metaMetricsIdOverride) {
trackOptions.userId = metaMetricsIdOverride
} else if (isOptIn) {
trackOptions.anonymousId = METAMETRICS_ANONYMOUS_ID
} else {
trackOptions.userId = metaMetricsId
}
return new Promise((resolve, reject) => {
// This is only safe to do because we have set an extremely low (10ms) flushInterval.
const callback = (err) => {
if (err) {
return reject(err)
}
return resolve()
}
if (matomoEvent === true) {
segmentLegacy.track(trackOptions, callback)
} else {
segment.track(trackOptions, callback)
}
})
}
}