1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 01:47:00 +01:00

Build user traits object when metamask state changes (#14192)

This commit is contained in:
Brad Decker 2022-03-28 16:56:56 -05:00 committed by GitHub
parent a3ab204d11
commit b525c620f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 266 additions and 145 deletions

View File

@ -1,4 +1,4 @@
import { merge, omit, omitBy } from 'lodash';
import { isEqual, merge, omit, omitBy, pickBy } from 'lodash';
import { ObservableStore } from '@metamask/obs-store';
import { bufferToHex, keccak } from 'ethereumjs-util';
import { generateUUID } from 'pubnub';
@ -6,6 +6,7 @@ import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app';
import {
METAMETRICS_ANONYMOUS_ID,
METAMETRICS_BACKGROUND_PAGE_OBJECT,
TRAITS,
} from '../../../shared/constants/metametrics';
import { SECOND } from '../../../shared/constants/time';
@ -303,150 +304,6 @@ export default class MetaMetricsController {
return this.store.getState();
}
/**
* Build the context object to attach to page and track events.
*
* @private
* @param {Pick<MetaMetricsContext, 'referrer'>} [referrer] - dapp origin that initialized
* the notification window.
* @param {Pick<MetaMetricsContext, 'page'>} [page] - page object describing the current
* view of the extension. Defaults to the background-process object.
* @returns {MetaMetricsContext}
*/
_buildContext(referrer, page = METAMETRICS_BACKGROUND_PAGE_OBJECT) {
return {
app: {
name: 'MetaMask Extension',
version: this.version,
},
userAgent: window.navigator.userAgent,
page,
referrer,
};
}
/**
* Build's the event payload, processing all fields into a format that can be
* fed to Segment's track method
*
* @private
* @param {
* Omit<MetaMetricsEventPayload, 'sensitiveProperties'>
* } rawPayload - raw payload provided to trackEvent
* @returns {SegmentEventPayload} formatted event payload for segment
*/
_buildEventPayload(rawPayload) {
const {
event,
properties,
revenue,
value,
currency,
category,
page,
referrer,
environmentType = ENVIRONMENT_TYPE_BACKGROUND,
} = rawPayload;
return {
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: properties?.network ?? this.network,
locale: this.locale,
chain_id: properties?.chain_id ?? this.chainId,
environment_type: environmentType,
},
context: this._buildContext(referrer, page),
};
}
/**
* Perform validation on the payload and update the id type to use before
* sending to Segment. Also examines the options to route and handle the
* event appropriately.
*
* @private
* @param {SegmentEventPayload} payload - properties to attach to event
* @param {MetaMetricsEventOptions} [options] - options for routing and
* handling the event
* @returns {Promise<void>}
*/
_track(payload, options) {
const {
isOptIn,
metaMetricsId: metaMetricsIdOverride,
matomoEvent,
flushImmediately,
} = options || {};
let idType = 'userId';
let idValue = this.state.metaMetricsId;
let excludeMetaMetricsId = options?.excludeMetaMetricsId ?? 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(payload.event.match(/^send|^confirm/iu));
if (isSendFlow) {
excludeMetaMetricsId = true;
}
// 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 || (isOptIn && !metaMetricsIdOverride)) {
idType = 'anonymousId';
idValue = METAMETRICS_ANONYMOUS_ID;
} else if (isOptIn && metaMetricsIdOverride) {
idValue = metaMetricsIdOverride;
}
payload[idType] = idValue;
// If this is an event on the old matomo schema, add a key to the payload
// to designate it as such
if (matomoEvent === true) {
payload.properties.legacy_event = true;
}
// Promises will only resolve when the event is sent to segment. For any
// event that relies on this promise being fulfilled before performing UI
// updates, or otherwise delaying user interaction, supply the
// 'flushImmediately' flag to the trackEvent method.
return new Promise((resolve, reject) => {
const callback = (err) => {
if (err) {
// The error that segment gives us has some manipulation done to it
// that seemingly breaks with lockdown enabled. Creating a new error
// here prevents the system from freezing when the network request to
// segment fails for any reason.
const safeError = new Error(err.message);
safeError.stack = err.stack;
return reject(safeError);
}
return resolve();
};
this.segment.track(payload, callback);
if (flushImmediately) {
this.segment.flush();
}
});
}
/**
* track a page view with Segment
*
@ -573,4 +430,185 @@ export default class MetaMetricsController {
);
}
}
handleMetaMaskStateUpdate(newState) {
const userTraits = this._buildUserTraitsObject(newState);
if (userTraits) {
// this.identify(userTraits);
}
}
/** PRIVATE METHODS */
/**
* Build the context object to attach to page and track events.
*
* @private
* @param {Pick<MetaMetricsContext, 'referrer'>} [referrer] - dapp origin that initialized
* the notification window.
* @param {Pick<MetaMetricsContext, 'page'>} [page] - page object describing the current
* view of the extension. Defaults to the background-process object.
* @returns {MetaMetricsContext}
*/
_buildContext(referrer, page = METAMETRICS_BACKGROUND_PAGE_OBJECT) {
return {
app: {
name: 'MetaMask Extension',
version: this.version,
},
userAgent: window.navigator.userAgent,
page,
referrer,
};
}
/**
* Build's the event payload, processing all fields into a format that can be
* fed to Segment's track method
*
* @private
* @param {
* Omit<MetaMetricsEventPayload, 'sensitiveProperties'>
* } rawPayload - raw payload provided to trackEvent
* @returns {SegmentEventPayload} formatted event payload for segment
*/
_buildEventPayload(rawPayload) {
const {
event,
properties,
revenue,
value,
currency,
category,
page,
referrer,
environmentType = ENVIRONMENT_TYPE_BACKGROUND,
} = rawPayload;
return {
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: properties?.network ?? this.network,
locale: this.locale,
chain_id: properties?.chain_id ?? this.chainId,
environment_type: environmentType,
},
context: this._buildContext(referrer, page),
};
}
_buildUserTraitsObject(metamaskState) {
const currentTraits = {
[TRAITS.LEDGER_CONNECTION_TYPE]: metamaskState.ledgerTransportType,
[TRAITS.NUMBER_OF_ACCOUNTS]: Object.values(metamaskState.identities)
.length,
[TRAITS.NETWORKS_ADDED]: metamaskState.frequentRpcListDetail.map(
(rpc) => rpc.chainId,
),
[TRAITS.THREE_BOX_ENABLED]: metamaskState.threeBoxSyncingAllowed,
};
if (!this.previousTraits) {
this.previousTraits = currentTraits;
return currentTraits;
}
if (this.previousTraits && !isEqual(this.previousTraits, currentTraits)) {
const updates = pickBy(
currentTraits,
(v, k) => !isEqual(this.previousTraits[k], v),
);
this.previousTraits = currentTraits;
return updates;
}
return null;
}
/**
* Perform validation on the payload and update the id type to use before
* sending to Segment. Also examines the options to route and handle the
* event appropriately.
*
* @private
* @param {SegmentEventPayload} payload - properties to attach to event
* @param {MetaMetricsEventOptions} [options] - options for routing and
* handling the event
* @returns {Promise<void>}
*/
_track(payload, options) {
const {
isOptIn,
metaMetricsId: metaMetricsIdOverride,
matomoEvent,
flushImmediately,
} = options || {};
let idType = 'userId';
let idValue = this.state.metaMetricsId;
let excludeMetaMetricsId = options?.excludeMetaMetricsId ?? 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(payload.event.match(/^send|^confirm/iu));
if (isSendFlow) {
excludeMetaMetricsId = true;
}
// 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 || (isOptIn && !metaMetricsIdOverride)) {
idType = 'anonymousId';
idValue = METAMETRICS_ANONYMOUS_ID;
} else if (isOptIn && metaMetricsIdOverride) {
idValue = metaMetricsIdOverride;
}
payload[idType] = idValue;
// If this is an event on the old matomo schema, add a key to the payload
// to designate it as such
if (matomoEvent === true) {
payload.properties.legacy_event = true;
}
// Promises will only resolve when the event is sent to segment. For any
// event that relies on this promise being fulfilled before performing UI
// updates, or otherwise delaying user interaction, supply the
// 'flushImmediately' flag to the trackEvent method.
return new Promise((resolve, reject) => {
const callback = (err) => {
if (err) {
// The error that segment gives us has some manipulation done to it
// that seemingly breaks with lockdown enabled. Creating a new error
// here prevents the system from freezing when the network request to
// segment fails for any reason.
const safeError = new Error(err.message);
safeError.stack = err.stack;
return reject(safeError);
}
return resolve();
};
this.segment.track(payload, callback);
if (flushImmediately) {
this.segment.flush();
}
});
}
}

View File

@ -5,8 +5,13 @@ import { createSegmentMock } from '../lib/segment';
import {
METAMETRICS_ANONYMOUS_ID,
METAMETRICS_BACKGROUND_PAGE_OBJECT,
TRAITS,
} from '../../../shared/constants/metametrics';
import waitUntilCalled from '../../../test/lib/wait-until-called';
import {
MAINNET_CHAIN_ID,
ROPSTEN_CHAIN_ID,
} from '../../../shared/constants/network';
import MetaMetricsController from './metametrics';
import { NETWORK_EVENTS } from './network';
@ -518,6 +523,80 @@ describe('MetaMetricsController', function () {
});
});
describe('_buildUserTraitsObject', function () {
it('should return full user traits object on first call', function () {
const metaMetricsController = getMetaMetricsController();
const traits = metaMetricsController._buildUserTraitsObject({
frequentRpcListDetail: [
{ chainId: MAINNET_CHAIN_ID },
{ chainId: ROPSTEN_CHAIN_ID },
],
ledgerTransportType: 'web-hid',
identities: [{}, {}],
threeBoxSyncingAllowed: false,
});
assert.deepEqual(traits, {
[TRAITS.THREE_BOX_ENABLED]: false,
[TRAITS.LEDGER_CONNECTION_TYPE]: 'web-hid',
[TRAITS.NUMBER_OF_ACCOUNTS]: 2,
[TRAITS.NETWORKS_ADDED]: [MAINNET_CHAIN_ID, ROPSTEN_CHAIN_ID],
});
});
it('should return only changed traits object on subsequent calls', function () {
const metaMetricsController = getMetaMetricsController();
metaMetricsController._buildUserTraitsObject({
frequentRpcListDetail: [
{ chainId: MAINNET_CHAIN_ID },
{ chainId: ROPSTEN_CHAIN_ID },
],
ledgerTransportType: 'web-hid',
identities: [{}, {}],
threeBoxSyncingAllowed: false,
});
const updatedTraits = metaMetricsController._buildUserTraitsObject({
frequentRpcListDetail: [
{ chainId: MAINNET_CHAIN_ID },
{ chainId: ROPSTEN_CHAIN_ID },
],
ledgerTransportType: 'web-hid',
identities: [{}, {}, {}],
threeBoxSyncingAllowed: false,
});
assert.deepEqual(updatedTraits, {
[TRAITS.NUMBER_OF_ACCOUNTS]: 3,
});
});
it('should return null if no traits changed', function () {
const metaMetricsController = getMetaMetricsController();
metaMetricsController._buildUserTraitsObject({
frequentRpcListDetail: [
{ chainId: MAINNET_CHAIN_ID },
{ chainId: ROPSTEN_CHAIN_ID },
],
ledgerTransportType: 'web-hid',
identities: [{}, {}],
threeBoxSyncingAllowed: false,
});
const updatedTraits = metaMetricsController._buildUserTraitsObject({
frequentRpcListDetail: [
{ chainId: MAINNET_CHAIN_ID },
{ chainId: ROPSTEN_CHAIN_ID },
],
ledgerTransportType: 'web-hid',
identities: [{}, {}],
threeBoxSyncingAllowed: false,
});
assert.equal(updatedTraits, null);
});
});
afterEach(function () {
// flush the queues manually after each test
segment.flush();

View File

@ -322,6 +322,10 @@ export default class MetamaskController extends EventEmitter {
captureException,
});
this.on('update', (update) => {
this.metaMetricsController.handleMetaMaskStateUpdate(update);
});
const gasFeeMessenger = this.controllerMessenger.getRestricted({
name: 'GasFeeController',
});