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
1 change: 1 addition & 0 deletions packages/analytics-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- 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))

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { AnalyticsController } from './AnalyticsController';
* Events are only tracked if analytics is enabled.
*
* @param event - Analytics event with properties and sensitive properties
* @param context - Optional platform-specific context forwarded to the platform adapter.
*/
export type AnalyticsControllerTrackEventAction = {
type: `AnalyticsController:trackEvent`;
Expand All @@ -21,6 +22,7 @@ export type AnalyticsControllerTrackEventAction = {
* Identify a user for analytics.
*
* @param traits - User traits/properties
* @param context - Optional platform-specific context forwarded to the platform adapter.
*/
export type AnalyticsControllerIdentifyAction = {
type: `AnalyticsController:identify`;
Expand All @@ -32,6 +34,7 @@ export type AnalyticsControllerIdentifyAction = {
*
* @param name - The identifier/name of the page or screen being viewed (e.g., "home", "settings", "wallet")
* @param properties - Optional properties associated with the view
* @param context - Optional platform-specific context forwarded to the platform adapter.
*/
export type AnalyticsControllerTrackViewAction = {
type: `AnalyticsController:trackView`;
Expand Down
138 changes: 138 additions & 0 deletions packages/analytics-controller/src/AnalyticsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
AnalyticsPlatformAdapter,
AnalyticsTrackingEvent,
AnalyticsControllerState,
AnalyticsContext,
} from '.';
import { isValidUUIDv4 } from './analyticsControllerStateValidator';

Expand Down Expand Up @@ -692,6 +693,54 @@ describe('AnalyticsController', () => {
});
});

it('forwards context to the platform adapter', async () => {
const mockAdapter = createMockAdapter();
const { controller } = await setupController({
state: {
optedIn: true,
analyticsId: '77777777-7777-4777-b777-777777777777',
},
platformAdapter: mockAdapter,
});

const event = createTestEvent('test_event', { prop: 'value' });
const context: AnalyticsContext = {
page: { title: 'Unit test' },
};

controller.trackEvent(event, context);

expect(mockAdapter.track).toHaveBeenCalledWith(
'test_event',
{ prop: 'value' },
context,
);
});

it('forwards context when tracking an event without properties', async () => {
const mockAdapter = createMockAdapter();
const { controller } = await setupController({
state: {
optedIn: true,
analyticsId: '77777777-7777-4777-9777-777777777777',
},
platformAdapter: mockAdapter,
});

const event = createTestEvent('test_event', {}, {}, true);
const context: AnalyticsContext = {
page: { path: '/background-process' },
};

controller.trackEvent(event, context);

expect(mockAdapter.track).toHaveBeenCalledWith(
'test_event',
undefined,
context,
);
});

it('tracks event without properties when event has no properties', async () => {
const mockAdapter = createMockAdapter();
const { controller } = await setupController({
Expand Down Expand Up @@ -805,6 +854,47 @@ describe('AnalyticsController', () => {
});
});

it('forwards context to both events when splitting sensitive events', async () => {
const mockAdapter = createMockAdapter();
const { controller } = await setupController({
state: {
optedIn: true,
analyticsId: '11111111-1111-4111-9111-111111111111',
},
platformAdapter: mockAdapter,
isAnonymousEventsFeatureEnabled: true,
});

const event = createTestEvent(
'test_event',
{ prop: 'value' },
{ sensitive_prop: 'sensitive value' },
);
const context: AnalyticsContext = {
app: { name: 'MetaMask' },
};

controller.trackEvent(event, context);

expect(mockAdapter.track).toHaveBeenCalledTimes(2);
expect(mockAdapter.track).toHaveBeenNthCalledWith(
1,
'test_event',
{ prop: 'value' },
context,
);
expect(mockAdapter.track).toHaveBeenNthCalledWith(
2,
'test_event',
{
prop: 'value',
sensitive_prop: 'sensitive value',
anonymous: true,
},
context,
);
});

it('tracks regular properties first, then combined event when only sensitive properties are present', async () => {
const mockAdapter = createMockAdapter();
const { controller } = await setupController({
Expand Down Expand Up @@ -912,6 +1002,31 @@ describe('AnalyticsController', () => {
expect(mockAdapter.identify).toHaveBeenCalledWith(analyticsId, undefined);
});

it('forwards context to the platform adapter', async () => {
const mockAdapter = createMockAdapter();
const analyticsId = 'dddddddd-dddd-4ddd-9ddd-dddddddddddd';
const { controller } = await setupController({
state: {
analyticsId,
optedIn: true,
},
platformAdapter: mockAdapter,
});

const traits = { PLAN: 'pro' };
const context: AnalyticsContext = {
locale: 'en',
};

controller.identify(traits, context);

expect(mockAdapter.identify).toHaveBeenCalledWith(
analyticsId,
traits,
context,
);
});

it('does not identify when disabled', async () => {
const mockAdapter = createMockAdapter();
const { controller } = await setupController({
Expand Down Expand Up @@ -951,6 +1066,29 @@ describe('AnalyticsController', () => {
});
});

it('forwards context to the platform adapter', async () => {
const mockAdapter = createMockAdapter();
const { controller } = await setupController({
state: {
optedIn: true,
analyticsId: 'ffffffff-ffff-4fff-afff-ffffffffffff',
},
platformAdapter: mockAdapter,
});

const context: AnalyticsContext = {
page: { title: 'Settings' },
};

controller.trackView('settings', { section: 'security' }, context);

expect(mockAdapter.view).toHaveBeenCalledWith(
'settings',
{ section: 'security' },
context,
);
});

it('does not call platform adapter when disabled', async () => {
const mockAdapter = createMockAdapter();
const { controller } = await setupController({
Expand Down
50 changes: 40 additions & 10 deletions packages/analytics-controller/src/AnalyticsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { validateAnalyticsControllerState } from './analyticsControllerStateVali
import { projectLogger as log } from './AnalyticsLogger';
import type {
AnalyticsPlatformAdapter,
AnalyticsContext,
AnalyticsEventProperties,
AnalyticsUserTraits,
AnalyticsTrackingEvent,
Expand Down Expand Up @@ -273,8 +274,9 @@ export class AnalyticsController extends BaseController<
* Events are only tracked if analytics is enabled.
*
* @param event - Analytics event with properties and sensitive properties
* @param context - Optional platform-specific context forwarded to the platform adapter.
*/
trackEvent(event: AnalyticsTrackingEvent): void {
trackEvent(event: AnalyticsTrackingEvent, context?: AnalyticsContext): void {
// Don't track if analytics is disabled
if (!analyticsControllerSelectors.selectEnabled(this.state)) {
return;
Expand All @@ -283,58 +285,86 @@ 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);
if (context) {
this.#platformAdapter.track(event.name, undefined, context);
} else {
this.#platformAdapter.track(event.name);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant context branching repeated five times

Low Severity

The if (context) { call(args, context) } else { call(args) } branching pattern is duplicated five times across trackEvent, identify, and trackView. Since context is typed as optional (context?: AnalyticsContext) on the AnalyticsPlatformAdapter methods, simply always passing context (which is undefined when omitted) achieves the same result in a single call per site, removing ~20 lines of redundant branching and reducing the maintenance surface for future changes.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f3dcf3e. Configure here.

return;
}

// Track regular properties first if anonymous events feature is enabled
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, {
const properties = {
...event.properties,
});
};
if (context) {
this.#platformAdapter.track(event.name, properties, context);
} else {
this.#platformAdapter.track(event.name, properties);
}
}

const hasSensitiveProperties =
Object.keys(event.sensitiveProperties).length > 0;

if (!this.#isAnonymousEventsFeatureEnabled || hasSensitiveProperties) {
this.#platformAdapter.track(event.name, {
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);
}
}
}

/**
* Identify a user for analytics.
*
* @param traits - User traits/properties
* @param context - Optional platform-specific context forwarded to the platform adapter.
*/
identify(traits?: AnalyticsUserTraits): void {
identify(traits?: AnalyticsUserTraits, context?: AnalyticsContext): void {
if (!analyticsControllerSelectors.selectEnabled(this.state)) {
return;
}

// Delegate to platform adapter using the current analytics ID
this.#platformAdapter.identify(this.state.analyticsId, traits);
if (context) {
this.#platformAdapter.identify(this.state.analyticsId, traits, context);
} else {
this.#platformAdapter.identify(this.state.analyticsId, traits);
}
}

/**
* Track a page or screen view.
*
* @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 forwarded to the platform adapter.
*/
trackView(name: string, properties?: AnalyticsEventProperties): void {
trackView(
name: string,
properties?: AnalyticsEventProperties,
context?: AnalyticsContext,
): void {
if (!analyticsControllerSelectors.selectEnabled(this.state)) {
return;
}

// Delegate to platform adapter
this.#platformAdapter.view(name, properties);
if (context) {
this.#platformAdapter.view(name, properties, context);
} else {
this.#platformAdapter.view(name, properties);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export type AnalyticsTrackingEvent = {
readonly hasProperties: boolean;
};

/**
* Optional analytics context payload (for example Segment-style context).
*/
export type AnalyticsContext = Record<string, Json>;

/**
* Platform adapter interface for analytics tracking
* Implementations should handle platform-specific details (Segment SDK, etc.)
Expand All @@ -47,16 +52,26 @@ export type AnalyticsPlatformAdapter = {
* @param eventName - The name of the event
* @param properties - Event properties. If not provided, the event has no properties.
* The privacy plugin should check for `isSensitive === true` to determine if an event contains sensitive data.
* @param context - Optional platform-specific context attached to the invocation.
*/
track(eventName: string, properties?: AnalyticsEventProperties): void;
track(
eventName: string,
properties?: AnalyticsEventProperties,
context?: AnalyticsContext,
): void;

/**
* Identify a user with traits.
*
* @param userId - The user identifier (e.g., metametrics ID)
* @param traits - User traits/properties
* @param context - Optional platform-specific context attached to the invocation.
*/
identify(userId: string, traits?: AnalyticsUserTraits): void;
identify(
userId: string,
traits?: AnalyticsUserTraits,
context?: AnalyticsContext,
): void;

/**
* Track a UI unit (page or screen) view depending on the platform
Expand All @@ -67,8 +82,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.
*/
view(name: string, properties?: AnalyticsEventProperties): void;
view(
name: string,
properties?: AnalyticsEventProperties,
context?: AnalyticsContext,
): void;

/**
* Lifecycle hook called after the AnalyticsController is fully initialized.
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 @@ -10,6 +10,7 @@ export { AnalyticsPlatformAdapterSetupError } from './AnalyticsPlatformAdapterSe

// Export types
export type {
AnalyticsContext,
AnalyticsEventProperties,
AnalyticsUserTraits,
AnalyticsPlatformAdapter,
Expand Down
Loading