diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 4f9a137d54..91ea5862ad 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Persisted `latestNonAnonymousEventTimestamp` state in `AnalyticsController`, updated on non-anonymous `track`, `identify`, and `view` delivery attempts. ([#9126](https://github.com/MetaMask/core/pull/9126)) +- `analyticsControllerSelectors.selectLatestNonAnonymousEventTimestamp` and exported `ANONYMOUS_EVENT_PROPERTY` constant. ([#9126](https://github.com/MetaMask/core/pull/9126)) + ### Changed - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 377dcae63e..51a4e63640 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -1073,6 +1073,114 @@ describe('AnalyticsController', () => { }); }); + describe('latestNonAnonymousEventTimestamp', () => { + const analyticsId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + + it('updates on non-anonymous track delivery', async () => { + jest.useFakeTimers(); + const currentTimestamp = new Date('2024-02-01T00:00:00.000Z').getTime(); + jest.setSystemTime(currentTimestamp); + + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId, + }, + }); + + controller.trackEvent(createTestEvent('test_event', { prop: 'value' })); + + expect(controller.state.latestNonAnonymousEventTimestamp).toBe( + currentTimestamp, + ); + jest.useRealTimers(); + }); + + it('does not update on anonymous track delivery', async () => { + const previousTimestamp = new Date('2024-01-01T00:00:00.000Z').getTime(); + const currentTimestamp = new Date('2024-02-01T00:00:00.000Z').getTime(); + jest.useFakeTimers(); + jest.setSystemTime(currentTimestamp); + + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId, + latestNonAnonymousEventTimestamp: previousTimestamp, + }, + isAnonymousEventsFeatureEnabled: false, + }); + + controller.trackEvent( + createTestEvent( + 'test_event', + { prop: 'value' }, + { sensitive_prop: 'sensitive value' }, + ), + ); + + expect(controller.state.latestNonAnonymousEventTimestamp).toBe( + previousTimestamp, + ); + jest.useRealTimers(); + }); + + it('updates on identify and trackView delivery', async () => { + jest.useFakeTimers(); + const currentTimestamp = new Date('2024-03-01T00:00:00.000Z').getTime(); + jest.setSystemTime(currentTimestamp); + + const { controller } = await setupController({ + state: { + optedIn: true, + analyticsId, + }, + }); + + controller.identify({ ENABLE_OPENSEA_API: 'ON' }); + expect(controller.state.latestNonAnonymousEventTimestamp).toBe( + currentTimestamp, + ); + + jest.setSystemTime(currentTimestamp + 1000); + controller.trackView('Home'); + expect(controller.state.latestNonAnonymousEventTimestamp).toBe( + currentTimestamp + 1000, + ); + jest.useRealTimers(); + }); + + it('does not update when analytics is disabled', async () => { + const { controller } = await setupController({ + state: { + optedIn: false, + analyticsId, + }, + }); + + controller.trackEvent(createTestEvent('test_event', { prop: 'value' })); + controller.identify(); + controller.trackView('Home'); + + expect(controller.state.latestNonAnonymousEventTimestamp).toBeUndefined(); + }); + + it('selector returns 0 when latestNonAnonymousEventTimestamp is unset', async () => { + const { controller } = await setupController({ + state: { + optedIn: false, + analyticsId, + }, + }); + + expect( + analyticsControllerSelectors.selectLatestNonAnonymousEventTimestamp( + controller.state, + ), + ).toBe(0); + }); + }); + describe('identify', () => { it('identifies user via platform adapter with traits using current analytics ID', async () => { const mockAdapter = createMockAdapter(); diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 0981690487..6304cd0d94 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -12,6 +12,7 @@ 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 { ANONYMOUS_EVENT_PROPERTY } from './AnalyticsPlatformAdapter.types'; import type { AnalyticsPlatformAdapter, AnalyticsDeliveryOptions, @@ -54,6 +55,13 @@ export type AnalyticsControllerState = { * This is only used when event queue persistence is enabled. */ eventQueue?: Record; + + /** + * Timestamp (milliseconds since epoch) of the latest non-anonymous analytics + * delivery attempt. Used by data deletion workflows to determine whether new + * identifiable analytics data was collected after a deletion request. + */ + latestNonAnonymousEventTimestamp?: number; }; /** @@ -166,6 +174,12 @@ const analyticsControllerMetadata = { includeInDebugSnapshot: false, usedInUi: false, }, + latestNonAnonymousEventTimestamp: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, } satisfies StateMetadata; // === MESSENGER === @@ -438,6 +452,10 @@ export class AnalyticsController extends BaseController< context?: AnalyticsContext, ): void { if (!this.#isEventQueuePersistenceEnabled) { + this.#updateLatestEventTimestamp({ + method: 'track', + properties, + }); this.#platformAdapter.track(eventName, properties, context); return; } @@ -467,6 +485,7 @@ export class AnalyticsController extends BaseController< context?: AnalyticsContext, ): void { if (!this.#isEventQueuePersistenceEnabled) { + this.#updateLatestEventTimestamp({ method: 'identify' }); this.#platformAdapter.identify(userId, traits, context); return; } @@ -496,6 +515,7 @@ export class AnalyticsController extends BaseController< context?: AnalyticsContext, ): void { if (!this.#isEventQueuePersistenceEnabled) { + this.#updateLatestEventTimestamp({ method: 'view' }); this.#platformAdapter.view(name, properties, context); return; } @@ -563,6 +583,10 @@ export class AnalyticsController extends BaseController< try { if (queuedEvent.type === 'track') { + this.#updateLatestEventTimestamp({ + method: 'track', + properties: queuedEvent.properties, + }); this.#platformAdapter.track( queuedEvent.eventName, cloneDeep(queuedEvent.properties), @@ -570,6 +594,7 @@ export class AnalyticsController extends BaseController< options, ); } else if (queuedEvent.type === 'identify') { + this.#updateLatestEventTimestamp({ method: 'identify' }); this.#platformAdapter.identify( queuedEvent.userId, cloneDeep(queuedEvent.traits), @@ -577,6 +602,7 @@ export class AnalyticsController extends BaseController< options, ); } else { + this.#updateLatestEventTimestamp({ method: 'view' }); this.#platformAdapter.view( queuedEvent.name, cloneDeep(queuedEvent.properties), @@ -659,6 +685,22 @@ export class AnalyticsController extends BaseController< }); } + #updateLatestEventTimestamp({ + method, + properties, + }: { + method: AnalyticsQueuedEventType; + properties?: AnalyticsEventProperties; + }): void { + if (method === 'track' && properties?.[ANONYMOUS_EVENT_PROPERTY] === true) { + return; + } + + this.update((state) => { + state.latestNonAnonymousEventTimestamp = Date.now(); + }); + } + /** * Track an analytics event. * @@ -702,7 +744,9 @@ export class AnalyticsController extends BaseController< { ...event.properties, ...event.sensitiveProperties, - ...(hasSensitiveProperties && { anonymous: true }), + ...(hasSensitiveProperties && { + [ANONYMOUS_EVENT_PROPERTY]: true, + }), }, context, ); diff --git a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts index 26bbd0790c..4aa6d362ec 100644 --- a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts +++ b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts @@ -1,5 +1,10 @@ import type { Json } from '@metamask/utils'; +/** + * Property marker used to indicate an analytics track event is anonymous. + */ +export const ANONYMOUS_EVENT_PROPERTY = 'anonymous' as const; + /** * Analytics event properties */ diff --git a/packages/analytics-controller/src/index.ts b/packages/analytics-controller/src/index.ts index 8b74f33609..a3022dcb46 100644 --- a/packages/analytics-controller/src/index.ts +++ b/packages/analytics-controller/src/index.ts @@ -18,6 +18,7 @@ export type { AnalyticsPlatformAdapter, AnalyticsTrackingEvent, } from './AnalyticsPlatformAdapter.types'; +export { ANONYMOUS_EVENT_PROPERTY } from './AnalyticsPlatformAdapter.types'; // Export state types export type { diff --git a/packages/analytics-controller/src/selectors.test.ts b/packages/analytics-controller/src/selectors.test.ts index e522e85fa5..daea083a0e 100644 --- a/packages/analytics-controller/src/selectors.test.ts +++ b/packages/analytics-controller/src/selectors.test.ts @@ -60,4 +60,35 @@ describe('analyticsControllerSelectors', () => { }, ); }); + + describe('selectLatestNonAnonymousEventTimestamp', () => { + it('returns the latest non-anonymous event timestamp from state', () => { + const state: AnalyticsControllerState = { + optedIn: true, + analyticsId: defaultAnalyticsId, + latestNonAnonymousEventTimestamp: 12345, + }; + + const result = + analyticsControllerSelectors.selectLatestNonAnonymousEventTimestamp( + state, + ); + + expect(result).toBe(12345); + }); + + it('returns 0 when latestNonAnonymousEventTimestamp is unset', () => { + const state: AnalyticsControllerState = { + optedIn: true, + analyticsId: defaultAnalyticsId, + }; + + const result = + analyticsControllerSelectors.selectLatestNonAnonymousEventTimestamp( + state, + ); + + expect(result).toBe(0); + }); + }); }); diff --git a/packages/analytics-controller/src/selectors.ts b/packages/analytics-controller/src/selectors.ts index 392909ec3a..7988808484 100644 --- a/packages/analytics-controller/src/selectors.ts +++ b/packages/analytics-controller/src/selectors.ts @@ -29,6 +29,16 @@ const selectOptedIn = (state: AnalyticsControllerState): boolean => const selectEnabled = (state: AnalyticsControllerState): boolean => state.optedIn; +/** + * Selects the timestamp of the latest non-anonymous analytics delivery attempt. + * + * @param state - The controller state + * @returns Timestamp in milliseconds since epoch + */ +const selectLatestNonAnonymousEventTimestamp = ( + state: AnalyticsControllerState, +): number => state.latestNonAnonymousEventTimestamp ?? 0; + /** * Selectors for the AnalyticsController state. * These can be used with Redux or directly with controller state. @@ -37,4 +47,5 @@ export const analyticsControllerSelectors = { selectAnalyticsId, selectOptedIn, selectEnabled, + selectLatestNonAnonymousEventTimestamp, };