From 591474098f0f8e868f5e7645dd4370b374614665 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 14 Jan 2026 13:22:22 +0100 Subject: [PATCH 01/16] feat: add time-based delay for first metrics sending --- .../src/ProfileMetricsController.test.ts | 119 ++++++++++++------ .../src/ProfileMetricsController.ts | 48 ++++++- 2 files changed, 127 insertions(+), 40 deletions(-) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts index 44e3e2d9f8a..14bb067011e 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts @@ -7,7 +7,10 @@ import type { MessengerEvents, } from '@metamask/messenger'; -import { ProfileMetricsController } from './ProfileMetricsController'; +import { + INITIAL_DELAY_DURATION, + ProfileMetricsController, +} from './ProfileMetricsController'; import type { ProfileMetricsControllerMessenger } from './ProfileMetricsController'; import type { ProfileMetricsSubmitMetricsRequest, @@ -58,29 +61,38 @@ describe('ProfileMetricsController', () => { }); describe('constructor subscriptions', () => { - describe('when KeyringController:unlock is published', () => { - it('starts polling if the user opted-in', async () => { - await withController( - { options: { assertUserOptedIn: () => true } }, - async ({ controller, rootMessenger }) => { - const pollSpy = jest.spyOn(controller, 'startPolling'); - - rootMessenger.publish('KeyringController:unlock'); - - expect(pollSpy).toHaveBeenCalledTimes(1); - }, + it('sets the initial delay end timestamp correctly when constructing the controller for the first time', async () => { + await withController(({ controller }) => { + expect(controller.state.initialDelayEndTimestamp).toBe( + Date.now() + INITIAL_DELAY_DURATION, ); }); + }); + + it('retains the initial delay end timestamp when reconstructing the controller from persisted state', async () => { + const pastTimestamp = Date.now() - 10000; + await withController( + { + options: { + state: { initialDelayEndTimestamp: pastTimestamp }, + }, + }, + ({ controller }) => { + expect(controller.state.initialDelayEndTimestamp).toBe(pastTimestamp); + }, + ); + }); - it('does not start polling if the user has not opted-in', async () => { + describe('when KeyringController:unlock is published', () => { + it('starts polling immediately', async () => { await withController( - { options: { assertUserOptedIn: () => false } }, + { options: { assertUserOptedIn: () => true } }, async ({ controller, rootMessenger }) => { const pollSpy = jest.spyOn(controller, 'startPolling'); rootMessenger.publish('KeyringController:unlock'); - expect(pollSpy).not.toHaveBeenCalled(); + expect(pollSpy).toHaveBeenCalledTimes(1); }, ); }); @@ -329,14 +341,39 @@ describe('ProfileMetricsController', () => { }); }); - describe('when the user has opted in to profile metrics', () => { + describe('when the initial delay period has not ended', () => { + it('does not process the sync queue', async () => { + const accounts: Record = { + id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + }; + await withController( + { + options: { + state: { + syncQueue: accounts, + }, + }, + }, + async ({ controller, mockSubmitMetrics }) => { + await controller._executePoll(); + + expect(mockSubmitMetrics).not.toHaveBeenCalled(); + expect(controller.state.syncQueue).toStrictEqual(accounts); + }, + ); + }); + }); + + describe('when the user has opted in to profile metrics and initial timestamp passed', () => { it('processes the sync queue on each poll', async () => { const accounts: Record = { id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], }; await withController( { - options: { state: { syncQueue: accounts } }, + options: { + state: { syncQueue: accounts, initialDelayEndTimestamp: 0 }, + }, }, async ({ controller, getMetaMetricsId, mockSubmitMetrics }) => { await controller._executePoll(); @@ -363,7 +400,9 @@ describe('ProfileMetricsController', () => { }; await withController( { - options: { state: { syncQueue: accounts } }, + options: { + state: { syncQueue: accounts, initialDelayEndTimestamp: 0 }, + }, }, async ({ controller, getMetaMetricsId, mockSubmitMetrics }) => { await controller._executePoll(); @@ -399,7 +438,9 @@ describe('ProfileMetricsController', () => { }; await withController( { - options: { state: { syncQueue: accounts } }, + options: { + state: { syncQueue: accounts, initialDelayEndTimestamp: 0 }, + }, }, async ({ controller, getMetaMetricsId, mockSubmitMetrics }) => { const consoleErrorSpy = jest.spyOn(console, 'error'); @@ -436,51 +477,57 @@ describe('ProfileMetricsController', () => { describe('metadata', () => { it('includes expected state in debug snapshots', async () => { await withController(({ controller }) => { + const expectedState = ` + Object { + "initialDelayEndTimestamp": ${Date.now() + INITIAL_DELAY_DURATION}, + "initialEnqueueCompleted": false, + } + `; expect( deriveStateFromMetadata( controller.state, controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(` - Object { - "initialEnqueueCompleted": false, - } - `); + ).toMatchInlineSnapshot(expectedState); }); }); it('includes expected state in state logs', async () => { await withController(({ controller }) => { + const expectedState = ` + Object { + "initialDelayEndTimestamp": ${Date.now() + INITIAL_DELAY_DURATION}, + "initialEnqueueCompleted": false, + "syncQueue": Object {}, + } + `; expect( deriveStateFromMetadata( controller.state, controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(` - Object { - "initialEnqueueCompleted": false, - "syncQueue": Object {}, - } - `); + ).toMatchInlineSnapshot(expectedState); }); }); it('persists expected state', async () => { await withController(({ controller }) => { + const expectedState = ` + Object { + "initialDelayEndTimestamp": ${Date.now() + INITIAL_DELAY_DURATION}, + "initialEnqueueCompleted": false, + "syncQueue": Object {}, + } + `; expect( deriveStateFromMetadata( controller.state, controller.metadata, 'persist', ), - ).toMatchInlineSnapshot(` - Object { - "initialEnqueueCompleted": false, - "syncQueue": Object {}, - } - `); + ).toMatchInlineSnapshot(expectedState); }); }); diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.ts index 4ba70b8ad36..b258e4c579f 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.ts @@ -27,6 +27,11 @@ import type { AccountWithScopes } from './ProfileMetricsService'; */ export const controllerName = 'ProfileMetricsController'; +/** + * The default delay duration before data is sent for the first time, in milliseconds. + */ +export const INITIAL_DELAY_DURATION = 10 * 60 * 1000; // 10 minutes + /** * Describes the shape of the state object for {@link ProfileMetricsController}. */ @@ -43,6 +48,10 @@ export type ProfileMetricsControllerState = { * source ID are grouped under the key "null". */ syncQueue: Record; + /** + * The timestamp when the first data sending can be attempted. + */ + initialDelayEndTimestamp?: number; }; /** @@ -61,6 +70,12 @@ const profileMetricsControllerMetadata = { includeInStateLogs: true, usedInUi: false, }, + initialDelayEndTimestamp: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: false, + }, } satisfies StateMetadata; /** @@ -194,11 +209,11 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< MESSENGER_EXPOSED_METHODS, ); + this.#setInitialDelayEndTimestampIfNull(); + this.messenger.subscribe('KeyringController:unlock', () => { this.#queueFirstSyncIfNeeded().catch(console.error); - if (this.#assertUserOptedIn()) { - this.startPolling(null); - } + this.startPolling(null); }); this.messenger.subscribe('KeyringController:lock', () => { @@ -227,7 +242,7 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< */ async _executePoll(): Promise { await this.#mutex.runExclusive(async () => { - if (!this.#assertUserOptedIn()) { + if (!this.#assertUserOptedIn() || !this.#isInitialDelayComplete()) { return; } for (const [entropySourceId, accounts] of Object.entries( @@ -283,6 +298,31 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< }); } + /** + * Set the initial delay end timestamp if it is not already set. + */ + #setInitialDelayEndTimestampIfNull(): void { + this.update((state) => { + state.initialDelayEndTimestamp ??= Date.now() + INITIAL_DELAY_DURATION; + }); + } + + /** + * Check if the initial delay end timestamp is in the past. + * + * @returns True if the initial delay period has completed, false otherwise. + */ + #isInitialDelayComplete(): boolean { + // The following check should never be true due to the initialization logic, + // as the `initialDelayEndTimestamp` is always set in the constructor, + // but is included for type safety. Ignoring for code coverage purposes. + // istanbul ignore if + if (this.state.initialDelayEndTimestamp === undefined) { + return false; + } + return Date.now() >= this.state.initialDelayEndTimestamp; + } + /** * Queue the given account to be synced at the next poll. * From 8cd9f3647ba7fb2f95098be51933122055f2c1ce Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 14 Jan 2026 13:40:56 +0100 Subject: [PATCH 02/16] set delay period end timestamp only after user opted in --- .../src/ProfileMetricsController.test.ts | 330 +++++++++--------- .../src/ProfileMetricsController.ts | 8 +- 2 files changed, 177 insertions(+), 161 deletions(-) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts index 14bb067011e..e53ff45b254 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts @@ -61,28 +61,6 @@ describe('ProfileMetricsController', () => { }); describe('constructor subscriptions', () => { - it('sets the initial delay end timestamp correctly when constructing the controller for the first time', async () => { - await withController(({ controller }) => { - expect(controller.state.initialDelayEndTimestamp).toBe( - Date.now() + INITIAL_DELAY_DURATION, - ); - }); - }); - - it('retains the initial delay end timestamp when reconstructing the controller from persisted state', async () => { - const pastTimestamp = Date.now() - 10000; - await withController( - { - options: { - state: { initialDelayEndTimestamp: pastTimestamp }, - }, - }, - ({ controller }) => { - expect(controller.state.initialDelayEndTimestamp).toBe(pastTimestamp); - }, - ); - }); - describe('when KeyringController:unlock is published', () => { it('starts polling immediately', async () => { await withController( @@ -364,171 +342,207 @@ describe('ProfileMetricsController', () => { }); }); - describe('when the user has opted in to profile metrics and initial timestamp passed', () => { - it('processes the sync queue on each poll', async () => { - const accounts: Record = { - id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], - }; - await withController( - { - options: { - state: { syncQueue: accounts, initialDelayEndTimestamp: 0 }, - }, - }, - async ({ controller, getMetaMetricsId, mockSubmitMetrics }) => { - await controller._executePoll(); + describe('when the user has opted in to profile metrics', () => { + it('sets the correct initial delay end timestamp if not set yet', async () => { + await withController(async ({ controller }) => { + await controller._executePoll(); - expect(mockSubmitMetrics).toHaveBeenCalledTimes(1); - expect(mockSubmitMetrics).toHaveBeenCalledWith({ - metametricsId: getMetaMetricsId(), - entropySourceId: 'id1', - accounts: [{ address: '0xAccount1', scopes: ['eip155:1'] }], - }); - expect(controller.state.syncQueue).toStrictEqual({}); - }, - ); + expect(controller.state.initialDelayEndTimestamp).toBe( + Date.now() + INITIAL_DELAY_DURATION, + ); + }); }); - it('processes the sync queue in batches grouped by entropySourceId', async () => { - const accounts: Record = { - id1: [ - { address: '0xAccount1', scopes: ['eip155:1'] }, - { address: '0xAccount2', scopes: ['eip155:1'] }, - ], - id2: [{ address: '0xAccount3', scopes: ['eip155:1'] }], - null: [{ address: '0xAccount4', scopes: ['eip155:1'] }], - }; + it('retains the existing initial delay end timestamp if already set', async () => { + const pastTimestamp = Date.now() - 10000; await withController( { options: { - state: { syncQueue: accounts, initialDelayEndTimestamp: 0 }, + state: { initialDelayEndTimestamp: pastTimestamp }, }, }, - async ({ controller, getMetaMetricsId, mockSubmitMetrics }) => { + async ({ controller }) => { await controller._executePoll(); - expect(mockSubmitMetrics).toHaveBeenCalledTimes(3); - expect(mockSubmitMetrics).toHaveBeenNthCalledWith(1, { - metametricsId: getMetaMetricsId(), - entropySourceId: 'id1', - accounts: [ - { address: '0xAccount1', scopes: ['eip155:1'] }, - { address: '0xAccount2', scopes: ['eip155:1'] }, - ], - }); - expect(mockSubmitMetrics).toHaveBeenNthCalledWith(2, { - metametricsId: getMetaMetricsId(), - entropySourceId: 'id2', - accounts: [{ address: '0xAccount3', scopes: ['eip155:1'] }], - }); - expect(mockSubmitMetrics).toHaveBeenNthCalledWith(3, { - metametricsId: getMetaMetricsId(), - entropySourceId: null, - accounts: [{ address: '0xAccount4', scopes: ['eip155:1'] }], - }); - expect(controller.state.syncQueue).toStrictEqual({}); + expect(controller.state.initialDelayEndTimestamp).toBe( + pastTimestamp, + ); }, ); }); - it('skips one of the batches if the :submitMetrics call fails, but continues processing the rest', async () => { - const accounts: Record = { - id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], - id2: [{ address: '0xAccount2', scopes: ['eip155:1'] }], - }; - await withController( - { - options: { - state: { syncQueue: accounts, initialDelayEndTimestamp: 0 }, + describe('when the initial delay period has ended', () => { + it('processes the sync queue on each poll', async () => { + const accounts: Record = { + id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + }; + await withController( + { + options: { + state: { syncQueue: accounts, initialDelayEndTimestamp: 0 }, + }, }, - }, - async ({ controller, getMetaMetricsId, mockSubmitMetrics }) => { - const consoleErrorSpy = jest.spyOn(console, 'error'); - mockSubmitMetrics.mockImplementationOnce(() => { - throw new Error('Network error'); - }); + async ({ controller, getMetaMetricsId, mockSubmitMetrics }) => { + await controller._executePoll(); + + expect(mockSubmitMetrics).toHaveBeenCalledTimes(1); + expect(mockSubmitMetrics).toHaveBeenCalledWith({ + metametricsId: getMetaMetricsId(), + entropySourceId: 'id1', + accounts: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + }); + expect(controller.state.syncQueue).toStrictEqual({}); + }, + ); + }); - await controller._executePoll(); + it('processes the sync queue in batches grouped by entropySourceId', async () => { + const accounts: Record = { + id1: [ + { address: '0xAccount1', scopes: ['eip155:1'] }, + { address: '0xAccount2', scopes: ['eip155:1'] }, + ], + id2: [{ address: '0xAccount3', scopes: ['eip155:1'] }], + null: [{ address: '0xAccount4', scopes: ['eip155:1'] }], + }; + await withController( + { + options: { + state: { syncQueue: accounts, initialDelayEndTimestamp: 0 }, + }, + }, + async ({ controller, getMetaMetricsId, mockSubmitMetrics }) => { + await controller._executePoll(); + + expect(mockSubmitMetrics).toHaveBeenCalledTimes(3); + expect(mockSubmitMetrics).toHaveBeenNthCalledWith(1, { + metametricsId: getMetaMetricsId(), + entropySourceId: 'id1', + accounts: [ + { address: '0xAccount1', scopes: ['eip155:1'] }, + { address: '0xAccount2', scopes: ['eip155:1'] }, + ], + }); + expect(mockSubmitMetrics).toHaveBeenNthCalledWith(2, { + metametricsId: getMetaMetricsId(), + entropySourceId: 'id2', + accounts: [{ address: '0xAccount3', scopes: ['eip155:1'] }], + }); + expect(mockSubmitMetrics).toHaveBeenNthCalledWith(3, { + metametricsId: getMetaMetricsId(), + entropySourceId: null, + accounts: [{ address: '0xAccount4', scopes: ['eip155:1'] }], + }); + expect(controller.state.syncQueue).toStrictEqual({}); + }, + ); + }); - expect(mockSubmitMetrics).toHaveBeenCalledTimes(2); - expect(mockSubmitMetrics).toHaveBeenNthCalledWith(1, { - metametricsId: getMetaMetricsId(), - entropySourceId: 'id1', - accounts: [{ address: '0xAccount1', scopes: ['eip155:1'] }], - }); - expect(mockSubmitMetrics).toHaveBeenNthCalledWith(2, { - metametricsId: getMetaMetricsId(), - entropySourceId: 'id2', - accounts: [{ address: '0xAccount2', scopes: ['eip155:1'] }], - }); - expect(controller.state.syncQueue).toStrictEqual({ - id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], - }); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to submit profile metrics for entropy source ID id1:', - expect.any(Error), - ); - }, - ); + it('skips one of the batches if the :submitMetrics call fails, but continues processing the rest', async () => { + const accounts: Record = { + id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + id2: [{ address: '0xAccount2', scopes: ['eip155:1'] }], + }; + await withController( + { + options: { + state: { syncQueue: accounts, initialDelayEndTimestamp: 0 }, + }, + }, + async ({ controller, getMetaMetricsId, mockSubmitMetrics }) => { + const consoleErrorSpy = jest.spyOn(console, 'error'); + mockSubmitMetrics.mockImplementationOnce(() => { + throw new Error('Network error'); + }); + + await controller._executePoll(); + + expect(mockSubmitMetrics).toHaveBeenCalledTimes(2); + expect(mockSubmitMetrics).toHaveBeenNthCalledWith(1, { + metametricsId: getMetaMetricsId(), + entropySourceId: 'id1', + accounts: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + }); + expect(mockSubmitMetrics).toHaveBeenNthCalledWith(2, { + metametricsId: getMetaMetricsId(), + entropySourceId: 'id2', + accounts: [{ address: '0xAccount2', scopes: ['eip155:1'] }], + }); + expect(controller.state.syncQueue).toStrictEqual({ + id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to submit profile metrics for entropy source ID id1:', + expect.any(Error), + ); + }, + ); + }); }); }); }); describe('metadata', () => { it('includes expected state in debug snapshots', async () => { - await withController(({ controller }) => { - const expectedState = ` - Object { - "initialDelayEndTimestamp": ${Date.now() + INITIAL_DELAY_DURATION}, - "initialEnqueueCompleted": false, - } - `; - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'includeInDebugSnapshot', - ), - ).toMatchInlineSnapshot(expectedState); - }); + await withController( + { options: { state: { initialDelayEndTimestamp: 10 } } }, + ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + Object { + "initialDelayEndTimestamp": 10, + "initialEnqueueCompleted": false, + } + `); + }, + ); }); it('includes expected state in state logs', async () => { - await withController(({ controller }) => { - const expectedState = ` - Object { - "initialDelayEndTimestamp": ${Date.now() + INITIAL_DELAY_DURATION}, - "initialEnqueueCompleted": false, - "syncQueue": Object {}, - } - `; - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'includeInStateLogs', - ), - ).toMatchInlineSnapshot(expectedState); - }); + await withController( + { options: { state: { initialDelayEndTimestamp: 10 } } }, + ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + Object { + "initialDelayEndTimestamp": 10, + "initialEnqueueCompleted": false, + "syncQueue": Object {}, + } + `); + }, + ); }); it('persists expected state', async () => { - await withController(({ controller }) => { - const expectedState = ` - Object { - "initialDelayEndTimestamp": ${Date.now() + INITIAL_DELAY_DURATION}, - "initialEnqueueCompleted": false, - "syncQueue": Object {}, - } - `; - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'persist', - ), - ).toMatchInlineSnapshot(expectedState); - }); + await withController( + { options: { state: { initialDelayEndTimestamp: 10 } } }, + ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + Object { + "initialDelayEndTimestamp": 10, + "initialEnqueueCompleted": false, + "syncQueue": Object {}, + } + `); + }, + ); }); it('exposes expected state to UI', async () => { diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.ts index b258e4c579f..d4843279615 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.ts @@ -209,8 +209,6 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< MESSENGER_EXPOSED_METHODS, ); - this.#setInitialDelayEndTimestampIfNull(); - this.messenger.subscribe('KeyringController:unlock', () => { this.#queueFirstSyncIfNeeded().catch(console.error); this.startPolling(null); @@ -242,7 +240,11 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< */ async _executePoll(): Promise { await this.#mutex.runExclusive(async () => { - if (!this.#assertUserOptedIn() || !this.#isInitialDelayComplete()) { + if (!this.#assertUserOptedIn()) { + return; + } + this.#setInitialDelayEndTimestampIfNull(); + if (!this.#isInitialDelayComplete()) { return; } for (const [entropySourceId, accounts] of Object.entries( From e01269a70dbe21f5a75cdf66691aa1fe3e17eaa0 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 14 Jan 2026 14:08:44 +0100 Subject: [PATCH 03/16] update changelog --- packages/profile-metrics-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index ded171418f0..8f565323820 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/controller-utils` from `^11.16.0` to `^11.18.0` ([#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583)) - Bump `@metamask/accounts-controller` from `^35.0.0` to `^35.0.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) - Bump `@metamask/polling-controller` from `^16.0.0` to `^16.0.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) +- Set time-based delay for first `ProfileMetricsController` data collection after opt-in ([#7624](https://github.com/MetaMask/core/pull/7624)) ## [2.0.0] From a4f085c0a090dd3c3145b275a6501069f32a45f5 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 14 Jan 2026 14:32:38 +0100 Subject: [PATCH 04/16] skip delay when transaction is submitted --- .../profile-metrics-controller/package.json | 1 + .../src/ProfileMetricsController.test.ts | 26 +++++++++++++++++++ .../src/ProfileMetricsController.ts | 23 +++++++++++++--- yarn.lock | 1 + 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/profile-metrics-controller/package.json b/packages/profile-metrics-controller/package.json index 49ee4262f0f..98aedc547b1 100644 --- a/packages/profile-metrics-controller/package.json +++ b/packages/profile-metrics-controller/package.json @@ -55,6 +55,7 @@ "@metamask/messenger": "^0.3.0", "@metamask/polling-controller": "^16.0.1", "@metamask/profile-sync-controller": "^27.0.0", + "@metamask/transaction-controller": "^62.9.1", "@metamask/utils": "^11.9.0", "async-mutex": "^0.5.0" }, diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts index e53ff45b254..4b158f36866 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts @@ -170,6 +170,31 @@ describe('ProfileMetricsController', () => { }); }); + describe('when TransactionController:transactionSubmitted is published', () => { + it('sets `initialDelayEndTimestamp` to current timestamp to skip the initial delay on the next poll', async () => { + await withController( + { + options: { + state: { + initialDelayEndTimestamp: Date.now() + INITIAL_DELAY_DURATION, + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.publish( + 'TransactionController:transactionSubmitted', + { + // @ts-expect-error Transaction object not needed for this test + foo: 'bar', + }, + ); + + expect(controller.state.initialDelayEndTimestamp).toBe(Date.now()); + }, + ); + }); + }); + describe('when AccountsController:accountAdded is published', () => { describe.each([ { assertUserOptedIn: true }, @@ -626,6 +651,7 @@ function getMessenger( 'KeyringController:lock', 'AccountsController:accountAdded', 'AccountsController:accountRemoved', + 'TransactionController:transactionSubmitted', ], }); return messenger; diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.ts index d4843279615..a76d466989d 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.ts @@ -15,6 +15,7 @@ import type { import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { Messenger } from '@metamask/messenger'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import { TransactionControllerTransactionSubmittedEvent } from '@metamask/transaction-controller'; import { Mutex } from 'async-mutex'; import type { ProfileMetricsServiceMethodActions } from '.'; @@ -140,7 +141,8 @@ type AllowedEvents = | KeyringControllerUnlockEvent | KeyringControllerLockEvent | AccountsControllerAccountAddedEvent - | AccountsControllerAccountRemovedEvent; + | AccountsControllerAccountRemovedEvent + | TransactionControllerTransactionSubmittedEvent; /** * The messenger restricted to actions and events accessed by @@ -214,9 +216,13 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< this.startPolling(null); }); - this.messenger.subscribe('KeyringController:lock', () => { - this.stopAllPolling(); - }); + this.messenger.subscribe('KeyringController:lock', () => + this.stopAllPolling(), + ); + + this.messenger.subscribe('TransactionController:transactionSubmitted', () => + this.#skipInitialDelay(), + ); this.messenger.subscribe('AccountsController:accountAdded', (account) => { this.#addAccountToQueue(account).catch(console.error); @@ -309,6 +315,15 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< }); } + /** + * Skip the initial delay period by setting the end timestamp to the current time. + */ + #skipInitialDelay(): void { + this.update((state) => { + state.initialDelayEndTimestamp = Date.now(); + }); + } + /** * Check if the initial delay end timestamp is in the past. * diff --git a/yarn.lock b/yarn.lock index 5da153fd94f..80b94c18a61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4485,6 +4485,7 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/polling-controller": "npm:^16.0.1" "@metamask/profile-sync-controller": "npm:^27.0.0" + "@metamask/transaction-controller": "npm:^62.9.1" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" From 319540cb1e183536a2922a25cb36b8dd0ac56465 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 14 Jan 2026 14:42:34 +0100 Subject: [PATCH 05/16] update tsconfig and README files --- README.md | 3 +++ packages/profile-metrics-controller/tsconfig.build.json | 3 ++- packages/profile-metrics-controller/tsconfig.json | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 92d9c369396..5fac0025775 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,8 @@ linkStyle default opacity:0.5 app_metadata_controller --> messenger; approval_controller --> base_controller; approval_controller --> messenger; + assets_controller --> base_controller; + assets_controller --> messenger; assets_controllers --> account_tree_controller; assets_controllers --> accounts_controller; assets_controllers --> approval_controller; @@ -352,6 +354,7 @@ linkStyle default opacity:0.5 profile_metrics_controller --> messenger; profile_metrics_controller --> polling_controller; profile_metrics_controller --> profile_sync_controller; + profile_metrics_controller --> transaction_controller; profile_sync_controller --> address_book_controller; profile_sync_controller --> base_controller; profile_sync_controller --> keyring_controller; diff --git a/packages/profile-metrics-controller/tsconfig.build.json b/packages/profile-metrics-controller/tsconfig.build.json index 31d19fe3b86..c8d6956fe1e 100644 --- a/packages/profile-metrics-controller/tsconfig.build.json +++ b/packages/profile-metrics-controller/tsconfig.build.json @@ -12,7 +12,8 @@ { "path": "../../packages/keyring-controller/tsconfig.build.json" }, { "path": "../../packages/messenger/tsconfig.build.json" }, { "path": "../../packages/polling-controller/tsconfig.build.json" }, - { "path": "../../packages/profile-sync-controller/tsconfig.build.json" } + { "path": "../../packages/profile-sync-controller/tsconfig.build.json" }, + { "path": "../../packages/transaction-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/profile-metrics-controller/tsconfig.json b/packages/profile-metrics-controller/tsconfig.json index 9b01f9256c0..56a9c79ec6c 100644 --- a/packages/profile-metrics-controller/tsconfig.json +++ b/packages/profile-metrics-controller/tsconfig.json @@ -10,7 +10,8 @@ { "path": "../../packages/keyring-controller" }, { "path": "../../packages/messenger" }, { "path": "../../packages/polling-controller" }, - { "path": "../../packages/profile-sync-controller" } + { "path": "../../packages/profile-sync-controller" }, + { "path": "../../packages/transaction-controller" } ], "include": ["../../types", "./src"], /** From 3e207569370ca8522826c7d47f8bb8f95b359446 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 14 Jan 2026 15:14:06 +0100 Subject: [PATCH 06/16] add breaking changelog entry --- packages/profile-metrics-controller/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index 8f565323820..bf52f2e6d5f 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** `ProileMetricsControllerMessenger` now requires the `TransactionController:transactionSubmitted` action to be allowed ([#7624](https://github.com/MetaMask/core/pull/7624)) +- Set time-based delay for first `ProfileMetricsController` data collection after opt-in ([#7624](https://github.com/MetaMask/core/pull/7624)) - Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) - Bump `@metamask/controller-utils` from `^11.16.0` to `^11.18.0` ([#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583)) - Bump `@metamask/accounts-controller` from `^35.0.0` to `^35.0.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) - Bump `@metamask/polling-controller` from `^16.0.0` to `^16.0.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) -- Set time-based delay for first `ProfileMetricsController` data collection after opt-in ([#7624](https://github.com/MetaMask/core/pull/7624)) ## [2.0.0] From 96b20bb091a9fca8b9ae593da4802fccca9fe5f4 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 15 Jan 2026 11:02:24 +0100 Subject: [PATCH 07/16] add `initialDelayDuration` constructor option --- .../profile-metrics-controller/CHANGELOG.md | 5 ++++ .../src/ProfileMetricsController.test.ts | 27 ++++++++++++++++--- .../src/ProfileMetricsController.ts | 12 +++++++-- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index bf52f2e6d5f..930841a96c7 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ProfileMetricsController` contructor now accepts an optional `initialDelayDuration` parameter ([#7624](https://github.com/MetaMask/core/pull/7624)) + - The parameter can be used to override the default time-based delay for the first data collection after opt-in + ### Changed - **BREAKING:** `ProileMetricsControllerMessenger` now requires the `TransactionController:transactionSubmitted` action to be allowed ([#7624](https://github.com/MetaMask/core/pull/7624)) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts index 4b158f36866..75f4436539c 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts @@ -8,7 +8,7 @@ import type { } from '@metamask/messenger'; import { - INITIAL_DELAY_DURATION, + DEFAULT_INITIAL_DELAY_DURATION, ProfileMetricsController, } from './ProfileMetricsController'; import type { ProfileMetricsControllerMessenger } from './ProfileMetricsController'; @@ -176,7 +176,8 @@ describe('ProfileMetricsController', () => { { options: { state: { - initialDelayEndTimestamp: Date.now() + INITIAL_DELAY_DURATION, + initialDelayEndTimestamp: + Date.now() + DEFAULT_INITIAL_DELAY_DURATION, }, }, }, @@ -368,16 +369,34 @@ describe('ProfileMetricsController', () => { }); describe('when the user has opted in to profile metrics', () => { - it('sets the correct initial delay end timestamp if not set yet', async () => { + it('sets the correct default initial delay end timestamp if not set yet', async () => { await withController(async ({ controller }) => { await controller._executePoll(); expect(controller.state.initialDelayEndTimestamp).toBe( - Date.now() + INITIAL_DELAY_DURATION, + Date.now() + DEFAULT_INITIAL_DELAY_DURATION, ); }); }); + it('sets a custom initial delay end timestamp if provided via options', async () => { + const customDelay = 60_000; + await withController( + { + options: { + initialDelayDuration: customDelay, + }, + }, + async ({ controller }) => { + await controller._executePoll(); + + expect(controller.state.initialDelayEndTimestamp).toBe( + Date.now() + customDelay, + ); + }, + ); + }); + it('retains the existing initial delay end timestamp if already set', async () => { const pastTimestamp = Date.now() - 10000; await withController( diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.ts index a76d466989d..b20fff764da 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.ts @@ -31,7 +31,7 @@ export const controllerName = 'ProfileMetricsController'; /** * The default delay duration before data is sent for the first time, in milliseconds. */ -export const INITIAL_DELAY_DURATION = 10 * 60 * 1000; // 10 minutes +export const DEFAULT_INITIAL_DELAY_DURATION = 10 * 60 * 1000; // 10 minutes /** * Describes the shape of the state object for {@link ProfileMetricsController}. @@ -165,6 +165,8 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< readonly #getMetaMetricsId: () => string; + readonly #initialDelayDuration: number; + /** * Constructs a new {@link ProfileMetricsController}. * @@ -179,6 +181,8 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< * of the user. * @param args.interval - The interval, in milliseconds, at which the controller will * attempt to send user profile data. Defaults to 10 seconds. + * @param args.initialDelayDuration - The delay duration before data is sent + * for the first time, in milliseconds. Defaults to 10 minutes. */ constructor({ messenger, @@ -186,12 +190,14 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< assertUserOptedIn, getMetaMetricsId, interval = 10 * 1000, + initialDelayDuration = DEFAULT_INITIAL_DELAY_DURATION, }: { messenger: ProfileMetricsControllerMessenger; state?: Partial; interval?: number; assertUserOptedIn: () => boolean; getMetaMetricsId: () => string; + initialDelayDuration?: number; }) { super({ messenger, @@ -205,6 +211,7 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< this.#assertUserOptedIn = assertUserOptedIn; this.#getMetaMetricsId = getMetaMetricsId; + this.#initialDelayDuration = initialDelayDuration; this.messenger.registerMethodActionHandlers( this, @@ -311,7 +318,8 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< */ #setInitialDelayEndTimestampIfNull(): void { this.update((state) => { - state.initialDelayEndTimestamp ??= Date.now() + INITIAL_DELAY_DURATION; + state.initialDelayEndTimestamp ??= + Date.now() + this.#initialDelayDuration; }); } From 2d6574b92031a9bfd9b99e060a957975e6bad38c Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 15 Jan 2026 11:06:24 +0100 Subject: [PATCH 08/16] fix changelog entry --- packages/profile-metrics-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index 930841a96c7..3543c2e8df4 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `ProfileMetricsController` contructor now accepts an optional `initialDelayDuration` parameter ([#7624](https://github.com/MetaMask/core/pull/7624)) +- `ProfileMetricsController` contructor now accepts an optional `initialDelayDuration` parameter ([#7624](https://github.com/MetaMask/core/pull/7624)) - The parameter can be used to override the default time-based delay for the first data collection after opt-in ### Changed From fdf20ce5337de006011ed13cc4ecff0ffc8aa6af Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 15 Jan 2026 17:54:11 +0100 Subject: [PATCH 09/16] disable initial delay if the user has opted in before unlock --- .../src/ProfileMetricsController.test.ts | 11 +++++++++++ .../src/ProfileMetricsController.ts | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts index 75f4436539c..d4d3cd79f79 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts @@ -75,6 +75,17 @@ describe('ProfileMetricsController', () => { ); }); + it('disables the initial delay if the user has opted in to profile metrics', async () => { + await withController( + { options: { assertUserOptedIn: () => true } }, + async ({ controller, rootMessenger }) => { + rootMessenger.publish('KeyringController:unlock'); + + expect(controller.state.initialDelayEndTimestamp).toBe(Date.now()); + }, + ); + }); + describe('when `initialEnqueueCompleted` is false', () => { it.each([{ assertUserOptedIn: true }, { assertUserOptedIn: false }])( 'adds existing accounts to the queue when `assertUserOptedIn` is $assertUserOptedIn', diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.ts index b20fff764da..2c752ca63cc 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.ts @@ -219,6 +219,11 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< ); this.messenger.subscribe('KeyringController:unlock', () => { + if (this.#assertUserOptedIn()) { + // If the user has already opted in at the start of the session, + // it must have opted in during onboarding, or during a previous session. + this.#skipInitialDelay(); + } this.#queueFirstSyncIfNeeded().catch(console.error); this.startPolling(null); }); From 957f48268e82028a697249bce6def81150bafbbd Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 15 Jan 2026 19:27:39 +0100 Subject: [PATCH 10/16] add `skipInitialDelay()` public method and action --- .../profile-metrics-controller/CHANGELOG.md | 2 ++ ...leMetricsController-method-action-types.ts | 20 +++++++++++++ .../src/ProfileMetricsController.test.ts | 18 ++++++++++++ .../src/ProfileMetricsController.ts | 28 ++++++++++--------- .../profile-metrics-controller/src/index.ts | 1 + 5 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 packages/profile-metrics-controller/src/ProfileMetricsController-method-action-types.ts diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index 3543c2e8df4..45fe0b1f899 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ProfileMetricsController` contructor now accepts an optional `initialDelayDuration` parameter ([#7624](https://github.com/MetaMask/core/pull/7624)) - The parameter can be used to override the default time-based delay for the first data collection after opt-in +- Add `skipInitialDelay()` method to `ProfileMetricsController` ([#7624](https://github.com/MetaMask/core/pull/7624)) + - The method can be also called through the `ProfileMetricsController:skipInitialDelay` action via messenger ### Changed diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController-method-action-types.ts b/packages/profile-metrics-controller/src/ProfileMetricsController-method-action-types.ts new file mode 100644 index 00000000000..b12cd4529cb --- /dev/null +++ b/packages/profile-metrics-controller/src/ProfileMetricsController-method-action-types.ts @@ -0,0 +1,20 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { ProfileMetricsController } from './ProfileMetricsController'; + +/** + * Skip the initial delay period by setting the end timestamp to the current time. + */ +export type ProfileMetricsControllerSkipInitialDelayAction = { + type: `ProfileMetricsController:skipInitialDelay`; + handler: ProfileMetricsController['skipInitialDelay']; +}; + +/** + * Union of all ProfileMetricsController action types. + */ +export type ProfileMetricsControllerMethodActions = + ProfileMetricsControllerSkipInitialDelayAction; diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts index d4d3cd79f79..350cfe5a6fc 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts @@ -333,6 +333,24 @@ describe('ProfileMetricsController', () => { }); }); + describe('skipInitialDelay', () => { + it('sets the initial delay end timestamp to the current time', async () => { + const pastTimestamp = Date.now() - 10000; + await withController( + { + options: { + state: { initialDelayEndTimestamp: pastTimestamp }, + }, + }, + async ({ controller }) => { + controller.skipInitialDelay(); + + expect(controller.state.initialDelayEndTimestamp).toBe(Date.now()); + }, + ); + }); + }); + describe('_executePoll', () => { describe('when the user has not opted in to profile metrics', () => { it('does not process the sync queue', async () => { diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.ts index 2c752ca63cc..c434bcbc852 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.ts @@ -19,6 +19,7 @@ import { TransactionControllerTransactionSubmittedEvent } from '@metamask/transa import { Mutex } from 'async-mutex'; import type { ProfileMetricsServiceMethodActions } from '.'; +import type { ProfileMetricsControllerMethodActions } from '.'; import type { AccountWithScopes } from './ProfileMetricsService'; /** @@ -94,7 +95,7 @@ export function getDefaultProfileMetricsControllerState(): ProfileMetricsControl }; } -const MESSENGER_EXPOSED_METHODS = [] as const; +const MESSENGER_EXPOSED_METHODS = ['skipInitialDelay'] as const; /** * Retrieves the state of the {@link ProfileMetricsController}. @@ -109,7 +110,7 @@ export type ProfileMetricsControllerGetStateAction = ControllerGetStateAction< */ export type ProfileMetricsControllerActions = | ProfileMetricsControllerGetStateAction - | ProfileMetricsServiceMethodActions; + | ProfileMetricsControllerMethodActions; /** * Actions from other messengers that {@link ProfileMetricsControllerMessenger} calls. @@ -222,7 +223,7 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< if (this.#assertUserOptedIn()) { // If the user has already opted in at the start of the session, // it must have opted in during onboarding, or during a previous session. - this.#skipInitialDelay(); + this.skipInitialDelay(); } this.#queueFirstSyncIfNeeded().catch(console.error); this.startPolling(null); @@ -233,7 +234,7 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< ); this.messenger.subscribe('TransactionController:transactionSubmitted', () => - this.#skipInitialDelay(), + this.skipInitialDelay(), ); this.messenger.subscribe('AccountsController:accountAdded', (account) => { @@ -247,6 +248,16 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< this.setIntervalLength(interval); } + /** + * Skip the initial delay period by setting the end timestamp to the current time. + * Metrics will be sent on the next poll. + */ + skipInitialDelay(): void { + this.update((state) => { + state.initialDelayEndTimestamp = Date.now(); + }); + } + /** * Execute a single poll to sync user profile data. * @@ -328,15 +339,6 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< }); } - /** - * Skip the initial delay period by setting the end timestamp to the current time. - */ - #skipInitialDelay(): void { - this.update((state) => { - state.initialDelayEndTimestamp = Date.now(); - }); - } - /** * Check if the initial delay end timestamp is in the past. * diff --git a/packages/profile-metrics-controller/src/index.ts b/packages/profile-metrics-controller/src/index.ts index a790d88548f..31c784e47bd 100644 --- a/packages/profile-metrics-controller/src/index.ts +++ b/packages/profile-metrics-controller/src/index.ts @@ -18,3 +18,4 @@ export type { } from './ProfileMetricsService'; export { ProfileMetricsService, serviceName } from './ProfileMetricsService'; export type { ProfileMetricsServiceMethodActions } from './ProfileMetricsService-method-action-types'; +export type { ProfileMetricsControllerMethodActions } from './ProfileMetricsController-method-action-types'; From bea172d01a4ea236b27bc2219f29f403682e50d9 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Fri, 16 Jan 2026 10:47:21 +0100 Subject: [PATCH 11/16] update `ProfileMetricsController-method-action-types.ts` --- .../src/ProfileMetricsController-method-action-types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController-method-action-types.ts b/packages/profile-metrics-controller/src/ProfileMetricsController-method-action-types.ts index b12cd4529cb..81589990bb8 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController-method-action-types.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController-method-action-types.ts @@ -7,6 +7,7 @@ import type { ProfileMetricsController } from './ProfileMetricsController'; /** * Skip the initial delay period by setting the end timestamp to the current time. + * Metrics will be sent on the next poll. */ export type ProfileMetricsControllerSkipInitialDelayAction = { type: `ProfileMetricsController:skipInitialDelay`; From c72fc191048854935e261c1c3524b29a35ef6175 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Fri, 16 Jan 2026 10:53:05 +0100 Subject: [PATCH 12/16] export `ProfileMetricsControllerSkipInitialDelayAction` --- packages/profile-metrics-controller/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/profile-metrics-controller/src/index.ts b/packages/profile-metrics-controller/src/index.ts index 31c784e47bd..a8a928733bf 100644 --- a/packages/profile-metrics-controller/src/index.ts +++ b/packages/profile-metrics-controller/src/index.ts @@ -18,4 +18,7 @@ export type { } from './ProfileMetricsService'; export { ProfileMetricsService, serviceName } from './ProfileMetricsService'; export type { ProfileMetricsServiceMethodActions } from './ProfileMetricsService-method-action-types'; -export type { ProfileMetricsControllerMethodActions } from './ProfileMetricsController-method-action-types'; +export type { + ProfileMetricsControllerMethodActions, + ProfileMetricsControllerSkipInitialDelayAction, +} from './ProfileMetricsController-method-action-types'; From e8140472606cf6ffdf35fb866e674428d7b1f462 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Fri, 16 Jan 2026 14:22:07 +0100 Subject: [PATCH 13/16] remove istanbul ignore if --- .../src/ProfileMetricsController.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.ts index c434bcbc852..8640527d408 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.ts @@ -345,14 +345,10 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< * @returns True if the initial delay period has completed, false otherwise. */ #isInitialDelayComplete(): boolean { - // The following check should never be true due to the initialization logic, - // as the `initialDelayEndTimestamp` is always set in the constructor, - // but is included for type safety. Ignoring for code coverage purposes. - // istanbul ignore if - if (this.state.initialDelayEndTimestamp === undefined) { - return false; - } - return Date.now() >= this.state.initialDelayEndTimestamp; + return ( + this.state.initialDelayEndTimestamp !== undefined && + Date.now() >= this.state.initialDelayEndTimestamp + ); } /** From 750feaa770ce235ba992c7be819dc6606f5c3312 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Fri, 16 Jan 2026 14:36:45 +0100 Subject: [PATCH 14/16] use `inMilliseconds` --- .../src/ProfileMetricsController.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.ts index 8640527d408..4e48fdf9b39 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.ts @@ -16,6 +16,7 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { Messenger } from '@metamask/messenger'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import { TransactionControllerTransactionSubmittedEvent } from '@metamask/transaction-controller'; +import { Duration, inMilliseconds } from '@metamask/utils'; import { Mutex } from 'async-mutex'; import type { ProfileMetricsServiceMethodActions } from '.'; @@ -30,9 +31,12 @@ import type { AccountWithScopes } from './ProfileMetricsService'; export const controllerName = 'ProfileMetricsController'; /** - * The default delay duration before data is sent for the first time, in milliseconds. + * The default delay duration before data is sent for the first time. */ -export const DEFAULT_INITIAL_DELAY_DURATION = 10 * 60 * 1000; // 10 minutes +export const DEFAULT_INITIAL_DELAY_DURATION = inMilliseconds( + 10, + Duration.Minute, +); /** * Describes the shape of the state object for {@link ProfileMetricsController}. From a70fe32f24f6e89edeb4f3a9ad13a0ad57cf8768 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Fri, 16 Jan 2026 14:37:01 +0100 Subject: [PATCH 15/16] use `messenger.captureException` to catch promise error --- .../src/ProfileMetricsController.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.ts index 4e48fdf9b39..228b8ad2944 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.ts @@ -229,7 +229,9 @@ export class ProfileMetricsController extends StaticIntervalPollingController()< // it must have opted in during onboarding, or during a previous session. this.skipInitialDelay(); } - this.#queueFirstSyncIfNeeded().catch(console.error); + this.#queueFirstSyncIfNeeded().catch( + this.messenger.captureException ?? console.error, + ); this.startPolling(null); }); From 0237820b5034bb8ad6b0adb98dae5b0d6d83d40f Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Fri, 16 Jan 2026 14:37:08 +0100 Subject: [PATCH 16/16] bump transaction-controller --- packages/profile-metrics-controller/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/profile-metrics-controller/package.json b/packages/profile-metrics-controller/package.json index 6a3c2020893..d6ecb52bc9d 100644 --- a/packages/profile-metrics-controller/package.json +++ b/packages/profile-metrics-controller/package.json @@ -55,7 +55,7 @@ "@metamask/messenger": "^0.3.0", "@metamask/polling-controller": "^16.0.2", "@metamask/profile-sync-controller": "^27.0.0", - "@metamask/transaction-controller": "^62.9.1", + "@metamask/transaction-controller": "^62.9.2", "@metamask/utils": "^11.9.0", "async-mutex": "^0.5.0" }, diff --git a/yarn.lock b/yarn.lock index 6e7f149f123..1cebfd5d40c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4506,7 +4506,7 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/polling-controller": "npm:^16.0.2" "@metamask/profile-sync-controller": "npm:^27.0.0" - "@metamask/transaction-controller": "npm:^62.9.1" + "@metamask/transaction-controller": "npm:^62.9.2" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1"