diff --git a/app/scripts/controllers/metametrics.js b/app/scripts/controllers/metametrics.js index 5207eda11..a3fd4eead 100644 --- a/app/scripts/controllers/metametrics.js +++ b/app/scripts/controllers/metametrics.js @@ -281,6 +281,30 @@ export default class MetaMetricsController { this.store.updateState({ fragments }); } + /** + * Calls this._identify with validated metaMetricsId and user traits if user is participating + * in the MetaMetrics analytics program + * + * @param {Object} userTraits + */ + identify(userTraits) { + const { metaMetricsId, participateInMetaMetrics } = this.state; + + if (!participateInMetaMetrics || !metaMetricsId || !userTraits) { + return; + } + if (typeof userTraits !== 'object') { + console.warn( + `MetaMetricsController#identify: userTraits parameter must be an object. Received type: ${typeof userTraits}`, + ); + return; + } + + const allValidTraits = this._buildValidTraits(userTraits); + + this._identify(allValidTraits); + } + /** * Setter for the `participateInMetaMetrics` property * @@ -434,7 +458,7 @@ export default class MetaMetricsController { handleMetaMaskStateUpdate(newState) { const userTraits = this._buildUserTraitsObject(newState); if (userTraits) { - // this.identify(userTraits); + this.identify(userTraits); } } @@ -535,6 +559,103 @@ export default class MetaMetricsController { return null; } + /** + * Returns a new object of all valid user traits. For dates, we transform them into ISO-8601 timestamp strings. + * + * @see {@link https://segment.com/docs/connections/spec/common/#timestamps} + * @param {Object} userTraits + * @returns {Object} + */ + _buildValidTraits(userTraits) { + return Object.entries(userTraits).reduce((validTraits, [key, value]) => { + if (this._isValidTraitDate(value)) { + validTraits[key] = value.toISOString(); + } else if (this._isValidTrait(value)) { + validTraits[key] = value; + } else { + console.warn( + `MetaMetricsController: "${key}" value is not a valid trait type`, + ); + } + return validTraits; + }, {}); + } + + /** + * Calls segment.identify with given user traits + * + * @see {@link https://segment.com/docs/connections/sources/catalog/libraries/server/node/#identify} + * @private + * @param {Object} userTraits + */ + _identify(userTraits) { + const { metaMetricsId } = this.state; + + if (!userTraits || Object.keys(userTraits).length === 0) { + console.warn('MetaMetricsController#_identify: No userTraits found'); + return; + } + + try { + this.segment.identify({ + userId: metaMetricsId, + traits: userTraits, + }); + } catch (err) { + this._captureException(err); + } + } + + /** + * Validates the trait value. Segment accepts any data type. We are adding validation here to + * support data types for our Segment destination(s) e.g. MixPanel + * + * @param {*} value + * @returns {boolean} + */ + _isValidTrait(value) { + const type = typeof value; + + return ( + type === 'string' || + type === 'boolean' || + type === 'number' || + this._isValidTraitArray(value) || + this._isValidTraitDate(value) + ); + } + + /** + * Segment accepts any data type value. We have special logic to validate arrays. + * + * @param {*} value + * @returns {boolean} + */ + _isValidTraitArray = (value) => { + return ( + Array.isArray(value) && + (value.every((element) => { + return typeof element === 'string'; + }) || + value.every((element) => { + return typeof element === 'boolean'; + }) || + value.every((element) => { + return typeof element === 'number'; + })) + ); + }; + + /** + * Returns true if the value is an accepted date type + * + * @param {*} value + * @returns {boolean} + */ + _isValidTraitDate = (value) => { + return Object.prototype.toString.call(value) === '[object Date]'; + }; + /** * 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 diff --git a/app/scripts/controllers/metametrics.test.js b/app/scripts/controllers/metametrics.test.js index 3c3461aa3..9d5819f2d 100644 --- a/app/scripts/controllers/metametrics.test.js +++ b/app/scripts/controllers/metametrics.test.js @@ -23,6 +23,20 @@ const FAKE_CHAIN_ID = '0x1338'; const LOCALE = 'en_US'; const TEST_META_METRICS_ID = '0xabc'; +const MOCK_TRAITS = { + test_boolean: true, + test_string: 'abc', + test_number: 123, + test_bool_array: [true, true, false], + test_string_array: ['test', 'test', 'test'], + test_boolean_array: [1, 2, 3], +}; + +const MOCK_INVALID_TRAITS = { + test_null: null, + test_array_multi_types: [true, 'a', 1], +}; + const DEFAULT_TEST_CONTEXT = { app: { name: 'MetaMask Extension', version: VERSION }, page: METAMETRICS_BACKGROUND_PAGE_OBJECT, @@ -216,6 +230,78 @@ describe('MetaMetricsController', function () { }); }); + describe('identify', function () { + it('should call segment.identify for valid traits if user is participating in metametrics', async function () { + const metaMetricsController = getMetaMetricsController({ + participateInMetaMetrics: true, + metaMetricsId: TEST_META_METRICS_ID, + }); + const mock = sinon.mock(segment); + + mock + .expects('identify') + .once() + .withArgs({ userId: TEST_META_METRICS_ID, traits: MOCK_TRAITS }); + + metaMetricsController.identify({ + ...MOCK_TRAITS, + ...MOCK_INVALID_TRAITS, + }); + mock.verify(); + }); + + it('should transform date type traits into ISO-8601 timestamp strings', async function () { + const metaMetricsController = getMetaMetricsController({ + participateInMetaMetrics: true, + metaMetricsId: TEST_META_METRICS_ID, + }); + const mock = sinon.mock(segment); + + const mockDate = new Date(); + const mockDateISOString = mockDate.toISOString(); + + mock + .expects('identify') + .once() + .withArgs({ + userId: TEST_META_METRICS_ID, + traits: { + test_date: mockDateISOString, + }, + }); + + metaMetricsController.identify({ + test_date: mockDate, + }); + mock.verify(); + }); + + it('should not call segment.identify if user is not participating in metametrics', function () { + const metaMetricsController = getMetaMetricsController({ + participateInMetaMetrics: false, + }); + const mock = sinon.mock(segment); + + mock.expects('identify').never(); + + metaMetricsController.identify(MOCK_TRAITS); + mock.verify(); + }); + + it('should not call segment.identify if there are no valid traits to identify', async function () { + const metaMetricsController = getMetaMetricsController({ + participateInMetaMetrics: true, + metaMetricsId: TEST_META_METRICS_ID, + }); + const mock = sinon.mock(segment); + + mock.expects('identify').never(); + + metaMetricsController.identify(MOCK_INVALID_TRAITS); + mock.verify(); + }); + }); + describe('setParticipateInMetaMetrics', function () { it('should update the value of participateInMetaMetrics', function () { const metaMetricsController = getMetaMetricsController({