diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 38b473c825..20f2855f58 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional `AnalyticsInvocationOptions` on `trackEvent`, `identify`, and `trackView` to forward context, callback, messageId, and timestamp to `AnalyticsPlatformAdapter` implementations. + ### Changed - Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) diff --git a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts index 45a8f83782..7418d91f47 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 options - Optional invocation metadata 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 options - Optional invocation metadata 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 options - Optional invocation metadata 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 e6c9a43a32..d390b98496 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -14,6 +14,7 @@ import type { AnalyticsPlatformAdapter, AnalyticsTrackingEvent, AnalyticsControllerState, + AnalyticsInvocationOptions, } from '.'; import { isValidUUIDv4 } from './analyticsControllerStateValidator'; @@ -276,6 +277,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }), + undefined, ); }); @@ -584,9 +586,40 @@ describe('AnalyticsController', () => { const event = createTestEvent('test_event', { prop: 'value' }); controller.trackEvent(event); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', { - prop: 'value', + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + { + prop: 'value', + }, + undefined, + ); + }); + + it('forwards invocation options to the platform adapter', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '66666666-6666-4666-a666-666666666666', + }, + platformAdapter: mockAdapter, }); + + const event = createTestEvent('test_event', { prop: 'value' }); + const options: AnalyticsInvocationOptions = { + context: { page: { title: 'Unit test' } }, + callback: jest.fn(), + messageId: 'be7ac049-0225-4f72-9af0-a79772392b69', + timestamp: '2024-01-15T12:00:00.000Z', + }; + + controller.trackEvent(event, options); + + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + { prop: 'value' }, + options, + ); }); it('tracks event without properties when event has no properties', async () => { @@ -602,7 +635,11 @@ describe('AnalyticsController', () => { const event = createTestEvent('test_event', {}, {}, true); controller.trackEvent(event); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event'); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + undefined, + ); }); it('tracks single combined event when isAnonymousEventsFeatureEnabled is disabled', async () => { @@ -624,11 +661,15 @@ describe('AnalyticsController', () => { controller.trackEvent(event); expect(mockAdapter.track).toHaveBeenCalledTimes(1); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', { - prop: 'value', - sensitive_prop: 'sensitive value', - anonymous: true, - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + { + prop: 'value', + sensitive_prop: 'sensitive value', + anonymous: true, + }, + undefined, + ); }); it('tracks single combined event when isAnonymousEventsFeatureEnabled is disabled and only sensitiveProperties are present', async () => { @@ -650,10 +691,14 @@ describe('AnalyticsController', () => { controller.trackEvent(event); expect(mockAdapter.track).toHaveBeenCalledTimes(1); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', { - sensitive_prop: 'sensitive value', - anonymous: true, - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + { + sensitive_prop: 'sensitive value', + anonymous: true, + }, + undefined, + ); }); it('does not call platform adapter when disabled', async () => { @@ -692,14 +737,24 @@ describe('AnalyticsController', () => { controller.trackEvent(event); expect(mockAdapter.track).toHaveBeenCalledTimes(2); - expect(mockAdapter.track).toHaveBeenNthCalledWith(1, 'test_event', { - prop: 'value', - }); - expect(mockAdapter.track).toHaveBeenNthCalledWith(2, 'test_event', { - prop: 'value', - sensitive_prop: 'sensitive value', - anonymous: true, - }); + expect(mockAdapter.track).toHaveBeenNthCalledWith( + 1, + 'test_event', + { + prop: 'value', + }, + undefined, + ); + expect(mockAdapter.track).toHaveBeenNthCalledWith( + 2, + 'test_event', + { + prop: 'value', + sensitive_prop: 'sensitive value', + anonymous: true, + }, + undefined, + ); }); it('tracks regular properties first, then combined event when only sensitive properties are present', async () => { @@ -721,11 +776,21 @@ describe('AnalyticsController', () => { controller.trackEvent(event); expect(mockAdapter.track).toHaveBeenCalledTimes(2); - expect(mockAdapter.track).toHaveBeenNthCalledWith(1, 'test_event', {}); - expect(mockAdapter.track).toHaveBeenNthCalledWith(2, 'test_event', { - sensitive_prop: 'sensitive value', - anonymous: true, - }); + expect(mockAdapter.track).toHaveBeenNthCalledWith( + 1, + 'test_event', + {}, + undefined, + ); + expect(mockAdapter.track).toHaveBeenNthCalledWith( + 2, + 'test_event', + { + sensitive_prop: 'sensitive value', + anonymous: true, + }, + undefined, + ); }); it('tracks only regular properties when no sensitive properties are present', async () => { @@ -743,9 +808,13 @@ describe('AnalyticsController', () => { controller.trackEvent(event); expect(mockAdapter.track).toHaveBeenCalledTimes(1); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', { - prop: 'value', - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + { + prop: 'value', + }, + undefined, + ); }); it('tracks only regular properties when empty sensitive properties are present', async () => { @@ -763,9 +832,52 @@ describe('AnalyticsController', () => { controller.trackEvent(event); expect(mockAdapter.track).toHaveBeenCalledTimes(1); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', { - prop: 'value', + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + { + prop: 'value', + }, + undefined, + ); + }); + + it('forwards invocation options on each track when splitting sensitive events', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '55555555-5555-4555-9555-555555555555', + }, + platformAdapter: mockAdapter, + isAnonymousEventsFeatureEnabled: true, }); + + const event = createTestEvent( + 'test_event', + { prop: 'value' }, + { sensitive_prop: 'sensitive value' }, + ); + const options: AnalyticsInvocationOptions = { + messageId: 'c3c3c3c3-c3c3-43c3-8c3c-c3c3c3c3c3c3', + }; + controller.trackEvent(event, options); + + expect(mockAdapter.track).toHaveBeenNthCalledWith( + 1, + 'test_event', + { prop: 'value' }, + options, + ); + expect(mockAdapter.track).toHaveBeenNthCalledWith( + 2, + 'test_event', + { + prop: 'value', + sensitive_prop: 'sensitive value', + anonymous: true, + }, + options, + ); }); }); }); @@ -790,7 +902,11 @@ describe('AnalyticsController', () => { controller.identify(traits); expect(controller.state.analyticsId).toBe(analyticsId); - expect(mockAdapter.identify).toHaveBeenCalledWith(analyticsId, traits); + expect(mockAdapter.identify).toHaveBeenCalledWith( + analyticsId, + traits, + undefined, + ); }); it('identifies user without traits', async () => { @@ -806,7 +922,37 @@ describe('AnalyticsController', () => { controller.identify(); - expect(mockAdapter.identify).toHaveBeenCalledWith(analyticsId, undefined); + expect(mockAdapter.identify).toHaveBeenCalledWith( + analyticsId, + undefined, + undefined, + ); + }); + + it('forwards invocation options to the platform adapter', async () => { + const mockAdapter = createMockAdapter(); + const analyticsId = 'cccccccc-cccc-4ccc-9ccc-cccccccccccc'; + const { controller } = await setupController({ + state: { + analyticsId, + optedIn: true, + }, + platformAdapter: mockAdapter, + }); + + const traits = { PLAN: 'pro' }; + const options: AnalyticsInvocationOptions = { + messageId: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa', + context: { locale: 'en' }, + }; + + controller.identify(traits, options); + + expect(mockAdapter.identify).toHaveBeenCalledWith( + analyticsId, + traits, + options, + ); }); it('does not identify when disabled', async () => { @@ -843,9 +989,36 @@ describe('AnalyticsController', () => { controller.trackView('home', { referrer: 'test' }); - expect(mockAdapter.view).toHaveBeenCalledWith('home', { - referrer: 'test', + expect(mockAdapter.view).toHaveBeenCalledWith( + 'home', + { + referrer: 'test', + }, + undefined, + ); + }); + + it('forwards invocation options to the platform adapter', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + }, + platformAdapter: mockAdapter, }); + + const options: AnalyticsInvocationOptions = { + timestamp: 1_700_000_000_000, + }; + + controller.trackView('settings', { section: 'security' }, options); + + expect(mockAdapter.view).toHaveBeenCalledWith( + 'settings', + { section: 'security' }, + options, + ); }); it('does not call platform adapter when disabled', async () => { diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 352b170247..09f227ca55 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -14,6 +14,7 @@ import type { AnalyticsEventProperties, AnalyticsUserTraits, AnalyticsTrackingEvent, + AnalyticsInvocationOptions, } from './AnalyticsPlatformAdapter.types'; import { analyticsControllerSelectors } from './selectors'; @@ -272,8 +273,12 @@ export class AnalyticsController extends BaseController< * Events are only tracked if analytics is enabled. * * @param event - Analytics event with properties and sensitive properties + * @param options - Optional invocation metadata forwarded to the platform adapter */ - trackEvent(event: AnalyticsTrackingEvent): void { + trackEvent( + event: AnalyticsTrackingEvent, + options?: AnalyticsInvocationOptions, + ): void { // Don't track if analytics is disabled if (!analyticsControllerSelectors.selectEnabled(this.state)) { return; @@ -282,7 +287,7 @@ 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); + this.#platformAdapter.track(event.name, undefined, options); return; } @@ -290,20 +295,28 @@ 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, { - ...event.properties, - }); + this.#platformAdapter.track( + event.name, + { + ...event.properties, + }, + options, + ); } const hasSensitiveProperties = Object.keys(event.sensitiveProperties).length > 0; if (!this.#isAnonymousEventsFeatureEnabled || hasSensitiveProperties) { - this.#platformAdapter.track(event.name, { - ...event.properties, - ...event.sensitiveProperties, - ...(hasSensitiveProperties && { anonymous: true }), - }); + this.#platformAdapter.track( + event.name, + { + ...event.properties, + ...event.sensitiveProperties, + ...(hasSensitiveProperties && { anonymous: true }), + }, + options, + ); } } @@ -311,14 +324,17 @@ export class AnalyticsController extends BaseController< * Identify a user for analytics. * * @param traits - User traits/properties + * @param options - Optional invocation metadata forwarded to the platform adapter */ - identify(traits?: AnalyticsUserTraits): void { + identify( + traits?: AnalyticsUserTraits, + options?: AnalyticsInvocationOptions, + ): void { if (!analyticsControllerSelectors.selectEnabled(this.state)) { return; } - // Delegate to platform adapter using the current analytics ID - this.#platformAdapter.identify(this.state.analyticsId, traits); + this.#platformAdapter.identify(this.state.analyticsId, traits, options); } /** @@ -326,14 +342,19 @@ 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 options - Optional invocation metadata forwarded to the platform adapter */ - trackView(name: string, properties?: AnalyticsEventProperties): void { + trackView( + name: string, + properties?: AnalyticsEventProperties, + options?: AnalyticsInvocationOptions, + ): void { if (!analyticsControllerSelectors.selectEnabled(this.state)) { return; } // Delegate to platform adapter - this.#platformAdapter.view(name, properties); + this.#platformAdapter.view(name, properties, options); } /** diff --git a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts index 39d22ff592..a9fd0d30dd 100644 --- a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts +++ b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts @@ -28,6 +28,40 @@ export type AnalyticsTrackingEvent = { readonly hasProperties: boolean; }; +/** + * Optional analytics context payload (for example Segment-style context). + */ +export type AnalyticsContext = Record; + +/** + * Optional callback invoked after the platform finishes processing the call. + * Implementations may pass errors from the underlying client when supported. + */ +export type AnalyticsInvocationCallback = (error?: Error) => void; + +/** + * Optional metadata forwarded with track, identify, and screen or page calls. + * Platforms map these to their analytics SDK options. + */ +export type AnalyticsInvocationOptions = { + /** + * Optional context object attached to the invocation. + */ + context?: AnalyticsContext; + /** + * Optional callback when the invocation completes or fails. + */ + callback?: AnalyticsInvocationCallback; + /** + * Optional stable identifier for deduplication or tracing. + */ + messageId?: string; + /** + * Optional event time. ISO strings, Unix timestamps in milliseconds, or Date values are typical. + */ + timestamp?: Date | number | string; +}; + /** * Platform adapter interface for analytics tracking * Implementations should handle platform-specific details (Segment SDK, etc.) @@ -41,16 +75,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 options - Optional invocation metadata for the platform client. */ - track(eventName: string, properties?: AnalyticsEventProperties): void; + track( + eventName: string, + properties?: AnalyticsEventProperties, + options?: AnalyticsInvocationOptions, + ): void; /** * Identify a user with traits. * * @param userId - The user identifier (e.g., metametrics ID) * @param traits - User traits/properties + * @param options - Optional invocation metadata for the platform client. */ - identify(userId: string, traits?: AnalyticsUserTraits): void; + identify( + userId: string, + traits?: AnalyticsUserTraits, + options?: AnalyticsInvocationOptions, + ): void; /** * Track a UI unit (page or screen) view depending on the platform @@ -61,8 +105,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 options - Optional invocation metadata for the platform client. */ - view(name: string, properties?: AnalyticsEventProperties): void; + view( + name: string, + properties?: AnalyticsEventProperties, + options?: AnalyticsInvocationOptions, + ): 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..be1a306880 100644 --- a/packages/analytics-controller/src/index.ts +++ b/packages/analytics-controller/src/index.ts @@ -10,8 +10,11 @@ export { AnalyticsPlatformAdapterSetupError } from './AnalyticsPlatformAdapterSe // Export types export type { + AnalyticsContext, AnalyticsEventProperties, AnalyticsUserTraits, + AnalyticsInvocationCallback, + AnalyticsInvocationOptions, AnalyticsPlatformAdapter, AnalyticsTrackingEvent, } from './AnalyticsPlatformAdapter.types';