From f3dcf3e664535aac75275a7ecb5f9e9cc91bf759 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Fri, 15 May 2026 17:29:09 +0200 Subject: [PATCH] Add analytics context forwarding --- packages/analytics-controller/CHANGELOG.md | 1 + ...AnalyticsController-method-action-types.ts | 3 + .../src/AnalyticsController.test.ts | 138 ++++++++++++++++++ .../src/AnalyticsController.ts | 50 +++++-- .../src/AnalyticsPlatformAdapter.types.ts | 26 +++- packages/analytics-controller/src/index.ts | 1 + 6 files changed, 206 insertions(+), 13 deletions(-) diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 016e0fc8f9..8ee49f43d4 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add optional analytics context on `trackEvent`, `identify`, and `trackView` to forward platform-specific context to `AnalyticsPlatformAdapter` implementations ([#8835](https://github.com/MetaMask/core/pull/8835)) - Optional `skipUUIDv4Check` on `AnalyticsPlatformAdapter` to allow non-UUIDv4 `analyticsId` strings when constructing `AnalyticsController` ([#8543](https://github.com/MetaMask/core/pull/8543)) ### Changed diff --git a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts index 45a8f83782..75aec7cb17 100644 --- a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts +++ b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts @@ -11,6 +11,7 @@ import type { AnalyticsController } from './AnalyticsController'; * Events are only tracked if analytics is enabled. * * @param event - Analytics event with properties and sensitive properties + * @param context - Optional platform-specific context forwarded to the platform adapter. */ export type AnalyticsControllerTrackEventAction = { type: `AnalyticsController:trackEvent`; @@ -21,6 +22,7 @@ export type AnalyticsControllerTrackEventAction = { * Identify a user for analytics. * * @param traits - User traits/properties + * @param context - Optional platform-specific context forwarded to the platform adapter. */ export type AnalyticsControllerIdentifyAction = { type: `AnalyticsController:identify`; @@ -32,6 +34,7 @@ export type AnalyticsControllerIdentifyAction = { * * @param name - The identifier/name of the page or screen being viewed (e.g., "home", "settings", "wallet") * @param properties - Optional properties associated with the view + * @param context - Optional platform-specific context forwarded to the platform adapter. */ export type AnalyticsControllerTrackViewAction = { type: `AnalyticsController:trackView`; diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 16744874a6..7b8d5005fa 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -15,6 +15,7 @@ import type { AnalyticsPlatformAdapter, AnalyticsTrackingEvent, AnalyticsControllerState, + AnalyticsContext, } from '.'; import { isValidUUIDv4 } from './analyticsControllerStateValidator'; @@ -692,6 +693,54 @@ describe('AnalyticsController', () => { }); }); + it('forwards context to the platform adapter', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '77777777-7777-4777-b777-777777777777', + }, + platformAdapter: mockAdapter, + }); + + const event = createTestEvent('test_event', { prop: 'value' }); + const context: AnalyticsContext = { + page: { title: 'Unit test' }, + }; + + controller.trackEvent(event, context); + + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + { prop: 'value' }, + context, + ); + }); + + it('forwards context when tracking an event without properties', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '77777777-7777-4777-9777-777777777777', + }, + platformAdapter: mockAdapter, + }); + + const event = createTestEvent('test_event', {}, {}, true); + const context: AnalyticsContext = { + page: { path: '/background-process' }, + }; + + controller.trackEvent(event, context); + + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + context, + ); + }); + it('tracks event without properties when event has no properties', async () => { const mockAdapter = createMockAdapter(); const { controller } = await setupController({ @@ -805,6 +854,47 @@ describe('AnalyticsController', () => { }); }); + it('forwards context to both events when splitting sensitive events', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '11111111-1111-4111-9111-111111111111', + }, + platformAdapter: mockAdapter, + isAnonymousEventsFeatureEnabled: true, + }); + + const event = createTestEvent( + 'test_event', + { prop: 'value' }, + { sensitive_prop: 'sensitive value' }, + ); + const context: AnalyticsContext = { + app: { name: 'MetaMask' }, + }; + + controller.trackEvent(event, context); + + expect(mockAdapter.track).toHaveBeenCalledTimes(2); + expect(mockAdapter.track).toHaveBeenNthCalledWith( + 1, + 'test_event', + { prop: 'value' }, + context, + ); + expect(mockAdapter.track).toHaveBeenNthCalledWith( + 2, + 'test_event', + { + prop: 'value', + sensitive_prop: 'sensitive value', + anonymous: true, + }, + context, + ); + }); + it('tracks regular properties first, then combined event when only sensitive properties are present', async () => { const mockAdapter = createMockAdapter(); const { controller } = await setupController({ @@ -912,6 +1002,31 @@ describe('AnalyticsController', () => { expect(mockAdapter.identify).toHaveBeenCalledWith(analyticsId, undefined); }); + it('forwards context to the platform adapter', async () => { + const mockAdapter = createMockAdapter(); + const analyticsId = 'dddddddd-dddd-4ddd-9ddd-dddddddddddd'; + const { controller } = await setupController({ + state: { + analyticsId, + optedIn: true, + }, + platformAdapter: mockAdapter, + }); + + const traits = { PLAN: 'pro' }; + const context: AnalyticsContext = { + locale: 'en', + }; + + controller.identify(traits, context); + + expect(mockAdapter.identify).toHaveBeenCalledWith( + analyticsId, + traits, + context, + ); + }); + it('does not identify when disabled', async () => { const mockAdapter = createMockAdapter(); const { controller } = await setupController({ @@ -951,6 +1066,29 @@ describe('AnalyticsController', () => { }); }); + it('forwards context to the platform adapter', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: 'ffffffff-ffff-4fff-afff-ffffffffffff', + }, + platformAdapter: mockAdapter, + }); + + const context: AnalyticsContext = { + page: { title: 'Settings' }, + }; + + controller.trackView('settings', { section: 'security' }, context); + + expect(mockAdapter.view).toHaveBeenCalledWith( + 'settings', + { section: 'security' }, + context, + ); + }); + it('does not call platform adapter when disabled', async () => { const mockAdapter = createMockAdapter(); const { controller } = await setupController({ diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 182386d7ba..806b08d73b 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -11,6 +11,7 @@ import { validateAnalyticsControllerState } from './analyticsControllerStateVali import { projectLogger as log } from './AnalyticsLogger'; import type { AnalyticsPlatformAdapter, + AnalyticsContext, AnalyticsEventProperties, AnalyticsUserTraits, AnalyticsTrackingEvent, @@ -273,8 +274,9 @@ export class AnalyticsController extends BaseController< * Events are only tracked if analytics is enabled. * * @param event - Analytics event with properties and sensitive properties + * @param context - Optional platform-specific context forwarded to the platform adapter. */ - trackEvent(event: AnalyticsTrackingEvent): void { + trackEvent(event: AnalyticsTrackingEvent, context?: AnalyticsContext): void { // Don't track if analytics is disabled if (!analyticsControllerSelectors.selectEnabled(this.state)) { return; @@ -283,7 +285,11 @@ export class AnalyticsController extends BaseController< // if event does not have properties, send event without properties // and return to prevent any additional processing if (!event.hasProperties) { - this.#platformAdapter.track(event.name); + if (context) { + this.#platformAdapter.track(event.name, undefined, context); + } else { + this.#platformAdapter.track(event.name); + } return; } @@ -291,20 +297,30 @@ export class AnalyticsController extends BaseController< if (this.#isAnonymousEventsFeatureEnabled) { // Note: Even if regular properties object is empty, we still send it to ensure // an event with user ID is tracked. - this.#platformAdapter.track(event.name, { + const properties = { ...event.properties, - }); + }; + if (context) { + this.#platformAdapter.track(event.name, properties, context); + } else { + this.#platformAdapter.track(event.name, properties); + } } const hasSensitiveProperties = Object.keys(event.sensitiveProperties).length > 0; if (!this.#isAnonymousEventsFeatureEnabled || hasSensitiveProperties) { - this.#platformAdapter.track(event.name, { + const properties = { ...event.properties, ...event.sensitiveProperties, ...(hasSensitiveProperties && { anonymous: true }), - }); + }; + if (context) { + this.#platformAdapter.track(event.name, properties, context); + } else { + this.#platformAdapter.track(event.name, properties); + } } } @@ -312,14 +328,19 @@ export class AnalyticsController extends BaseController< * Identify a user for analytics. * * @param traits - User traits/properties + * @param context - Optional platform-specific context forwarded to the platform adapter. */ - identify(traits?: AnalyticsUserTraits): void { + identify(traits?: AnalyticsUserTraits, context?: AnalyticsContext): void { if (!analyticsControllerSelectors.selectEnabled(this.state)) { return; } // Delegate to platform adapter using the current analytics ID - this.#platformAdapter.identify(this.state.analyticsId, traits); + if (context) { + this.#platformAdapter.identify(this.state.analyticsId, traits, context); + } else { + this.#platformAdapter.identify(this.state.analyticsId, traits); + } } /** @@ -327,14 +348,23 @@ export class AnalyticsController extends BaseController< * * @param name - The identifier/name of the page or screen being viewed (e.g., "home", "settings", "wallet") * @param properties - Optional properties associated with the view + * @param context - Optional platform-specific context forwarded to the platform adapter. */ - trackView(name: string, properties?: AnalyticsEventProperties): void { + trackView( + name: string, + properties?: AnalyticsEventProperties, + context?: AnalyticsContext, + ): void { if (!analyticsControllerSelectors.selectEnabled(this.state)) { return; } // Delegate to platform adapter - this.#platformAdapter.view(name, properties); + if (context) { + this.#platformAdapter.view(name, properties, context); + } else { + this.#platformAdapter.view(name, properties); + } } /** diff --git a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts index 78485ba6f4..fdb582f3f4 100644 --- a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts +++ b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts @@ -28,6 +28,11 @@ export type AnalyticsTrackingEvent = { readonly hasProperties: boolean; }; +/** + * Optional analytics context payload (for example Segment-style context). + */ +export type AnalyticsContext = Record; + /** * Platform adapter interface for analytics tracking * Implementations should handle platform-specific details (Segment SDK, etc.) @@ -47,16 +52,26 @@ export type AnalyticsPlatformAdapter = { * @param eventName - The name of the event * @param properties - Event properties. If not provided, the event has no properties. * The privacy plugin should check for `isSensitive === true` to determine if an event contains sensitive data. + * @param context - Optional platform-specific context attached to the invocation. */ - track(eventName: string, properties?: AnalyticsEventProperties): void; + track( + eventName: string, + properties?: AnalyticsEventProperties, + context?: AnalyticsContext, + ): void; /** * Identify a user with traits. * * @param userId - The user identifier (e.g., metametrics ID) * @param traits - User traits/properties + * @param context - Optional platform-specific context attached to the invocation. */ - identify(userId: string, traits?: AnalyticsUserTraits): void; + identify( + userId: string, + traits?: AnalyticsUserTraits, + context?: AnalyticsContext, + ): void; /** * Track a UI unit (page or screen) view depending on the platform @@ -67,8 +82,13 @@ export type AnalyticsPlatformAdapter = { * * @param name - The identifier/name of the page or screen being viewed (e.g., "home", "settings", "wallet") * @param properties - Optional properties associated with the view + * @param context - Optional platform-specific context attached to the invocation. */ - view(name: string, properties?: AnalyticsEventProperties): void; + view( + name: string, + properties?: AnalyticsEventProperties, + context?: AnalyticsContext, + ): void; /** * Lifecycle hook called after the AnalyticsController is fully initialized. diff --git a/packages/analytics-controller/src/index.ts b/packages/analytics-controller/src/index.ts index 3f361c7a66..cb4312feca 100644 --- a/packages/analytics-controller/src/index.ts +++ b/packages/analytics-controller/src/index.ts @@ -10,6 +10,7 @@ export { AnalyticsPlatformAdapterSetupError } from './AnalyticsPlatformAdapterSe // Export types export type { + AnalyticsContext, AnalyticsEventProperties, AnalyticsUserTraits, AnalyticsPlatformAdapter,