Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/analytics-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
108 changes: 108 additions & 0 deletions packages/analytics-controller/src/AnalyticsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
46 changes: 45 additions & 1 deletion packages/analytics-controller/src/AnalyticsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -54,6 +55,13 @@ export type AnalyticsControllerState = {
* This is only used when event queue persistence is enabled.
*/
eventQueue?: Record<string, Json>;

/**
* 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;
};

/**
Expand Down Expand Up @@ -166,6 +174,12 @@ const analyticsControllerMetadata = {
includeInDebugSnapshot: false,
usedInUi: false,
},
latestNonAnonymousEventTimestamp: {
includeInStateLogs: false,
persist: true,
includeInDebugSnapshot: false,
usedInUi: false,
},
} satisfies StateMetadata<AnalyticsControllerState>;

// === MESSENGER ===
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -563,20 +583,26 @@ 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),
cloneDeep(queuedEvent.context),
options,
);
} else if (queuedEvent.type === 'identify') {
this.#updateLatestEventTimestamp({ method: 'identify' });
this.#platformAdapter.identify(
queuedEvent.userId,
cloneDeep(queuedEvent.traits),
cloneDeep(queuedEvent.context),
options,
);
} else {
this.#updateLatestEventTimestamp({ method: 'view' });
this.#platformAdapter.view(
queuedEvent.name,
cloneDeep(queuedEvent.properties),
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -702,7 +744,9 @@ export class AnalyticsController extends BaseController<
{
...event.properties,
...event.sensitiveProperties,
...(hasSensitiveProperties && { anonymous: true }),
...(hasSensitiveProperties && {
[ANONYMOUS_EVENT_PROPERTY]: true,
}),
},
context,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down
1 change: 1 addition & 0 deletions packages/analytics-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type {
AnalyticsPlatformAdapter,
AnalyticsTrackingEvent,
} from './AnalyticsPlatformAdapter.types';
export { ANONYMOUS_EVENT_PROPERTY } from './AnalyticsPlatformAdapter.types';

// Export state types
export type {
Expand Down
31 changes: 31 additions & 0 deletions packages/analytics-controller/src/selectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
11 changes: 11 additions & 0 deletions packages/analytics-controller/src/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -37,4 +47,5 @@ export const analyticsControllerSelectors = {
selectAnalyticsId,
selectOptedIn,
selectEnabled,
selectLatestNonAnonymousEventTimestamp,
};
Loading