diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 016e0fc8f9..f51de31a29 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)) - 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/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..e3532b3d26 100644 --- a/packages/analytics-controller/package.json +++ b/packages/analytics-controller/package.json @@ -55,12 +55,14 @@ "dependencies": { "@metamask/base-controller": "^9.1.0", "@metamask/messenger": "^1.2.0", - "@metamask/utils": "^11.9.0" + "@metamask/utils": "^11.9.0", + "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 16744874a6..39931a9d96 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, } from '.'; @@ -22,6 +23,7 @@ type SetupControllerOptions = { state: AnalyticsControllerState; platformAdapter?: AnalyticsPlatformAdapter; isAnonymousEventsFeatureEnabled?: boolean; + isEventQueuePersistenceEnabled?: boolean; }; type SetupControllerReturn = { @@ -29,6 +31,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. * @@ -36,6 +45,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( @@ -45,6 +55,7 @@ async function setupController( state, platformAdapter, isAnonymousEventsFeatureEnabled = false, + isEventQueuePersistenceEnabled = false, } = options; const adapter = @@ -77,6 +88,7 @@ async function setupController( platformAdapter: adapter, state, isAnonymousEventsFeatureEnabled, + isEventQueuePersistenceEnabled, }); controller.init(); @@ -121,7 +133,7 @@ function createTestEvent( * * @returns A mock AnalyticsPlatformAdapter */ -function createMockAdapter(): AnalyticsPlatformAdapter { +function createMockAdapter(): MockAnalyticsPlatformAdapter { return { track: jest.fn(), identify: jest.fn(), @@ -130,6 +142,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][2] as AnalyticsDeliveryOptions; +} + describe('AnalyticsController', () => { describe('getDefaultAnalyticsControllerState', () => { it('returns default opt-in preferences without analyticsId', () => { @@ -212,6 +238,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, @@ -967,6 +1040,469 @@ 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', + }); + expect(mockAdapter.track.mock.calls[0]).toHaveLength(2); + }); + + 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('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, + }); + + controller.identify({ trait: 'value' }); + controller.trackView('home', { referrer: 'test' }); + + const identifyOptions = getDeliveryOptions(mockAdapter.identify); + const viewOptions = getDeliveryOptions(mockAdapter.view); + + expect(mockAdapter.identify).toHaveBeenCalledWith( + analyticsId, + { trait: 'value' }, + expect.objectContaining({ messageId: identifyOptions.messageId }), + ); + expect(mockAdapter.view).toHaveBeenCalledWith( + 'home', + { referrer: 'test' }, + expect.objectContaining({ messageId: viewOptions.messageId }), + ); + 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' }, + expect.objectContaining({ + messageId: 'track-message-id', + timestamp: new Date(trackEvent.timestamp), + callback: expect.any(Function), + }), + ); + expect(mockAdapter.identify).toHaveBeenCalledWith( + analyticsId, + { trait: 'value' }, + expect.objectContaining({ + messageId: 'identify-message-id', + timestamp: new Date(identifyEvent.timestamp), + callback: expect.any(Function), + }), + ); + expect(mockAdapter.view).toHaveBeenCalledWith( + 'home', + { referrer: 'test' }, + 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('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 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 182386d7ba..282fec318e 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -5,12 +5,15 @@ 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 { 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, AnalyticsEventProperties, AnalyticsUserTraits, AnalyticsTrackingEvent, @@ -43,8 +46,79 @@ 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; +}; + +/** + * Persisted identify event queue entry. + */ +export type AnalyticsQueuedIdentifyEvent = AnalyticsQueuedEventBase & { + type: 'identify'; + userId: string; + traits?: AnalyticsUserTraits; +}; + +/** + * Persisted view event queue entry. + */ +export type AnalyticsQueuedViewEvent = AnalyticsQueuedEventBase & { + type: 'view'; + name: string; + properties?: AnalyticsEventProperties; +}; + +/** + * 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. * @@ -81,6 +155,12 @@ const analyticsControllerMetadata = { includeInDebugSnapshot: true, usedInUi: false, }, + eventQueue: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, } satisfies StateMetadata; // === MESSENGER === @@ -168,8 +248,70 @@ 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)) + ); + } + + if (value.type === 'identify') { + return ( + typeof value.userId === 'string' && + (value.traits === undefined || isRecord(value.traits)) + ); + } + + if (value.type === 'view') { + return ( + typeof value.name === 'string' && + (value.properties === undefined || isRecord(value.properties)) + ); + } + + return false; +} + /** * The AnalyticsController manages analytics tracking across platforms (Mobile/Extension). * It provides a unified interface for tracking events, identifying users, and managing @@ -192,6 +334,8 @@ export class AnalyticsController extends BaseController< readonly #isAnonymousEventsFeatureEnabled: boolean; + readonly #isEventQueuePersistenceEnabled: boolean; + #initialized: boolean; /** @@ -203,6 +347,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. */ @@ -211,6 +356,7 @@ export class AnalyticsController extends BaseController< messenger, platformAdapter, isAnonymousEventsFeatureEnabled = false, + isEventQueuePersistenceEnabled = false, }: AnalyticsControllerOptions) { const initialState: AnalyticsControllerState = { ...getDefaultAnalyticsControllerState(), @@ -230,6 +376,7 @@ export class AnalyticsController extends BaseController< }); this.#isAnonymousEventsFeatureEnabled = isAnonymousEventsFeatureEnabled; + this.#isEventQueuePersistenceEnabled = isEventQueuePersistenceEnabled; this.#platformAdapter = platformAdapter; this.#initialized = false; @@ -242,6 +389,7 @@ export class AnalyticsController extends BaseController< enabled: analyticsControllerSelectors.selectEnabled(this.state), optedIn: this.state.optedIn, analyticsId: this.state.analyticsId, + eventQueuePersistenceEnabled: this.#isEventQueuePersistenceEnabled, }); } @@ -265,6 +413,236 @@ 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. + */ + #sendOrQueueTrackEvent( + eventName: string, + properties?: AnalyticsEventProperties, + ): void { + if (!this.#isEventQueuePersistenceEnabled) { + if (properties === undefined) { + this.#platformAdapter.track(eventName); + return; + } + + this.#platformAdapter.track(eventName, properties); + return; + } + + const queuedEvent: AnalyticsQueuedTrackEvent = { + type: 'track', + eventName, + messageId: uuid(), + timestamp: new Date().toISOString(), + ...(properties === undefined ? {} : { properties }), + }; + + 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. + */ + #sendOrQueueIdentifyEvent( + userId: string, + traits?: AnalyticsUserTraits, + ): void { + if (!this.#isEventQueuePersistenceEnabled) { + this.#platformAdapter.identify(userId, traits); + return; + } + + const queuedEvent: AnalyticsQueuedIdentifyEvent = { + type: 'identify', + userId, + messageId: uuid(), + timestamp: new Date().toISOString(), + ...(traits === undefined ? {} : { traits }), + }; + + 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. + */ + #sendOrQueueViewEvent( + name: string, + properties?: AnalyticsEventProperties, + ): void { + if (!this.#isEventQueuePersistenceEnabled) { + this.#platformAdapter.view(name, properties); + return; + } + + const queuedEvent: AnalyticsQueuedViewEvent = { + type: 'view', + name, + messageId: uuid(), + timestamp: new Date().toISOString(), + ...(properties === undefined ? {} : { properties }), + }; + + 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, + queuedEvent.properties, + options, + ); + } else if (queuedEvent.type === 'identify') { + this.#platformAdapter.identify( + queuedEvent.userId, + queuedEvent.traits, + options, + ); + } else { + this.#platformAdapter.view( + queuedEvent.name, + queuedEvent.properties, + 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; + }); } /** @@ -283,7 +661,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.#sendOrQueueTrackEvent(event.name); return; } @@ -291,7 +669,7 @@ 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, { + this.#sendOrQueueTrackEvent(event.name, { ...event.properties, }); } @@ -300,7 +678,7 @@ export class AnalyticsController extends BaseController< Object.keys(event.sensitiveProperties).length > 0; if (!this.#isAnonymousEventsFeatureEnabled || hasSensitiveProperties) { - this.#platformAdapter.track(event.name, { + this.#sendOrQueueTrackEvent(event.name, { ...event.properties, ...event.sensitiveProperties, ...(hasSensitiveProperties && { anonymous: true }), @@ -319,7 +697,7 @@ export class AnalyticsController extends BaseController< } // Delegate to platform adapter using the current analytics ID - this.#platformAdapter.identify(this.state.analyticsId, traits); + this.#sendOrQueueIdentifyEvent(this.state.analyticsId, traits); } /** @@ -334,7 +712,7 @@ export class AnalyticsController extends BaseController< } // Delegate to platform adapter - this.#platformAdapter.view(name, properties); + this.#sendOrQueueViewEvent(name, properties); } /** @@ -353,5 +731,9 @@ export class AnalyticsController extends BaseController< this.update((state) => { state.optedIn = false; }); + + if (this.#isEventQueuePersistenceEnabled) { + this.#clearQueuedEvents(); + } } } diff --git a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts index 78485ba6f4..76fe3aa6a4 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. @@ -46,17 +73,27 @@ export type AnalyticsPlatformAdapter = { * * @param eventName - The name of the event * @param properties - Event properties. If not provided, the event has no properties. + * @param options - Optional delivery metadata for platform adapters. * The privacy plugin should check for `isSensitive === true` to determine if an event contains sensitive data. */ - track(eventName: string, properties?: AnalyticsEventProperties): void; + track( + eventName: string, + properties?: AnalyticsEventProperties, + options?: AnalyticsDeliveryOptions, + ): void; /** * Identify a user with traits. * * @param userId - The user identifier (e.g., metametrics ID) * @param traits - User traits/properties + * @param options - Optional delivery metadata for platform adapters. */ - identify(userId: string, traits?: AnalyticsUserTraits): void; + identify( + userId: string, + traits?: AnalyticsUserTraits, + options?: AnalyticsDeliveryOptions, + ): void; /** * Track a UI unit (page or screen) view depending on the platform @@ -67,8 +104,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 delivery metadata for platform adapters. */ - view(name: string, properties?: AnalyticsEventProperties): void; + view( + name: string, + properties?: AnalyticsEventProperties, + options?: AnalyticsDeliveryOptions, + ): 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..9a874b9d1d 100644 --- a/packages/analytics-controller/src/index.ts +++ b/packages/analytics-controller/src/index.ts @@ -11,13 +11,23 @@ export { AnalyticsPlatformAdapterSetupError } from './AnalyticsPlatformAdapterSe // Export types export type { 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 c4599a4918..9f3b74d818 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2670,6 +2670,7 @@ __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" ts-jest: "npm:^29.2.5" @@ -2677,6 +2678,7 @@ __metadata: 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