diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 8ee49f43d4..f8fb73eb31 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 +- Optional persisted event queue support in `AnalyticsController`, disabled by default. ([#8797](https://github.com/MetaMask/core/pull/8797)) - 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)) diff --git a/packages/analytics-controller/README.md b/packages/analytics-controller/README.md index 0fb6d1b968..90e3186378 100644 --- a/packages/analytics-controller/README.md +++ b/packages/analytics-controller/README.md @@ -20,6 +20,7 @@ The AnalyticsController provides a unified interface for tracking analytics even | ------------- | --------- | --------------------------------------------- | --------- | | `analyticsId` | `string` | UUIDv4 identifier (client platform-generated) | Yes | | `optedIn` | `boolean` | User opt-in status | Yes | +| `eventQueue` | `object` | Optional persisted delivery queue | Yes | ### Client Platform Responsibilities @@ -37,6 +38,14 @@ When `isAnonymousEventsFeatureEnabled` is enabled in the constructor, events wit This allows sensitive data to be tracked anonymously while maintaining user identification for regular properties. When disabled (default), all properties are tracked in a single event. +## Persisted Event Queue + +When `isEventQueuePersistenceEnabled` is enabled in the constructor, each final platform adapter payload is persisted until the adapter reports successful delivery through its callback. + +This feature is disabled by default. Client platforms that already rely on SDK-level persistence, such as MetaMask Mobile through `@segment/analytics-react-native`'s `storePersistor` option, should leave it disabled. + +Platforms without SDK-level persistence, such as MetaMask Extension, can enable it to replay queued payloads after restart. The queue stores the final adapter calls, so anonymous event splitting persists the identified and anonymous payloads separately. + ## Lifecycle Hooks ### `onSetupCompleted` diff --git a/packages/analytics-controller/package.json b/packages/analytics-controller/package.json index 080ad8a997..332add3483 100644 --- a/packages/analytics-controller/package.json +++ b/packages/analytics-controller/package.json @@ -55,12 +55,15 @@ "dependencies": { "@metamask/base-controller": "^9.1.0", "@metamask/messenger": "^1.2.0", - "@metamask/utils": "^11.9.0" + "@metamask/utils": "^11.9.0", + "lodash": "^4.17.21", + "uuid": "^8.3.2" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", + "@types/uuid": "^8.3.0", "deepmerge": "^4.2.2", "jest": "^29.7.0", "ts-jest": "^29.2.5", diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 7b8d5005fa..638683998a 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -13,6 +13,7 @@ import type { AnalyticsControllerActions, AnalyticsControllerEvents, AnalyticsPlatformAdapter, + AnalyticsDeliveryOptions, AnalyticsTrackingEvent, AnalyticsControllerState, AnalyticsContext, @@ -23,6 +24,7 @@ type SetupControllerOptions = { state: AnalyticsControllerState; platformAdapter?: AnalyticsPlatformAdapter; isAnonymousEventsFeatureEnabled?: boolean; + isEventQueuePersistenceEnabled?: boolean; }; type SetupControllerReturn = { @@ -30,6 +32,13 @@ type SetupControllerReturn = { messenger: AnalyticsControllerMessenger; }; +type MockAnalyticsPlatformAdapter = AnalyticsPlatformAdapter & { + track: jest.Mock; + identify: jest.Mock; + view: jest.Mock; + onSetupCompleted: jest.Mock; +}; + /** * Sets up an AnalyticsController for testing. * @@ -37,6 +46,7 @@ type SetupControllerReturn = { * @param options.state - Controller state (analyticsId required) * @param options.platformAdapter - Optional platform adapter * @param options.isAnonymousEventsFeatureEnabled - Optional anonymous events feature flag (default: false) + * @param options.isEventQueuePersistenceEnabled - Optional event queue persistence flag (default: false) * @returns The controller and messenger */ async function setupController( @@ -46,6 +56,7 @@ async function setupController( state, platformAdapter, isAnonymousEventsFeatureEnabled = false, + isEventQueuePersistenceEnabled = false, } = options; const adapter = @@ -78,6 +89,7 @@ async function setupController( platformAdapter: adapter, state, isAnonymousEventsFeatureEnabled, + isEventQueuePersistenceEnabled, }); controller.init(); @@ -122,7 +134,7 @@ function createTestEvent( * * @returns A mock AnalyticsPlatformAdapter */ -function createMockAdapter(): AnalyticsPlatformAdapter { +function createMockAdapter(): MockAnalyticsPlatformAdapter { return { track: jest.fn(), identify: jest.fn(), @@ -131,6 +143,20 @@ function createMockAdapter(): AnalyticsPlatformAdapter { }; } +/** + * Gets delivery options from a mock adapter call. + * + * @param mock - The mock adapter method. + * @param callIndex - The call index. + * @returns Delivery options from the call. + */ +function getDeliveryOptions( + mock: jest.Mock, + callIndex = 0, +): AnalyticsDeliveryOptions { + return mock.mock.calls[callIndex][3] as AnalyticsDeliveryOptions; +} + describe('AnalyticsController', () => { describe('getDefaultAnalyticsControllerState', () => { it('returns default opt-in preferences without analyticsId', () => { @@ -213,6 +239,53 @@ describe('AnalyticsController', () => { `); }); + it('persists eventQueue but excludes it from logs, snapshots, and UI', async () => { + const state: AnalyticsControllerState = { + ...metadataFixtureState, + eventQueue: { + 'message-id-1': { + type: 'track', + eventName: 'test_event', + messageId: 'message-id-1', + timestamp: '2026-01-01T00:00:00.000Z', + properties: { + sensitive_prop: 'sensitive value', + }, + }, + }, + }; + const { controller } = await setupController({ state }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).not.toHaveProperty('eventQueue'); + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).not.toHaveProperty('eventQueue'); + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).not.toHaveProperty('eventQueue'); + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toHaveProperty('eventQueue', state.eventQueue); + }); + it('exposes expected state to UI', async () => { const { controller } = await setupController({ state: metadataFixtureState, @@ -360,6 +433,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }), + undefined, ); }); @@ -688,9 +762,11 @@ 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 context to the platform adapter', async () => { @@ -754,7 +830,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 () => { @@ -776,11 +856,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 () => { @@ -802,10 +886,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 () => { @@ -844,14 +932,22 @@ 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('forwards context to both events when splitting sensitive events', async () => { @@ -914,11 +1010,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 () => { @@ -936,9 +1042,11 @@ 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 () => { @@ -956,9 +1064,11 @@ 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, + ); }); }); }); @@ -983,7 +1093,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 () => { @@ -999,7 +1113,11 @@ describe('AnalyticsController', () => { controller.identify(); - expect(mockAdapter.identify).toHaveBeenCalledWith(analyticsId, undefined); + expect(mockAdapter.identify).toHaveBeenCalledWith( + analyticsId, + undefined, + undefined, + ); }); it('forwards context to the platform adapter', async () => { @@ -1061,9 +1179,11 @@ describe('AnalyticsController', () => { controller.trackView('home', { referrer: 'test' }); - expect(mockAdapter.view).toHaveBeenCalledWith('home', { - referrer: 'test', - }); + expect(mockAdapter.view).toHaveBeenCalledWith( + 'home', + { referrer: 'test' }, + undefined, + ); }); it('forwards context to the platform adapter', async () => { @@ -1105,6 +1225,647 @@ describe('AnalyticsController', () => { }); }); + describe('event queue persistence', () => { + it('does not create eventQueue when disabled', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-000000000000', + }, + platformAdapter: mockAdapter, + }); + + controller.trackEvent(createTestEvent('test_event', { prop: 'value' })); + + expect(controller.state.eventQueue).toBeUndefined(); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + { prop: 'value' }, + undefined, + ); + expect(mockAdapter.track.mock.calls[0]).toHaveLength(3); + }); + + it('persists track payloads until the adapter callback succeeds', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-000000000001', + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: true, + }); + + controller.trackEvent(createTestEvent('test_event', { prop: 'value' })); + + const deliveryOptions = getDeliveryOptions(mockAdapter.track); + expect(deliveryOptions).toStrictEqual({ + messageId: expect.any(String), + timestamp: expect.any(Date), + callback: expect.any(Function), + }); + expect(controller.state.eventQueue).toStrictEqual({ + [deliveryOptions.messageId as string]: { + type: 'track', + eventName: 'test_event', + messageId: deliveryOptions.messageId, + timestamp: deliveryOptions.timestamp?.toISOString(), + properties: { prop: 'value' }, + }, + }); + + deliveryOptions.callback?.(); + + expect(controller.state.eventQueue).toStrictEqual({}); + }); + + it('ignores duplicate successful delivery callbacks', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-000000000011', + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: true, + }); + + controller.trackEvent(createTestEvent('test_event', { prop: 'value' })); + + const deliveryOptions = getDeliveryOptions(mockAdapter.track); + deliveryOptions.callback?.(); + + expect(() => deliveryOptions.callback?.()).not.toThrow(); + expect(controller.state.eventQueue).toStrictEqual({}); + }); + + it('keeps queued payloads when the adapter callback receives an error', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-000000000002', + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: true, + }); + + controller.trackEvent(createTestEvent('test_event', { prop: 'value' })); + + const deliveryOptions = getDeliveryOptions(mockAdapter.track); + deliveryOptions.callback?.(new Error('Segment failed')); + + const [messageId] = Object.keys(controller.state.eventQueue ?? {}); + + expect(controller.state.eventQueue).toHaveProperty(messageId); + expect(controller.state.eventQueue?.[messageId]).toMatchObject({ + type: 'track', + eventName: 'test_event', + properties: { prop: 'value' }, + }); + }); + + it('keeps queued payloads when the platform adapter throws', async () => { + const mockAdapter = createMockAdapter(); + jest.spyOn(mockAdapter, 'track').mockImplementation(() => { + throw new Error('Segment failed'); + }); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-000000000003', + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: true, + }); + + expect(() => + controller.trackEvent(createTestEvent('test_event', { prop: 'value' })), + ).not.toThrow(); + + const [messageId] = Object.keys(controller.state.eventQueue ?? {}); + + expect(controller.state.eventQueue).toHaveProperty(messageId); + expect(controller.state.eventQueue?.[messageId]).toMatchObject({ + type: 'track', + eventName: 'test_event', + properties: { prop: 'value' }, + }); + }); + + it('passes mutable clones of queued payload data to the platform adapter', async () => { + const mockAdapter = createMockAdapter(); + let adapterMutationCompleted = false; + jest + .spyOn(mockAdapter, 'track') + .mockImplementation((_eventName, properties, context, options) => { + ( + properties as { nested: { adapterNormalized?: boolean } } + ).nested.adapterNormalized = true; + ( + context as { page: { adapterNormalized?: boolean } } + ).page.adapterNormalized = true; + adapterMutationCompleted = true; + (options as AnalyticsDeliveryOptions).callback?.( + new Error('Segment failed'), + ); + }); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-000000000012', + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: true, + }); + const context: AnalyticsContext = { + page: { title: 'Unit test' }, + }; + + controller.trackEvent( + createTestEvent('test_event', { nested: { prop: 'value' } }), + context, + ); + + const [messageId] = Object.keys(controller.state.eventQueue ?? {}); + + expect(adapterMutationCompleted).toBe(true); + expect(controller.state.eventQueue?.[messageId]).toMatchObject({ + type: 'track', + eventName: 'test_event', + properties: { nested: { prop: 'value' } }, + context: { page: { title: 'Unit test' } }, + }); + expect(controller.state.eventQueue?.[messageId]).not.toHaveProperty( + 'properties.nested.adapterNormalized', + ); + expect(controller.state.eventQueue?.[messageId]).not.toHaveProperty( + 'context.page.adapterNormalized', + ); + }); + + it('queues both track payloads when anonymous events split sensitive properties', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-000000000004', + }, + platformAdapter: mockAdapter, + isAnonymousEventsFeatureEnabled: true, + isEventQueuePersistenceEnabled: true, + }); + + controller.trackEvent( + createTestEvent( + 'test_event', + { prop: 'value' }, + { sensitive_prop: 'sensitive value' }, + ), + ); + + const identifiedOptions = getDeliveryOptions(mockAdapter.track, 0); + const anonymousOptions = getDeliveryOptions(mockAdapter.track, 1); + + expect(anonymousOptions.messageId).not.toBe(identifiedOptions.messageId); + expect(anonymousOptions.messageId).toStrictEqual(expect.any(String)); + expect(Object.keys(controller.state.eventQueue ?? {})).toHaveLength(2); + + identifiedOptions.callback?.(); + + expect(controller.state.eventQueue).not.toHaveProperty( + identifiedOptions.messageId as string, + ); + expect(controller.state.eventQueue).toHaveProperty( + anonymousOptions.messageId as string, + ); + + anonymousOptions.callback?.(); + + expect(controller.state.eventQueue).toStrictEqual({}); + }); + + it('persists identify and view payloads until their callbacks succeed', async () => { + const mockAdapter = createMockAdapter(); + const analyticsId = '10000000-0000-4000-8000-000000000005'; + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId, + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: true, + }); + + const identifyContext: AnalyticsContext = { + app: { name: 'MetaMask' }, + }; + const viewContext: AnalyticsContext = { + page: { title: 'Home' }, + }; + + controller.identify({ trait: 'value' }, identifyContext); + controller.trackView('home', { referrer: 'test' }, viewContext); + + const identifyOptions = getDeliveryOptions(mockAdapter.identify); + const viewOptions = getDeliveryOptions(mockAdapter.view); + + expect(mockAdapter.identify).toHaveBeenCalledWith( + analyticsId, + { trait: 'value' }, + identifyContext, + expect.objectContaining({ messageId: identifyOptions.messageId }), + ); + expect(mockAdapter.view).toHaveBeenCalledWith( + 'home', + { referrer: 'test' }, + viewContext, + expect.objectContaining({ messageId: viewOptions.messageId }), + ); + expect(controller.state.eventQueue).toMatchObject({ + [identifyOptions.messageId as string]: { + context: identifyContext, + }, + [viewOptions.messageId as string]: { + context: viewContext, + }, + }); + expect(Object.keys(controller.state.eventQueue ?? {})).toHaveLength(2); + + identifyOptions.callback?.(); + viewOptions.callback?.(); + + expect(controller.state.eventQueue).toStrictEqual({}); + }); + + it('queues track, identify, and view payloads without optional properties', async () => { + const mockAdapter = createMockAdapter(); + const analyticsId = '10000000-0000-4000-8000-000000000010'; + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId, + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: true, + }); + + controller.trackEvent(createTestEvent('test_event')); + controller.identify(); + controller.trackView('home'); + + const trackOptions = getDeliveryOptions(mockAdapter.track); + const identifyOptions = getDeliveryOptions(mockAdapter.identify); + const viewOptions = getDeliveryOptions(mockAdapter.view); + + expect(controller.state.eventQueue).toStrictEqual({ + [trackOptions.messageId as string]: { + type: 'track', + eventName: 'test_event', + messageId: trackOptions.messageId, + timestamp: trackOptions.timestamp?.toISOString(), + }, + [identifyOptions.messageId as string]: { + type: 'identify', + userId: analyticsId, + messageId: identifyOptions.messageId, + timestamp: identifyOptions.timestamp?.toISOString(), + }, + [viewOptions.messageId as string]: { + type: 'view', + name: 'home', + messageId: viewOptions.messageId, + timestamp: viewOptions.timestamp?.toISOString(), + }, + }); + }); + + it('replays queued track, identify, and view events during init when enabled and opted in', async () => { + const mockAdapter = createMockAdapter(); + const analyticsId = '10000000-0000-4000-8000-000000000006'; + const trackEvent = { + type: 'track' as const, + eventName: 'test_event', + messageId: 'track-message-id', + timestamp: '2026-01-01T00:00:00.000Z', + properties: { prop: 'value' }, + }; + const identifyEvent = { + type: 'identify' as const, + userId: analyticsId, + messageId: 'identify-message-id', + timestamp: '2026-01-01T00:00:01.000Z', + traits: { trait: 'value' }, + }; + const viewEvent = { + type: 'view' as const, + name: 'home', + messageId: 'view-message-id', + timestamp: '2026-01-01T00:00:02.000Z', + properties: { referrer: 'test' }, + }; + + await setupController({ + state: { + optedIn: true, + analyticsId, + eventQueue: { + 'track-message-id': trackEvent, + 'identify-message-id': identifyEvent, + 'view-message-id': viewEvent, + }, + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: true, + }); + + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + { prop: 'value' }, + undefined, + expect.objectContaining({ + messageId: 'track-message-id', + timestamp: new Date(trackEvent.timestamp), + callback: expect.any(Function), + }), + ); + expect(mockAdapter.identify).toHaveBeenCalledWith( + analyticsId, + { trait: 'value' }, + undefined, + expect.objectContaining({ + messageId: 'identify-message-id', + timestamp: new Date(identifyEvent.timestamp), + callback: expect.any(Function), + }), + ); + expect(mockAdapter.view).toHaveBeenCalledWith( + 'home', + { referrer: 'test' }, + undefined, + expect.objectContaining({ + messageId: 'view-message-id', + timestamp: new Date(viewEvent.timestamp), + callback: expect.any(Function), + }), + ); + }); + + it('does not replay queued events when queue persistence is disabled', async () => { + const mockAdapter = createMockAdapter(); + + await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-000000000007', + eventQueue: { + 'message-id-1': { + type: 'track', + eventName: 'test_event', + messageId: 'message-id-1', + timestamp: '2026-01-01T00:00:00.000Z', + properties: { prop: 'value' }, + }, + }, + }, + platformAdapter: mockAdapter, + }); + + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('clears queued events during init when opted out', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: false, + analyticsId: '10000000-0000-4000-8000-000000000008', + eventQueue: { + 'message-id-1': { + type: 'track', + eventName: 'test_event', + messageId: 'message-id-1', + timestamp: '2026-01-01T00:00:00.000Z', + properties: { prop: 'value' }, + }, + }, + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: true, + }); + + expect(mockAdapter.track).not.toHaveBeenCalled(); + expect(controller.state.eventQueue).toStrictEqual({}); + }); + + it('clears queued events on opt out', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-000000000009', + eventQueue: { + 'message-id-1': { + type: 'track', + eventName: 'test_event', + messageId: 'message-id-1', + timestamp: '2026-01-01T00:00:00.000Z', + properties: { prop: 'value' }, + }, + }, + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: true, + }); + + controller.optOut(); + + expect(controller.state.optedIn).toBe(false); + expect(controller.state.eventQueue).toStrictEqual({}); + }); + + it('clears queued events on opt out when queue persistence is disabled', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-000000000013', + eventQueue: { + 'message-id-1': { + type: 'track', + eventName: 'test_event', + messageId: 'message-id-1', + timestamp: '2026-01-01T00:00:00.000Z', + properties: { prop: 'value' }, + }, + }, + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: false, + }); + + controller.optOut(); + + expect(controller.state.optedIn).toBe(false); + expect(controller.state.eventQueue).toStrictEqual({}); + }); + + it('does not fail when clearing an empty event queue on opt out', async () => { + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-00000000000c', + }, + isEventQueuePersistenceEnabled: true, + }); + + expect(() => controller.optOut()).not.toThrow(); + + expect(controller.state.optedIn).toBe(false); + expect(controller.state.eventQueue).toBeUndefined(); + }); + + it('drops invalid queued events during replay', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-00000000000d', + eventQueue: { + nullRecord: null, + invalidRecord: 'not-an-event', + invalidMetadata: { + type: 'track', + eventName: 'test_event', + messageId: 123, + timestamp: '2026-01-01T00:00:00.000Z', + }, + unsupportedType: { + type: 'unknown', + messageId: 'unsupportedType', + timestamp: '2026-01-01T00:00:00.000Z', + }, + 'message-id-1': { + type: 'track', + eventName: 'test_event', + messageId: 'different-message-id', + timestamp: '2026-01-01T00:00:00.000Z', + }, + } as unknown as AnalyticsControllerState['eventQueue'], + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: true, + }); + + expect(mockAdapter.track).not.toHaveBeenCalled(); + expect(controller.state.eventQueue).toStrictEqual({}); + }); + + it('drops queued events with invalid payload fields during replay', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-000000000014', + eventQueue: { + invalidTrackName: { + type: 'track', + eventName: 123, + messageId: 'invalidTrackName', + timestamp: '2026-01-01T00:00:00.000Z', + }, + invalidTrackProperties: { + type: 'track', + eventName: 'test_event', + messageId: 'invalidTrackProperties', + timestamp: '2026-01-01T00:00:00.000Z', + properties: 'invalid', + }, + invalidTrackContext: { + type: 'track', + eventName: 'test_event', + messageId: 'invalidTrackContext', + timestamp: '2026-01-01T00:00:00.000Z', + context: 'invalid', + }, + invalidIdentifyUserId: { + type: 'identify', + userId: 123, + messageId: 'invalidIdentifyUserId', + timestamp: '2026-01-01T00:00:00.000Z', + }, + invalidIdentifyTraits: { + type: 'identify', + userId: '10000000-0000-4000-8000-000000000014', + messageId: 'invalidIdentifyTraits', + timestamp: '2026-01-01T00:00:00.000Z', + traits: 'invalid', + }, + invalidIdentifyContext: { + type: 'identify', + userId: '10000000-0000-4000-8000-000000000014', + messageId: 'invalidIdentifyContext', + timestamp: '2026-01-01T00:00:00.000Z', + context: 'invalid', + }, + invalidViewName: { + type: 'view', + name: 123, + messageId: 'invalidViewName', + timestamp: '2026-01-01T00:00:00.000Z', + }, + invalidViewProperties: { + type: 'view', + name: 'home', + messageId: 'invalidViewProperties', + timestamp: '2026-01-01T00:00:00.000Z', + properties: 'invalid', + }, + invalidViewContext: { + type: 'view', + name: 'home', + messageId: 'invalidViewContext', + timestamp: '2026-01-01T00:00:00.000Z', + context: 'invalid', + }, + } as unknown as AnalyticsControllerState['eventQueue'], + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: true, + }); + + expect(mockAdapter.track).not.toHaveBeenCalled(); + expect(mockAdapter.identify).not.toHaveBeenCalled(); + expect(mockAdapter.view).not.toHaveBeenCalled(); + expect(controller.state.eventQueue).toStrictEqual({}); + }); + + it('drops queued events with invalid timestamps during replay', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId: '10000000-0000-4000-8000-00000000000e', + eventQueue: { + 'message-id-1': { + type: 'track', + eventName: 'test_event', + messageId: 'message-id-1', + timestamp: 'invalid-timestamp', + }, + }, + }, + platformAdapter: mockAdapter, + isEventQueuePersistenceEnabled: true, + }); + + expect(mockAdapter.track).not.toHaveBeenCalled(); + expect(controller.state.eventQueue).toStrictEqual({}); + }); + }); + describe('optIn', () => { it('sets optedIn to true', async () => { const { controller } = await setupController({ diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 806b08d73b..88a254e4b4 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -5,12 +5,16 @@ import type { } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; +import { v4 as uuid } from 'uuid'; import type { AnalyticsControllerMethodActions } from './AnalyticsController-method-action-types'; import { validateAnalyticsControllerState } from './analyticsControllerStateValidator'; import { projectLogger as log } from './AnalyticsLogger'; import type { AnalyticsPlatformAdapter, + AnalyticsDeliveryOptions, AnalyticsContext, AnalyticsEventProperties, AnalyticsUserTraits, @@ -44,8 +48,82 @@ export type AnalyticsControllerState = { * Must be provided by the platform - the controller does not generate it. */ analyticsId: string; + + /** + * Persisted queue of analytics events waiting for delivery acknowledgement. + * This is only used when event queue persistence is enabled. + */ + eventQueue?: Record; }; +/** + * Event types supported by the persisted analytics event queue. + */ +export type AnalyticsQueuedEventType = 'track' | 'identify' | 'view'; + +/** + * Base persisted event queue entry. + */ +export type AnalyticsQueuedEventBase = { + /** + * Event type used to replay the payload with the platform adapter. + */ + type: AnalyticsQueuedEventType; + + /** + * Stable identifier for the analytics payload. + */ + messageId: string; + + /** + * Original payload timestamp serialized for persistence. + */ + timestamp: string; +}; + +/** + * Persisted track event queue entry. + */ +export type AnalyticsQueuedTrackEvent = AnalyticsQueuedEventBase & { + type: 'track'; + eventName: string; + properties?: AnalyticsEventProperties; + context?: AnalyticsContext; +}; + +/** + * Persisted identify event queue entry. + */ +export type AnalyticsQueuedIdentifyEvent = AnalyticsQueuedEventBase & { + type: 'identify'; + userId: string; + traits?: AnalyticsUserTraits; + context?: AnalyticsContext; +}; + +/** + * Persisted view event queue entry. + */ +export type AnalyticsQueuedViewEvent = AnalyticsQueuedEventBase & { + type: 'view'; + name: string; + properties?: AnalyticsEventProperties; + context?: AnalyticsContext; +}; + +/** + * Persisted analytics event queue entry. + */ +export type AnalyticsQueuedEvent = + | AnalyticsQueuedTrackEvent + | AnalyticsQueuedIdentifyEvent + | AnalyticsQueuedViewEvent; + +/** + * Persisted analytics event queue keyed by message ID. + */ +export type AnalyticsEventQueue = Record; + /** * Returns default values for AnalyticsController state. * @@ -82,6 +160,12 @@ const analyticsControllerMetadata = { includeInDebugSnapshot: true, usedInUi: false, }, + eventQueue: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, } satisfies StateMetadata; // === MESSENGER === @@ -169,8 +253,73 @@ export type AnalyticsControllerOptions = { * @default false */ isAnonymousEventsFeatureEnabled?: boolean; + + /** + * Whether analytics event queue persistence is enabled. + * + * When enabled, AnalyticsController persists each platform adapter payload + * until the adapter reports successful delivery. + * + * @default false + */ + isEventQueuePersistenceEnabled?: boolean; }; +/** + * Returns whether a value is a non-array object. + * + * @param value - The value to check. + * @returns True if the value is a record. + */ +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Returns whether a value is a valid persisted analytics event. + * + * @param value - The value to check. + * @returns True if the value is a queued analytics event. + */ +function isAnalyticsQueuedEvent(value: unknown): value is AnalyticsQueuedEvent { + if (!isRecord(value)) { + return false; + } + + if ( + typeof value.messageId !== 'string' || + typeof value.timestamp !== 'string' + ) { + return false; + } + + if (value.type === 'track') { + return ( + typeof value.eventName === 'string' && + (value.properties === undefined || isRecord(value.properties)) && + (value.context === undefined || isRecord(value.context)) + ); + } + + if (value.type === 'identify') { + return ( + typeof value.userId === 'string' && + (value.traits === undefined || isRecord(value.traits)) && + (value.context === undefined || isRecord(value.context)) + ); + } + + if (value.type === 'view') { + return ( + typeof value.name === 'string' && + (value.properties === undefined || isRecord(value.properties)) && + (value.context === undefined || isRecord(value.context)) + ); + } + + return false; +} + /** * The AnalyticsController manages analytics tracking across platforms (Mobile/Extension). * It provides a unified interface for tracking events, identifying users, and managing @@ -193,6 +342,8 @@ export class AnalyticsController extends BaseController< readonly #isAnonymousEventsFeatureEnabled: boolean; + readonly #isEventQueuePersistenceEnabled: boolean; + #initialized: boolean; /** @@ -204,6 +355,7 @@ export class AnalyticsController extends BaseController< * @param options.messenger - Messenger used to communicate with BaseController * @param options.platformAdapter - Platform adapter implementation for tracking * @param options.isAnonymousEventsFeatureEnabled - Whether the anonymous events feature is enabled + * @param options.isEventQueuePersistenceEnabled - Whether analytics event queue persistence is enabled * @throws Error if state.analyticsId is missing or not a valid UUIDv4 * @remarks After construction, call {@link AnalyticsController.init} to complete initialization. */ @@ -212,6 +364,7 @@ export class AnalyticsController extends BaseController< messenger, platformAdapter, isAnonymousEventsFeatureEnabled = false, + isEventQueuePersistenceEnabled = false, }: AnalyticsControllerOptions) { const initialState: AnalyticsControllerState = { ...getDefaultAnalyticsControllerState(), @@ -231,6 +384,7 @@ export class AnalyticsController extends BaseController< }); this.#isAnonymousEventsFeatureEnabled = isAnonymousEventsFeatureEnabled; + this.#isEventQueuePersistenceEnabled = isEventQueuePersistenceEnabled; this.#platformAdapter = platformAdapter; this.#initialized = false; @@ -243,6 +397,7 @@ export class AnalyticsController extends BaseController< enabled: analyticsControllerSelectors.selectEnabled(this.state), optedIn: this.state.optedIn, analyticsId: this.state.analyticsId, + eventQueuePersistenceEnabled: this.#isEventQueuePersistenceEnabled, }); } @@ -266,6 +421,243 @@ export class AnalyticsController extends BaseController< // Log error but don't throw - adapter setup failure shouldn't break controller log('Error calling platformAdapter.onSetupCompleted', error); } + + this.#replayQueuedEvents(); + } + + /** + * Send final track payload through the platform adapter or queue it if persistence is enabled. + * + * @param eventName - The name of the event. + * @param properties - Optional event properties. + * @param context - Optional platform-specific context. + */ + #sendOrQueueTrackEvent( + eventName: string, + properties?: AnalyticsEventProperties, + context?: AnalyticsContext, + ): void { + if (!this.#isEventQueuePersistenceEnabled) { + this.#platformAdapter.track(eventName, properties, context); + return; + } + + const queuedEvent: AnalyticsQueuedTrackEvent = { + type: 'track', + eventName, + messageId: uuid(), + timestamp: new Date().toISOString(), + ...(properties === undefined ? {} : { properties }), + ...(context === undefined ? {} : { context }), + }; + + this.#enqueueEvent(queuedEvent); + } + + /** + * Send final identify payload through the platform adapter or queue it if persistence is enabled. + * + * @param userId - The user ID. + * @param traits - Optional user traits. + * @param context - Optional platform-specific context. + */ + #sendOrQueueIdentifyEvent( + userId: string, + traits?: AnalyticsUserTraits, + context?: AnalyticsContext, + ): void { + if (!this.#isEventQueuePersistenceEnabled) { + this.#platformAdapter.identify(userId, traits, context); + return; + } + + const queuedEvent: AnalyticsQueuedIdentifyEvent = { + type: 'identify', + userId, + messageId: uuid(), + timestamp: new Date().toISOString(), + ...(traits === undefined ? {} : { traits }), + ...(context === undefined ? {} : { context }), + }; + + this.#enqueueEvent(queuedEvent); + } + + /** + * Send final view payload through the platform adapter or queue it if persistence is enabled. + * + * @param name - The view name. + * @param properties - Optional view properties. + * @param context - Optional platform-specific context. + */ + #sendOrQueueViewEvent( + name: string, + properties?: AnalyticsEventProperties, + context?: AnalyticsContext, + ): void { + if (!this.#isEventQueuePersistenceEnabled) { + this.#platformAdapter.view(name, properties, context); + return; + } + + const queuedEvent: AnalyticsQueuedViewEvent = { + type: 'view', + name, + messageId: uuid(), + timestamp: new Date().toISOString(), + ...(properties === undefined ? {} : { properties }), + ...(context === undefined ? {} : { context }), + }; + + this.#enqueueEvent(queuedEvent); + } + + /** + * Add an analytics event to the queue and send it. + * + * @param queuedEvent - The event to enqueue and deliver. + */ + #enqueueEvent(queuedEvent: AnalyticsQueuedEvent): void { + const eventQueue: Record = { + ...(this.state.eventQueue ?? {}), + [queuedEvent.messageId]: queuedEvent as unknown as Json, + }; + + this.update((state) => { + state.eventQueue = eventQueue as never; + }); + + this.#sendQueuedEvent(queuedEvent); + } + + /** + * Send a queued event through the platform adapter. + * + * @param queuedEvent - The queued event to deliver. + */ + #sendQueuedEvent(queuedEvent: AnalyticsQueuedEvent): void { + const timestamp = new Date(queuedEvent.timestamp); + + if (Number.isNaN(timestamp.getTime())) { + log('Dropping queued analytics event with invalid timestamp', { + messageId: queuedEvent.messageId, + }); + this.#removeQueuedEvent(queuedEvent.messageId); + return; + } + + const options: AnalyticsDeliveryOptions = { + messageId: queuedEvent.messageId, + timestamp, + callback: (error?: unknown) => { + if (error) { + log('Queued analytics event delivery failed', { + messageId: queuedEvent.messageId, + error, + }); + return; + } + + this.#removeQueuedEvent(queuedEvent.messageId); + }, + }; + + try { + if (queuedEvent.type === 'track') { + this.#platformAdapter.track( + queuedEvent.eventName, + cloneDeep(queuedEvent.properties), + cloneDeep(queuedEvent.context), + options, + ); + } else if (queuedEvent.type === 'identify') { + this.#platformAdapter.identify( + queuedEvent.userId, + cloneDeep(queuedEvent.traits), + cloneDeep(queuedEvent.context), + options, + ); + } else { + this.#platformAdapter.view( + queuedEvent.name, + cloneDeep(queuedEvent.properties), + cloneDeep(queuedEvent.context), + options, + ); + } + } catch (error) { + log('Error sending queued analytics event', { + messageId: queuedEvent.messageId, + error, + }); + } + } + + /** + * Replay persisted analytics events. + */ + #replayQueuedEvents(): void { + if (!this.#isEventQueuePersistenceEnabled || !this.state.eventQueue) { + return; + } + + if (!analyticsControllerSelectors.selectEnabled(this.state)) { + this.#clearQueuedEvents(); + return; + } + + for (const [messageId, queuedEvent] of Object.entries( + this.state.eventQueue, + )) { + if ( + !isAnalyticsQueuedEvent(queuedEvent) || + queuedEvent.messageId !== messageId + ) { + log('Dropping invalid queued analytics event', { messageId }); + this.#removeQueuedEvent(messageId); + continue; + } + + this.#sendQueuedEvent(queuedEvent); + } + } + + /** + * Remove a queued analytics event. + * + * @param messageId - The queued event message ID. + */ + #removeQueuedEvent(messageId: string): void { + const currentEventQueue = this.state.eventQueue; + + if ( + !currentEventQueue || + !Object.prototype.hasOwnProperty.call(currentEventQueue, messageId) + ) { + return; + } + + const { [messageId]: _deletedEvent, ...eventQueue } = currentEventQueue; + + this.update((state) => { + state.eventQueue = eventQueue as never; + }); + } + + /** + * Clear all queued analytics events. + */ + #clearQueuedEvents(): void { + if ( + !this.state.eventQueue || + Object.keys(this.state.eventQueue).length === 0 + ) { + return; + } + + this.update((state) => { + state.eventQueue = {} as never; + }); } /** @@ -285,11 +677,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) { - if (context) { - this.#platformAdapter.track(event.name, undefined, context); - } else { - this.#platformAdapter.track(event.name); - } + this.#sendOrQueueTrackEvent(event.name, undefined, context); return; } @@ -297,30 +685,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. - const properties = { - ...event.properties, - }; - if (context) { - this.#platformAdapter.track(event.name, properties, context); - } else { - this.#platformAdapter.track(event.name, properties); - } + this.#sendOrQueueTrackEvent( + event.name, + { + ...event.properties, + }, + context, + ); } const hasSensitiveProperties = Object.keys(event.sensitiveProperties).length > 0; if (!this.#isAnonymousEventsFeatureEnabled || hasSensitiveProperties) { - 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); - } + this.#sendOrQueueTrackEvent( + event.name, + { + ...event.properties, + ...event.sensitiveProperties, + ...(hasSensitiveProperties && { anonymous: true }), + }, + context, + ); } } @@ -336,11 +722,7 @@ export class AnalyticsController extends BaseController< } // Delegate to platform adapter using the current analytics ID - if (context) { - this.#platformAdapter.identify(this.state.analyticsId, traits, context); - } else { - this.#platformAdapter.identify(this.state.analyticsId, traits); - } + this.#sendOrQueueIdentifyEvent(this.state.analyticsId, traits, context); } /** @@ -360,11 +742,7 @@ export class AnalyticsController extends BaseController< } // Delegate to platform adapter - if (context) { - this.#platformAdapter.view(name, properties, context); - } else { - this.#platformAdapter.view(name, properties); - } + this.#sendOrQueueViewEvent(name, properties, context); } /** @@ -383,5 +761,7 @@ export class AnalyticsController extends BaseController< this.update((state) => { state.optedIn = false; }); + + this.#clearQueuedEvents(); } } diff --git a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts index fdb582f3f4..26bbd0790c 100644 --- a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts +++ b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts @@ -10,6 +10,33 @@ export type AnalyticsEventProperties = Record; */ export type AnalyticsUserTraits = Record; +/** + * Callback invoked by the platform adapter after an analytics payload is + * delivered or fails. + */ +export type AnalyticsInvocationCallback = (error?: unknown) => void; + +/** + * Internal delivery metadata used by AnalyticsController when event queue + * persistence is enabled. + */ +export type AnalyticsDeliveryOptions = { + /** + * Stable identifier for the analytics payload. + */ + messageId?: string; + + /** + * Original timestamp for the analytics payload. + */ + timestamp?: Date; + + /** + * Callback for delivery acknowledgement. + */ + callback?: AnalyticsInvocationCallback; +}; + /** * Event properties structure with two distinct properties lists for regular and sensitive data. * Similar to ITrackingEvent from legacy analytics but decoupled for platform agnosticism. @@ -53,11 +80,13 @@ export type AnalyticsPlatformAdapter = { * @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. + * @param options - Optional delivery metadata for platform adapters. */ track( eventName: string, properties?: AnalyticsEventProperties, context?: AnalyticsContext, + options?: AnalyticsDeliveryOptions, ): void; /** @@ -66,11 +95,13 @@ export type AnalyticsPlatformAdapter = { * @param userId - The user identifier (e.g., metametrics ID) * @param traits - User traits/properties * @param context - Optional platform-specific context attached to the invocation. + * @param options - Optional delivery metadata for platform adapters. */ identify( userId: string, traits?: AnalyticsUserTraits, context?: AnalyticsContext, + options?: AnalyticsDeliveryOptions, ): void; /** @@ -83,11 +114,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. + * @param options - Optional delivery metadata for platform adapters. */ view( name: string, properties?: AnalyticsEventProperties, context?: AnalyticsContext, + options?: AnalyticsDeliveryOptions, ): void; /** diff --git a/packages/analytics-controller/src/index.ts b/packages/analytics-controller/src/index.ts index cb4312feca..8b74f33609 100644 --- a/packages/analytics-controller/src/index.ts +++ b/packages/analytics-controller/src/index.ts @@ -12,13 +12,23 @@ export { AnalyticsPlatformAdapterSetupError } from './AnalyticsPlatformAdapterSe export type { AnalyticsContext, AnalyticsEventProperties, + AnalyticsDeliveryOptions, + AnalyticsInvocationCallback, AnalyticsUserTraits, AnalyticsPlatformAdapter, AnalyticsTrackingEvent, } from './AnalyticsPlatformAdapter.types'; // Export state types -export type { AnalyticsControllerState } from './AnalyticsController'; +export type { + AnalyticsControllerState, + AnalyticsEventQueue, + AnalyticsQueuedEvent, + AnalyticsQueuedEventType, + AnalyticsQueuedTrackEvent, + AnalyticsQueuedIdentifyEvent, + AnalyticsQueuedViewEvent, +} from './AnalyticsController'; // Export selectors export { analyticsControllerSelectors } from './selectors'; diff --git a/yarn.lock b/yarn.lock index 973919d95c..af6e5657e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2670,13 +2670,16 @@ __metadata: "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" + "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" jest: "npm:^29.7.0" + lodash: "npm:^4.17.21" ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + uuid: "npm:^8.3.2" languageName: unknown linkType: soft