Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add simulation metrics to "Transaction Submitted" and "Transaction Finalized" events #28240

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 198 additions & 2 deletions app/scripts/controllers/metametrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { InternalAccount } from '@metamask/keyring-api';
import { Browser } from 'webextension-polyfill';
import { Hex } from '@metamask/utils';
import { merge } from 'lodash';
import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app';
import { createSegmentMock } from '../lib/segment';
import {
Expand Down Expand Up @@ -93,8 +94,19 @@ const DEFAULT_PAGE_PROPERTIES = {
...DEFAULT_SHARED_PROPERTIES,
};

const SAMPLE_PERSISTED_EVENT = {
id: 'testid',
const SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT = {
id: 'transaction-submitted-0000',
canDeleteIfAbandoned: true,
category: 'Unit Test',
successEvent: 'Transaction Finalized',
persist: true,
properties: {
simulation_response: 'no_balance_change',
test_stored_prop: 1,
},
};

const SAMPLE_PERSISTED_EVENT_NO_ID = {
persist: true,
category: 'Unit Test',
successEvent: 'sample persisted event success',
Expand All @@ -104,6 +116,11 @@ const SAMPLE_PERSISTED_EVENT = {
},
};

const SAMPLE_PERSISTED_EVENT = {
id: 'testid',
...SAMPLE_PERSISTED_EVENT_NO_ID,
};

const SAMPLE_NON_PERSISTED_EVENT = {
id: 'testid2',
persist: false,
Expand Down Expand Up @@ -255,6 +272,185 @@ describe('MetaMetricsController', function () {
});
});

describe('createEventFragment', function () {
it('should throw an error if the param is missing successEvent or category', async function () {
const metaMetricsController = getMetaMetricsController();

await expect(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error because we are testing the error case
metaMetricsController.createEventFragment({ event: 'test' });
}).toThrow(/Must specify success event and category\./u);

await expect(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error because we are testing the error case
metaMetricsController.createEventFragment({ category: 'test' });
}).toThrow(/Must specify success event and category\./u);
});

it('should update fragments state with new fragment', function () {
jest.useFakeTimers().setSystemTime(1730798301422);

const metaMetricsController = getMetaMetricsController();
const mockNewId = 'testid3';

metaMetricsController.createEventFragment({
...SAMPLE_PERSISTED_EVENT_NO_ID,
uniqueIdentifier: mockNewId,
});

const resultFragment = metaMetricsController.state.fragments[mockNewId];

expect(resultFragment).toStrictEqual({
...SAMPLE_PERSISTED_EVENT_NO_ID,
id: mockNewId,
uniqueIdentifier: mockNewId,
lastUpdated: 1730798301422,
});

jest.useRealTimers();
});

it('should track the initial event if provided', function () {
const metaMetricsController = getMetaMetricsController({
participateInMetaMetrics: true,
});
const spy = jest.spyOn(segmentMock, 'track');
const mockInitialEventName = 'Test Initial Event';

metaMetricsController.createEventFragment({
...SAMPLE_PERSISTED_EVENT_NO_ID,
initialEvent: mockInitialEventName,
});

expect(spy).toHaveBeenCalledTimes(1);
});

it('should not call track if no initialEvent was provided', function () {
const metaMetricsController = getMetaMetricsController({
participateInMetaMetrics: true,
});
const spy = jest.spyOn(segmentMock, 'track');

metaMetricsController.createEventFragment({
...SAMPLE_PERSISTED_EVENT_NO_ID,
});

expect(spy).toHaveBeenCalledTimes(0);
});

describe('when intialEvent is "Transaction Submitted" and a fragment exists before createEventFragment is called', function () {
it('should update existing fragment state with new fragment props', function () {
jest.useFakeTimers().setSystemTime(1730798302222);

const metaMetricsController = getMetaMetricsController();
const { id } = SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT;

metaMetricsController.updateEventFragment(
SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT.id,
{
...SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT,
},
);
metaMetricsController.createEventFragment({
...SAMPLE_PERSISTED_EVENT_NO_ID,
initialEvent: 'Transaction Submitted',
uniqueIdentifier: id,
});

const resultFragment = metaMetricsController.state.fragments[id];
const expectedFragment = merge(
SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT,
SAMPLE_PERSISTED_EVENT_NO_ID,
{
canDeleteIfAbandoned: false,
id,
initialEvent: 'Transaction Submitted',
uniqueIdentifier: id,
lastUpdated: 1730798302222,
},
);

expect(resultFragment).toStrictEqual(expectedFragment);

jest.useRealTimers();
});
});
});

describe('updateEventFragment', function () {
beforeEach(function () {
jest.useFakeTimers().setSystemTime(1730798303333);
});
afterEach(function () {
jest.useRealTimers();
});

it('updates fragment with additional provided props', async function () {
const metaMetricsController = getMetaMetricsController();
const MOCK_PROPS_TO_UPDATE = {
properties: {
test: 1,
},
};

metaMetricsController.updateEventFragment(
SAMPLE_PERSISTED_EVENT.id,
MOCK_PROPS_TO_UPDATE,
);

const resultFragment =
metaMetricsController.state.fragments[SAMPLE_PERSISTED_EVENT.id];
const expectedPartialFragment = {
...SAMPLE_PERSISTED_EVENT,
...MOCK_PROPS_TO_UPDATE,
lastUpdated: 1730798303333,
};
expect(resultFragment).toStrictEqual(expectedPartialFragment);
});

it('throws error when no existing fragment exists', async function () {
const metaMetricsController = getMetaMetricsController();

const MOCK_NONEXISTING_ID = 'test-nonexistingid';

await expect(() => {
metaMetricsController.updateEventFragment(MOCK_NONEXISTING_ID, {
properties: { test: 1 },
});
}).toThrow(/Event fragment with id test-nonexistingid does not exist\./u);
});

describe('when id includes "transaction-submitted"', function () {
it('creates and stores new fragment props with canDeleteIfAbandoned set to true', function () {
const metaMetricsController = getMetaMetricsController();
const MOCK_ID = 'transaction-submitted-1111';
const MOCK_PROPS_TO_UPDATE = {
properties: {
test: 1,
},
};

metaMetricsController.updateEventFragment(
MOCK_ID,
MOCK_PROPS_TO_UPDATE,
);

const resultFragment = metaMetricsController.state.fragments[MOCK_ID];
const expectedPartialFragment = {
...MOCK_PROPS_TO_UPDATE,
category: 'Transactions',
canDeleteIfAbandoned: true,
id: MOCK_ID,
lastUpdated: 1730798303333,
successEvent: 'Transaction Finalized',
};
expect(resultFragment).toStrictEqual(expectedPartialFragment);
});
});
});

describe('generateMetaMetricsId', function () {
it('should generate an 0x prefixed hex string', function () {
const metaMetricsController = getMetaMetricsController();
Expand Down
70 changes: 66 additions & 4 deletions app/scripts/controllers/metametrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app';
import {
METAMETRICS_ANONYMOUS_ID,
METAMETRICS_BACKGROUND_PAGE_OBJECT,
MetaMetricsEventCategory,
MetaMetricsEventName,
MetaMetricsEventFragment,
MetaMetricsUserTrait,
Expand Down Expand Up @@ -312,7 +313,7 @@ export default class MetaMetricsController {
// 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 });
this.processAbandonedFragment(fragment);
});

// Code below submits any pending segmentApiCalls to Segment if/when the controller is re-instantiated
Expand Down Expand Up @@ -368,7 +369,7 @@ export default class MetaMetricsController {
fragment.lastUpdated &&
Date.now() - fragment.lastUpdated / 1000 > fragment.timeout
) {
this.finalizeEventFragment(fragment.id, { abandoned: true });
this.processAbandonedFragment(fragment);
}
});
}
Expand Down Expand Up @@ -414,10 +415,30 @@ export default class MetaMetricsController {
...options,
lastUpdated: Date.now(),
};

/**
* HACK: "transaction-submitted-<id>" fragment hack
matthewwalsh0 marked this conversation as resolved.
Show resolved Hide resolved
* A "transaction-submitted-<id>" fragment may exist following the "Transaction Added"
* event to persist accumulated event fragment props to the "Transaction Submitted" event
* which fires after a user confirms a transaction. Rejecting a confirmation does not fire the
* "Transaction Submitted" event. In this case, these abandoned fragments will be deleted
* instead of finalized with canDeleteIfAbandoned set to true.
*/
const hasExistingSubmittedFragment =
options.initialEvent === TransactionMetaMetricsEvent.submitted &&
matthewwalsh0 marked this conversation as resolved.
Show resolved Hide resolved
fragments[id];

const additionalFragmentProps = hasExistingSubmittedFragment
? {
...fragments[id],
canDeleteIfAbandoned: false,
}
: {};

this.store.updateState({
fragments: {
...fragments,
[id]: fragment,
[id]: merge(additionalFragmentProps, fragment),
},
});

Expand Down Expand Up @@ -455,6 +476,19 @@ export default class MetaMetricsController {
return fragment;
}

/**
* Deletes to finalizes event fragment based on the canDeleteIfAbandoned property.
*
* @param fragment
*/
processAbandonedFragment(fragment: MetaMetricsEventFragment): void {
if (fragment.canDeleteIfAbandoned) {
this.deleteEventFragment(fragment.id);
} else {
this.finalizeEventFragment(fragment.id, { abandoned: true });
}
}

/**
* Updates an event fragment in state
*
Expand All @@ -469,7 +503,22 @@ export default class MetaMetricsController {

const fragment = fragments[id];

if (!fragment) {
/**
* HACK: "transaction-submitted-<id>" fragment hack
* Creates a "transaction-submitted-<id>" fragment if it does not exist to persist
* accumulated event metrics. In the case it is unused, the abandoned fragment will
* eventually be deleted with canDeleteIfAbandoned set to true.
*/
const createIfNotFound = !fragment && id.includes('transaction-submitted-');

if (createIfNotFound) {
fragments[id] = {
digiwand marked this conversation as resolved.
Show resolved Hide resolved
canDeleteIfAbandoned: true,
category: MetaMetricsEventCategory.Transactions,
digiwand marked this conversation as resolved.
Show resolved Hide resolved
successEvent: TransactionMetaMetricsEvent.finalized,
id,
};
} else if (!fragment) {
throw new Error(`Event fragment with id ${id} does not exist.`);
}

Expand All @@ -484,6 +533,19 @@ export default class MetaMetricsController {
});
}

/**
* Deletes an event fragment from state
*
* @param id - The fragment id to delete
*/
deleteEventFragment(id: string): void {
const { fragments } = this.store.getState();

if (fragments[id]) {
delete fragments[id];
}
}

/**
* Finalizes a fragment, tracking either a success event or failure Event
* and then removes the fragment from state.
Expand Down
Loading