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

Implement event fragments (#12251)

This commit is contained in:
Brad Decker 2022-01-12 13:31:54 -06:00 committed by GitHub
parent ff27f24ef9
commit b820ef131b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 707 additions and 10 deletions

View File

@ -1,11 +1,13 @@
import { merge, omit } from 'lodash';
import { merge, omit, omitBy } from 'lodash';
import { ObservableStore } from '@metamask/obs-store';
import { bufferToHex, keccak } from 'ethereumjs-util';
import { generateUUID } from 'pubnub';
import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app';
import {
METAMETRICS_ANONYMOUS_ID,
METAMETRICS_BACKGROUND_PAGE_OBJECT,
} from '../../../shared/constants/metametrics';
import { SECOND } from '../../../shared/constants/time';
const defaultCaptureException = (err) => {
// throw error on clean stack so its captured by platform integrations (eg sentry)
@ -27,15 +29,18 @@ const exceptionsToFilter = {
* @typedef {import('../../../shared/constants/metametrics').SegmentInterface} SegmentInterface
* @typedef {import('../../../shared/constants/metametrics').MetaMetricsPagePayload} MetaMetricsPagePayload
* @typedef {import('../../../shared/constants/metametrics').MetaMetricsPageOptions} MetaMetricsPageOptions
* @typedef {import('../../../shared/constants/metametrics').MetaMetricsEventFragment} MetaMetricsEventFragment
*/
/**
* @typedef {Object} MetaMetricsControllerState
* @property {?string} metaMetricsId - The user's metaMetricsId that will be
* @property {string} [metaMetricsId] - The user's metaMetricsId that will be
* attached to all non-anonymized event payloads
* @property {?boolean} participateInMetaMetrics - The user's preference for
* @property {boolean} [participateInMetaMetrics] - The user's preference for
* participating in the MetaMetrics analytics program. This setting controls
* whether or not events are tracked
* @property {{[string]: MetaMetricsEventFragment}} [fragments] - Object keyed
* by UUID with stored fragments as values.
*/
export default class MetaMetricsController {
@ -81,10 +86,15 @@ export default class MetaMetricsController {
this.version =
environment === 'production' ? version : `${version}-${environment}`;
const abandonedFragments = omitBy(initState?.fragments, 'persist');
this.store = new ObservableStore({
participateInMetaMetrics: null,
metaMetricsId: null,
...initState,
fragments: {
...initState?.fragments,
},
});
preferencesStore.subscribe(({ currentLocale }) => {
@ -96,6 +106,32 @@ export default class MetaMetricsController {
this.network = getNetworkIdentifier();
});
this.segment = segment;
// Track abandoned fragments that weren't properly cleaned up.
// Abandoned fragments are those that were stored in persistent memory
// and are available at controller instance creation, but do not have the
// 'persist' flag set. This means anytime the extension is unlocked, any
// fragments that are not marked as persistent will be purged and the
// failure event will be emitted.
Object.values(abandonedFragments).forEach((fragment) => {
this.finalizeEventFragment(fragment.id, { abandoned: true });
});
// Close out event fragments that were created but not progressed. An
// interval is used to routinely check if a fragment has not been updated
// within the fragment's timeout window. When creating a new event fragment
// a timeout can be specified that will cause an abandoned event to be
// tracked if the event isn't progressed within that amount of time.
setInterval(() => {
this.store.getState().fragments.forEach((fragment) => {
if (
fragment.timeout &&
Date.now() - fragment.lastUpdated / 1000 > fragment.timeout
) {
this.finalizeEventFragment(fragment.id, { abandoned: true });
}
});
}, SECOND * 30);
}
generateMetaMetricsId() {
@ -109,6 +145,110 @@ export default class MetaMetricsController {
);
}
/**
* Create an event fragment in state and returns the event fragment object.
*
* @param {MetaMetricsFunnel} options - Fragment settings and properties
* to initiate the fragment with.
* @returns {MetaMetricsFunnel}
*/
createEventFragment(options) {
if (!options.successEvent || !options.category) {
throw new Error(
`Must specify success event and category. Success event was: ${
options.event
}. Category was: ${options.category}. Payload keys were: ${Object.keys(
options,
)}. ${
typeof options.properties === 'object'
? `Payload property keys were: ${Object.keys(options.properties)}`
: ''
}`,
);
}
const { fragments } = this.store.getState();
const id = generateUUID();
const fragment = {
id,
...options,
lastUpdated: Date.now(),
};
this.store.updateState({
fragments: {
...fragments,
[id]: fragment,
},
});
return fragment;
}
/**
* Updates an event fragment in state
*
* @param {string} id - The fragment id to update
* @param {MetaMetricsEventFragment} payload - Fragment settings and
* properties to initiate the fragment with.
*/
updateEventFragment(id, payload) {
const { fragments } = this.store.getState();
const fragment = fragments[id];
if (!fragment) {
throw new Error(`Event fragment with id ${id} does not exist.`);
}
this.store.updateState({
fragments: {
...fragments,
[id]: merge(fragments[id], {
...payload,
lastUpdated: Date.now(),
}),
},
});
}
/**
* Finalizes a fragment, tracking either a success event or failure Event
* and then removes the fragment from state.
*
* @param {string} id - UUID of the event fragment to be closed
* @param {object} options
* @param {boolean} [options.abandoned] - if true track the failure
* event instead of the success event
* @param {MetaMetricsContext.page} [options.page] - page the final event
* occurred on. This will override whatever is set on the fragment
* @param {MetaMetricsContext.referrer} [options.referrer] - Dapp that
* originated the fragment. This is for fallback only, the fragment referrer
* property will take precedence.
*/
finalizeEventFragment(id, { abandoned = false, page, referrer }) {
const fragment = this.store.getState().fragments[id];
if (!fragment) {
throw new Error(`Funnel with id ${id} does not exist.`);
}
const eventName = abandoned ? fragment.failureEvent : fragment.successEvent;
this.trackEvent({
event: eventName,
category: fragment.category,
properties: fragment.properties,
sensitiveProperties: fragment.sensitiveProperties,
page: page ?? fragment.page,
referrer: fragment.referrer ?? referrer,
revenue: fragment.revenue,
value: fragment.value,
currency: fragment.currency,
environmentType: fragment.environmentType,
});
const { fragments } = this.store.getState();
delete fragments[id];
this.store.updateState({ fragments });
}
/**
* Setter for the `participateInMetaMetrics` property
*

View File

@ -81,6 +81,28 @@ function getMockPreferencesStore({ currentLocale = LOCALE } = {}) {
};
}
const SAMPLE_PERSISTED_EVENT = {
id: 'testid',
persist: true,
category: 'Unit Test',
successEvent: 'sample persisted event success',
failureEvent: 'sample persisted event failure',
properties: {
test: true,
},
};
const SAMPLE_NON_PERSISTED_EVENT = {
id: 'testid2',
persist: false,
category: 'Unit Test',
successEvent: 'sample non-persisted event success',
failureEvent: 'sample non-persisted event failure',
properties: {
test: true,
},
};
function getMetaMetricsController({
participateInMetaMetrics = true,
metaMetricsId = TEST_META_METRICS_ID,
@ -105,12 +127,29 @@ function getMetaMetricsController({
initState: {
participateInMetaMetrics,
metaMetricsId,
fragments: {
testid: SAMPLE_PERSISTED_EVENT,
testid2: SAMPLE_NON_PERSISTED_EVENT,
},
},
});
}
describe('MetaMetricsController', function () {
describe('constructor', function () {
it('should properly initialize', function () {
const mock = sinon.mock(segment);
mock
.expects('track')
.once()
.withArgs({
event: 'sample non-persisted event failure',
userId: TEST_META_METRICS_ID,
context: DEFAULT_TEST_CONTEXT,
properties: {
...DEFAULT_EVENT_PROPERTIES,
test: true,
},
});
const metaMetricsController = getMetaMetricsController();
assert.strictEqual(metaMetricsController.version, VERSION);
assert.strictEqual(metaMetricsController.network, NETWORK);
@ -127,6 +166,10 @@ describe('MetaMetricsController', function () {
metaMetricsController.locale,
LOCALE.replace('_', '-'),
);
assert.deepStrictEqual(metaMetricsController.state.fragments, {
testid: SAMPLE_PERSISTED_EVENT,
});
mock.verify();
});
it('should update when network changes', function () {

View File

@ -1408,6 +1408,15 @@ export default class MetamaskController extends EventEmitter {
trackMetaMetricsPage: metaMetricsController.trackPage.bind(
metaMetricsController,
),
createEventFragment: metaMetricsController.createEventFragment.bind(
metaMetricsController,
),
updateEventFragment: metaMetricsController.updateEventFragment.bind(
metaMetricsController,
),
finalizeEventFragment: metaMetricsController.finalizeEventFragment.bind(
metaMetricsController,
),
// approval controller
resolvePendingApproval: approvalController.accept.bind(

View File

@ -82,6 +82,39 @@
* segment source that marks the event data as not conforming to our schema
*/
/**
* @typedef {Object} MetaMetricsEventFragment
* @property {string} successEvent - The event name to fire when the fragment
* is closed in an affirmative action.
* @property {string} [failureEvent] - The event name to fire when the fragment
* is closed with a rejection.
* @property {string} category - the event category to use for both the success
* and failure events
* @property {boolean} [persist] - Should this fragment be persisted in
* state and progressed after the extension is locked and unlocked.
* @property {number} [timeout] - Time in seconds the event should be persisted
* for. After the timeout the fragment will be closed as abandoned. if not
* supplied the fragment is stored indefinitely.
* @property {number} [lastUpdated] - Date.now() when the fragment was last
* updated. Used to determine if the timeout has expired and the fragment
* should be closed.
* @property {object} [properties] - Object of custom values to track, keys in
* this object must be in snake_case.
* @property {object} [sensitiveProperties] - Object of sensitive values to
* track. Keys in this object must be in 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 if fragment is successful.
* @property {string} [currency] - ISO 4127 format currency for events with
* revenue, defaults to US dollars
* @property {number} [value] - Abstract business "value" attributable to
* customers who successfully complete this fragment
* @property {MetaMetricsPageObject} [page] - the page/route that the event
* occurred on
* @property {MetaMetricsReferrerObject} [referrer] - the origin of the dapp
* that initiated the event fragment.
*/
/**
* Represents the shape of data sent to the segment.track method.
*

View File

@ -3,7 +3,6 @@ import React, {
createContext,
useEffect,
useCallback,
useContext,
useState,
} from 'react';
import { useSelector } from 'react-redux';
@ -125,10 +124,6 @@ export function MetaMetricsProvider({ children }) {
MetaMetricsProvider.propTypes = { children: PropTypes.node };
export function useMetaMetricsContext() {
return useContext(MetaMetricsContext);
}
export class LegacyMetaMetricsProvider extends Component {
static propTypes = {
children: PropTypes.node,

View File

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { TRANSACTION_TYPES } from '../../shared/constants/transaction';
import { getMethodName } from '../helpers/utils/metrics';
import { useGasFeeContext } from './gasFee';
import { useMetaMetricsContext } from './metametrics';
import { MetaMetricsContext } from './metametrics';
export const TransactionModalContext = createContext({});
@ -15,7 +15,7 @@ export const TransactionModalContextProvider = ({
captureEventEnabled = true,
}) => {
const [openModals, setOpenModals] = useState([]);
const metricsEvent = useMetaMetricsContext();
const metricsEvent = useContext(MetaMetricsContext);
const { transaction: { origin } = {} } = useGasFeeContext();
const captureEvent = () => {

View File

@ -0,0 +1,100 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { getEnvironmentType } from '../../app/scripts/lib/util';
import { selectMatchingFragment } from '../selectors';
import {
finalizeEventFragment,
createEventFragment,
updateEventFragment,
} from '../store/actions';
import { useMetaMetricsContext } from './useMetricEvent';
/**
* Retrieves a fragment from memory or initializes new fragment if one does not
* exist. Returns three methods that are tied to the fragment, as well as the
* fragment id.
*
* @param {string} existingId
* @param {Object} fragmentOptions
* @returns
*/
export function useEventFragment(existingId, fragmentOptions) {
// To prevent overcalling the createEventFragment background method a ref
// is used to store a boolean value of whether we have already called the
// method.
const createEventFragmentCalled = useRef(false);
// In order to immediately return a created fragment, instead of waiting for
// background state to update and find the newly created fragment, we have a
// state element that is updated with the fragmentId returned from the
// call into the background process.
const [createdFragmentId, setCreatedFragmentId] = useState(undefined);
// Select a matching fragment from state if one exists that matches the
// criteria. If an existingId is passed in it is preferred, if not and the
// fragmentOptions has the persist key set to true, a fragment with matching
// successEvent will be pulled from memory if it exists.
const fragment = useSelector((state) =>
selectMatchingFragment(state, {
fragmentOptions,
existingId: existingId ?? createdFragmentId,
}),
);
// If no valid existing fragment can be found, a new one must be created that
// will then be found by the selector above. To do this, invoke the
// createEventFragment method with the fragmentOptions and current sessionId.
// As soon as we call the background method we also update the
// createEventFragmentCalled ref's current value to true so that future calls
// are suppressed.
useEffect(() => {
if (fragment === undefined && createEventFragmentCalled.current === false) {
createEventFragmentCalled.current = true;
createEventFragment({
...fragmentOptions,
environmentType: getEnvironmentType(),
}).then((createdFragment) => {
setCreatedFragmentId(createdFragment.id);
});
}
}, [fragment, fragmentOptions]);
const context = useMetaMetricsContext();
/**
* trackSuccess is used to close a fragment with the affirmative action. This
* method is just a thin wrapper around the background method that sets the
* necessary values.
*/
const trackSuccess = useCallback(() => {
finalizeEventFragment(fragment.id, { context });
}, [fragment, context]);
/**
* trackFailure is used to close a fragment as abandoned. This method is just a
* thin wrapper around the background method that sets the necessary values.
*/
const trackFailure = useCallback(() => {
finalizeEventFragment(fragment.id, { abandoned: true, context });
}, [fragment, context]);
/**
* updateEventFragmentProperties is a thin wrapper around updateEventFragment
* that supplies the fragment id as the first parameter. This function will
* be passed back from the hook as 'updateEventFragment', but is named
* updateEventFragmentProperties to avoid naming conflicts.
*/
const updateEventFragmentProperties = useCallback(
(payload) => {
updateEventFragment(fragment.id, payload);
},
[fragment],
);
return {
trackSuccess,
trackFailure,
updateEventFragment: updateEventFragmentProperties,
fragment,
};
}

View File

@ -0,0 +1,211 @@
import { renderHook } from '@testing-library/react-hooks';
import { useSelector } from 'react-redux';
import {
finalizeEventFragment,
createEventFragment,
updateEventFragment,
} from '../store/actions';
import { useEventFragment } from './useEventFragment';
jest.mock('../store/actions', () => ({
finalizeEventFragment: jest.fn(),
updateEventFragment: jest.fn(),
createEventFragment: jest.fn(),
}));
jest.mock('./useMetricEvent', () => ({
useMetaMetricsContext: jest.fn(() => ({ page: '/' })),
}));
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
describe('useEventFragment', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('return shape', () => {
let value;
beforeAll(async () => {
useSelector.mockImplementation((selector) =>
selector({ metamask: { fragments: { testid: { id: 'testid' } } } }),
);
createEventFragment.mockImplementation(() =>
Promise.resolve({
id: 'testid',
}),
);
const { result, waitForNextUpdate } = renderHook(() =>
useEventFragment(undefined, {
successEvent: 'success',
failureEvent: 'failure',
persist: true,
}),
);
await waitForNextUpdate();
value = result.current;
});
it('should have trackSuccess method', () => {
expect(value).toHaveProperty('trackSuccess');
expect(typeof value.trackSuccess).toBe('function');
});
it('should have trackFailure method', () => {
expect(value).toHaveProperty('trackFailure');
expect(typeof value.trackFailure).toBe('function');
});
it('should have updateEventFragment method', () => {
expect(value).toHaveProperty('updateEventFragment');
expect(typeof value.updateEventFragment).toBe('function');
});
it('should have fragment property', () => {
expect(value).toHaveProperty('fragment');
expect(value.fragment).toMatchObject({
id: 'testid',
});
});
});
describe('identifying appropriate fragment', () => {
it('should create a new fragment when a matching fragment does not exist', async () => {
useSelector.mockImplementation((selector) =>
selector({
metamask: {
fragments: {
testid: {
id: 'testid',
successEvent: 'success',
failureEvent: 'failure',
},
},
},
}),
);
createEventFragment.mockImplementation(() =>
Promise.resolve({
id: 'testid',
}),
);
const { result, waitForNextUpdate } = renderHook(() =>
useEventFragment(undefined, {
successEvent: 'success',
failureEvent: 'failure',
}),
);
await waitForNextUpdate();
expect(createEventFragment).toHaveBeenCalledTimes(1);
const returnValue = result.current;
expect(returnValue.fragment).toMatchObject({
id: 'testid',
successEvent: 'success',
failureEvent: 'failure',
});
});
it('should return the matching fragment by id when existingId is provided', async () => {
useSelector.mockImplementation((selector) =>
selector({
metamask: {
fragments: {
testid: {
id: 'testid',
successEvent: 'success',
failureEvent: 'failure',
},
},
},
}),
);
const { result } = renderHook(() =>
useEventFragment('testid', {
successEvent: 'success',
failureEvent: 'failure',
}),
);
const returnValue = result.current;
expect(returnValue.fragment).toMatchObject({
id: 'testid',
successEvent: 'success',
failureEvent: 'failure',
});
});
it('should return matching fragment by successEvent when no id is provided, but persist is true', async () => {
useSelector.mockImplementation((selector) =>
selector({
metamask: {
fragments: {
testid: {
persist: true,
id: 'testid',
successEvent: 'track new event',
},
},
},
}),
);
const { result } = renderHook(() =>
useEventFragment(undefined, {
successEvent: 'track new event',
persist: true,
}),
);
const returnValue = result.current;
expect(returnValue.fragment).toMatchObject({
id: 'testid',
persist: true,
successEvent: 'track new event',
});
});
});
describe('methods', () => {
let value;
beforeAll(async () => {
useSelector.mockImplementation((selector) =>
selector({ metamask: { fragments: { testid: { id: 'testid' } } } }),
);
createEventFragment.mockImplementation(() =>
Promise.resolve({
id: 'testid',
}),
);
const { result, waitForNextUpdate } = renderHook(() =>
useEventFragment(undefined, {
successEvent: 'success',
failureEvent: 'failure',
persist: true,
}),
);
await waitForNextUpdate();
value = result.current;
});
it('trackSuccess method should invoke the background finalizeEventFragment method', () => {
value.trackSuccess();
expect(finalizeEventFragment).toHaveBeenCalledWith('testid', {
context: { page: '/' },
});
});
it('trackFailure method should invoke the background finalizeEventFragment method', () => {
value.trackFailure();
expect(finalizeEventFragment).toHaveBeenCalledWith('testid', {
abandoned: true,
context: { page: '/' },
});
});
it('updateEventFragment method should invoke the background updateEventFragment method', () => {
value.updateEventFragment({ properties: { count: 1 } });
expect(updateEventFragment).toHaveBeenCalledWith('testid', {
properties: { count: 1 },
});
});
});
});

View File

@ -1,6 +1,10 @@
import { useContext, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router-dom';
import { MetaMetricsContext } from '../contexts/metametrics';
import { MetaMetricsContext as NewMetaMetricsContext } from '../contexts/metametrics.new';
import { PATH_NAME_MAP } from '../helpers/constants/routes';
import { txDataSelector } from '../selectors';
import { useEqualityCheck } from './useEqualityCheck';
// Type imports
@ -38,3 +42,45 @@ export function useNewMetricEvent(payload, options) {
memoizedOptions,
]);
}
const PATHS_TO_CHECK = Object.keys(PATH_NAME_MAP);
/**
* Returns the current page if it matches our route map, as well as the origin
* if there is a confirmation that was triggered by a dapp. These values are
* not required but add valuable context to events, and should be included in
* the context object on the event payload.
*
* @returns {{
* page?: MetaMetricsPageObject
* referrer?: MetaMetricsReferrerObject
* }}
*/
export function useMetaMetricsContext() {
const match = useRouteMatch({
path: PATHS_TO_CHECK,
exact: true,
strict: true,
});
const txData = useSelector(txDataSelector) || {};
const confirmTransactionOrigin = txData.origin;
const referrer = confirmTransactionOrigin
? {
url: confirmTransactionOrigin,
}
: undefined;
const page = match
? {
path: match.path,
title: PATH_NAME_MAP[match.path],
url: match.path,
}
: undefined;
return {
page,
referrer,
};
}

View File

@ -1,6 +1,7 @@
export * from './confirm-transaction';
export * from './custom-gas';
export * from './first-time-flow';
export * from './metametrics';
export * from './permissions';
export * from './selectors';
export * from './transactions';

View File

@ -0,0 +1,36 @@
import { createSelector } from 'reselect';
export const selectFragments = (state) => state.metamask.fragments;
export const selectFragmentBySuccessEvent = createSelector(
selectFragments,
(_, fragmentOptions) => fragmentOptions,
(fragments, fragmentOptions) => {
if (fragmentOptions.persist) {
return Object.values(fragments).find(
(fragment) => fragment.successEvent === fragmentOptions.successEvent,
);
}
return undefined;
},
);
export const selectFragmentById = createSelector(
selectFragments,
(_, fragmentId) => fragmentId,
(fragments, fragmentId) => {
// A valid existing fragment must exist in state.
// If these conditions are not meant we will create a new fragment.
if (fragmentId && fragments?.[fragmentId]) {
return fragments[fragmentId];
}
return undefined;
},
);
export const selectMatchingFragment = createSelector(
(state, params) =>
selectFragmentBySuccessEvent(state, params.fragmentOptions),
(state, params) => selectFragmentById(state, params.existingId),
(matchedBySuccessEvent, matchedById) => matchedById ?? matchedBySuccessEvent,
);

View File

@ -0,0 +1,71 @@
const {
selectFragmentBySuccessEvent,
selectFragmentById,
selectMatchingFragment,
} = require('.');
describe('selectFragmentBySuccessEvent', () => {
it('should find matching fragment in state by successEvent', () => {
const state = {
metamask: {
fragments: {
randomid: {
successEvent: 'example event',
persist: true,
id: 'randomid',
},
},
},
};
const selected = selectFragmentBySuccessEvent(state, {
successEvent: 'example event',
persist: true,
});
expect(selected).toHaveProperty('id', 'randomid');
});
});
describe('selectFragmentById', () => {
it('should find matching fragment in state by id', () => {
const state = {
metamask: {
fragments: {
randomid: {
successEvent: 'example event',
persist: true,
id: 'randomid',
},
},
},
};
const selected = selectFragmentById(state, 'randomid');
expect(selected).toHaveProperty('id', 'randomid');
});
});
describe('selectMatchingFragment', () => {
it('should find matching fragment in state by id', () => {
const state = {
metamask: {
fragments: {
notthecorrectid: {
successEvent: 'event name',
id: 'notthecorrectid',
},
randomid: {
successEvent: 'example event',
persist: true,
id: 'randomid',
},
},
},
};
const selected = selectMatchingFragment(state, {
fragmentOptions: {
successEvent: 'event name',
},
existingId: 'randomid',
});
expect(selected).toHaveProperty('id', 'randomid');
});
});

View File

@ -3072,6 +3072,18 @@ export function trackMetaMetricsEvent(payload, options) {
return promisifiedBackground.trackMetaMetricsEvent(payload, options);
}
export function createEventFragment(options) {
return promisifiedBackground.createEventFragment(options);
}
export function updateEventFragment(id, payload) {
return promisifiedBackground.updateEventFragment(id, payload);
}
export function finalizeEventFragment(id, options) {
return promisifiedBackground.finalizeEventFragment(id, options);
}
/**
* @param {MetaMetricsPagePayload} payload - details of the page viewed
* @param {MetaMetricsPageOptions} options - options for handling the page view