diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 00ee789e76..5813066066 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add optional `getCanonicalProfileId` constructor callback and `metaMetricsFlags` map so threshold flags segment users by canonical profile ID by default, or by MetaMetrics ID when the flag name is present in `metaMetricsFlags` ([#9325](https://github.com/MetaMask/core/pull/9325)) - Add optional `featureFlagThresholdGroups` field to `RemoteFeatureFlagControllerState` to map feature flag names to their selected threshold group names ([#9289](https://github.com/MetaMask/core/pull/9289)) - Add optional `metaMetricsIds` field to threshold feature flag entries for explicit user targeting ([#9340](https://github.com/MetaMask/core/pull/9340)) - When a threshold entry includes `metaMetricsIds: string[]`, the entry is selected immediately if the current user's MetaMetrics ID appears in that list, bypassing hash-based rollout. This is intended for QA and Product Manager testing in any environment, including production. diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts index 50f4f68902..9f40779d07 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts @@ -42,11 +42,17 @@ const MOCK_FLAGS_WITH_THRESHOLD = { scope: { type: 'threshold', value: 0.5 }, value: 'valueB', }, - { name: 'groupC', scope: { type: 'threshold', value: 1 }, value: 'valueC' }, + { + name: 'groupC', + scope: { type: 'threshold', value: 1 }, + value: 'valueC', + }, ], }; const MOCK_METRICS_ID = 'f9e8d7c6-b5a4-4210-9876-543210fedcba'; +const MOCK_CANONICAL_ID = + '0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420'; const MOCK_BASE_VERSION = '13.10.0'; /** @@ -57,6 +63,8 @@ const MOCK_BASE_VERSION = '13.10.0'; * @param options.clientConfigApiService - The client config API service instance * @param options.disabled - Whether the controller should start disabled * @param options.getMetaMetricsId - Returns metaMetricsId + * @param options.getCanonicalProfileId - Returns canonicalProfileId + * @param options.metaMetricsFlags - Feature flags that should use MetaMetrics ID * @param options.clientVersion - The client version string * @param options.prevClientVersion - The previous client version string * @returns The controller and the root messenger @@ -67,6 +75,8 @@ function createController( clientConfigApiService: AbstractClientConfigApiService; disabled: boolean; getMetaMetricsId: () => string; + getCanonicalProfileId: () => string; + metaMetricsFlags: Record; clientVersion: string; prevClientVersion: string; }> = {}, @@ -81,6 +91,10 @@ function createController( getMetaMetricsId: options.getMetaMetricsId ?? ((): typeof MOCK_METRICS_ID => MOCK_METRICS_ID), + getCanonicalProfileId: + options.getCanonicalProfileId ?? + ((): typeof MOCK_CANONICAL_ID => MOCK_CANONICAL_ID), + metaMetricsFlags: options.metaMetricsFlags, clientVersion: options.clientVersion ?? MOCK_BASE_VERSION, prevClientVersion: options.prevClientVersion, }); @@ -100,6 +114,35 @@ describe('RemoteFeatureFlagController', () => { }); }); + it('defaults getCanonicalProfileId to an empty string when omitted', async () => { + const mockFlags = { + canonicalThresholdFlag: [ + { + name: 'groupA', + scope: { type: 'threshold', value: 1.0 }, + value: 'canonicalA', + }, + ], + }; + const { rootMessenger, controllerMessenger } = buildMessenger(); + const controller = new RemoteFeatureFlagController({ + messenger: controllerMessenger, + clientConfigApiService: buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }), + getMetaMetricsId: (): string => MOCK_METRICS_ID, + clientVersion: MOCK_BASE_VERSION, + }); + + await rootMessenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect( + controller.state.remoteFeatureFlags.canonicalThresholdFlag, + ).toStrictEqual(mockFlags.canonicalThresholdFlag); + }); + it('initializes with default state if the disabled parameter is provided', () => { const { controller } = createController({ disabled: true }); @@ -471,6 +514,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlagForThreshold: true }, }); await messenger.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', @@ -506,6 +550,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger.call( @@ -527,6 +572,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', @@ -571,6 +617,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { featureA: true, featureB: true }, }); // Act @@ -603,6 +650,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); // Act @@ -639,6 +687,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { mixedArray: true }, }); // Act @@ -680,6 +729,7 @@ describe('RemoteFeatureFlagController', () => { createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger1.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', @@ -690,6 +740,7 @@ describe('RemoteFeatureFlagController', () => { createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger2.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', @@ -704,10 +755,74 @@ describe('RemoteFeatureFlagController', () => { testFlag: 'control', }); }); + + it('uses getCanonicalProfileId for threshold flags absent from metaMetricsFlags', async () => { + const mockFlags = { + canonicalThresholdFlag: [ + { + name: 'groupA', + scope: { type: 'threshold', value: 0.5 }, + value: 'canonicalA', + }, + { + name: 'groupB', + scope: { type: 'threshold', value: 1.0 }, + value: 'canonicalB', + }, + ], + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + getMetaMetricsId: () => '', + getCanonicalProfileId: () => MOCK_CANONICAL_ID, + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect(controller.state.remoteFeatureFlags.canonicalThresholdFlag).toBe( + 'canonicalB', + ); + expect(controller.state.thresholdCache).toStrictEqual({ + [`${MOCK_CANONICAL_ID}:canonicalThresholdFlag`]: expect.any(Number), + }); + }); + + it('preserves threshold arrays when canonical profile id is empty', async () => { + const mockFlags = { + canonicalThresholdFlag: [ + { + name: 'groupA', + scope: { type: 'threshold', value: 1.0 }, + value: 'canonicalA', + }, + ], + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + getMetaMetricsId: () => MOCK_METRICS_ID, + getCanonicalProfileId: () => '', + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect( + controller.state.remoteFeatureFlags.canonicalThresholdFlag, + ).toStrictEqual(mockFlags.canonicalThresholdFlag); + }); }); describe('metaMetricsIds explicit targeting', () => { - const MOCK_FLAGS_WITH_EXPLICIT_IDS = { + const MOCK_FLAGS_WITH_EXPLICIT_IDS: FeatureFlags = { testFlag: [ { name: 'qaGroup', @@ -735,6 +850,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger.call( @@ -748,7 +864,7 @@ describe('RemoteFeatureFlagController', () => { }); it('first entry with a matching metaMetricsId wins when multiple entries match', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { name: 'first', @@ -770,6 +886,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger.call( @@ -783,7 +900,7 @@ describe('RemoteFeatureFlagController', () => { }); it('falls back to hash-based threshold when no entry matches the metaMetricsId', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { name: 'qaGroup', @@ -809,6 +926,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger.call( @@ -823,7 +941,7 @@ describe('RemoteFeatureFlagController', () => { }); it('ignores entries with a malformed metaMetricsIds (non-array) and falls back to hash-based threshold', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { name: 'badGroup', @@ -863,7 +981,7 @@ describe('RemoteFeatureFlagController', () => { }); it('ignores non-string items within metaMetricsIds when matching', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { name: 'badGroup', @@ -898,7 +1016,7 @@ describe('RemoteFeatureFlagController', () => { }); it('normalizes metaMetricsId with trim and toLowerCase before matching', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { name: 'qaGroup', @@ -952,7 +1070,7 @@ describe('RemoteFeatureFlagController', () => { }); it('still populates the threshold cache for hash-based fallback when no explicit ID matches', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { name: 'qaGroup', @@ -973,6 +1091,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger.call( @@ -997,8 +1116,13 @@ describe('RemoteFeatureFlagController', () => { 'RemoteFeatureFlagController:updateRemoteFeatureFlags', ); - const rawEntries = controller.state.rawRemoteFeatureFlags - .testFlag as Record[]; + expect(controller.state.rawRemoteFeatureFlags).toBeDefined(); + const rawRemoteFeatureFlags = controller.state + .rawRemoteFeatureFlags as FeatureFlags; + const rawEntries = rawRemoteFeatureFlags.testFlag as Record< + string, + unknown + >[]; expect( rawEntries.every((entry) => entry.metaMetricsIds === undefined), ).toBe(true); @@ -1008,11 +1132,13 @@ describe('RemoteFeatureFlagController', () => { const clientConfigApiService = buildClientConfigApiService({ remoteFeatureFlags: MOCK_FLAGS_WITH_EXPLICIT_IDS, }); - // No metaMetricsId → threshold arrays are preserved as-is, but - // metaMetricsIds must still be stripped from the processed output. + // This flag is configured to use MetaMetrics ID. When unavailable, + // threshold arrays are preserved as-is, but metaMetricsIds must still be + // stripped from the processed output. const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => '', + metaMetricsFlags: { testFlag: true }, }); await messenger.call( @@ -1049,7 +1175,7 @@ describe('RemoteFeatureFlagController', () => { }); it('supports ThresholdVersion.DirectValue entries with explicit-ID matching', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { thresholdName: 'qaGroup', @@ -1382,6 +1508,7 @@ describe('RemoteFeatureFlagController', () => { clientConfigApiService: mockApiService, clientVersion: '13.1.5', // Qualifies for 13.1.0 version but not 13.2.0 getMetaMetricsId: () => MOCK_METRICS_ID, // This generates threshold > 0.7 + metaMetricsFlags: { multiVersionABFlag: true }, }); await messenger.call( @@ -1686,6 +1813,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { flagA: true, flagB: true }, }); // Act - First update: both flags processed @@ -1816,6 +1944,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { persistentFlag: true }, }); // Act - Multiple updates with same flag @@ -1862,6 +1991,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, state: { thresholdCache: { [`${differentUserId}:oldFlag`]: 0.123, // Different user's cache @@ -1898,6 +2028,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { newFlag: true }, }); // Act - Process with empty cache @@ -1927,6 +2058,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { oldFlag: true, newFlag: true }, }); await messenger.call( @@ -1989,6 +2121,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => '', // Empty metaMetricsId + metaMetricsFlags: { thresholdFlag: true }, }); // Act @@ -2019,6 +2152,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { 'feature:v2': true }, }); // Act @@ -2067,6 +2201,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { flagA: true, flagB: true }, }); // Act - First update populates cache diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts index f04531a32b..eabfe2d1a3 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts @@ -212,6 +212,10 @@ export class RemoteFeatureFlagController extends BaseController< readonly #getMetaMetricsId: () => string; + readonly #getCanonicalProfileId: () => string; + + readonly #metaMetricsFlags: Record; + readonly #clientVersion: SemVerVersion; #processedRemoteFeatureFlags: FeatureFlags = {}; @@ -226,6 +230,8 @@ export class RemoteFeatureFlagController extends BaseController< * @param options.fetchInterval - The interval in milliseconds before cached flags expire. Defaults to 1 day. * @param options.disabled - Determines if the controller should be disabled initially. Defaults to false. * @param options.getMetaMetricsId - Returns metaMetricsId. + * @param options.getCanonicalProfileId - Returns the canonical profile identifier used for threshold flags by default. + * @param options.metaMetricsFlags - Map of feature flag names that should use MetaMetrics ID for threshold assignment. * @param options.clientVersion - The current client version for version-based feature flag filtering. Must be a valid 3-part SemVer version string. * @param options.prevClientVersion - The previous client version for feature flag cache invalidation. */ @@ -236,6 +242,8 @@ export class RemoteFeatureFlagController extends BaseController< fetchInterval = DEFAULT_CACHE_DURATION, disabled = false, getMetaMetricsId, + getCanonicalProfileId = (): string => '', + metaMetricsFlags = {}, clientVersion, prevClientVersion, }: { @@ -243,6 +251,8 @@ export class RemoteFeatureFlagController extends BaseController< state?: Partial; clientConfigApiService: AbstractClientConfigApiService; getMetaMetricsId: () => string; + getCanonicalProfileId?: () => string; + metaMetricsFlags?: Record; fetchInterval?: number; disabled?: boolean; clientVersion: string; @@ -294,6 +304,8 @@ export class RemoteFeatureFlagController extends BaseController< this.#disabled = disabled; this.#clientConfigApiService = clientConfigApiService; this.#getMetaMetricsId = getMetaMetricsId; + this.#getCanonicalProfileId = getCanonicalProfileId; + this.#metaMetricsFlags = metaMetricsFlags; this.#clientVersion = clientVersion; this.messenger.registerMethodActionHandlers( @@ -354,6 +366,7 @@ export class RemoteFeatureFlagController extends BaseController< } = await this.#processRemoteFeatureFlags(remoteFeatureFlags); const metaMetricsId = this.#getMetaMetricsId(); + const canonicalProfileId = this.#getCanonicalProfileId(); const currentFlagNames = Object.keys(remoteFeatureFlags); // Build updated threshold cache @@ -366,10 +379,12 @@ export class RemoteFeatureFlagController extends BaseController< // Clean up stale entries for (const cacheKey of Object.keys(updatedThresholdCache)) { - const [cachedMetaMetricsId, ...cachedFlagNameParts] = cacheKey.split(':'); + const [cachedSegmentationId, ...cachedFlagNameParts] = + cacheKey.split(':'); const cachedFlagName = cachedFlagNameParts.join(':'); if ( - cachedMetaMetricsId === metaMetricsId && + (cachedSegmentationId === metaMetricsId || + cachedSegmentationId === canonicalProfileId) && !currentFlagNames.includes(cachedFlagName) ) { delete updatedThresholdCache[cacheKey]; @@ -414,13 +429,24 @@ export class RemoteFeatureFlagController extends BaseController< return getVersionData(flagValue, this.#clientVersion); } + #getSegmentationId(featureFlagName: string): string { + if ( + Object.prototype.hasOwnProperty.call( + this.#metaMetricsFlags, + featureFlagName, + ) + ) { + return this.#getMetaMetricsId(); + } + return this.#getCanonicalProfileId(); + } + async #processRemoteFeatureFlags(remoteFeatureFlags: FeatureFlags): Promise<{ processedFlags: FeatureFlags; thresholdCacheUpdates: Record; featureFlagThresholdGroupUpdates: Record; }> { const processedFlags: FeatureFlags = {}; - const metaMetricsId = this.#getMetaMetricsId(); const thresholdCacheUpdates: Record = {}; const featureFlagThresholdGroupUpdates: Record = {}; @@ -447,19 +473,12 @@ export class RemoteFeatureFlagController extends BaseController< continue; } - // Skip threshold processing if metaMetricsId is not available - if (!metaMetricsId) { - // Preserve array as-is when user hasn't opted into MetaMetrics - processedFlags[remoteFeatureFlagName] = processedValue; - continue; - } - // Explicit-ID matching: check before hash-based threshold, bypasses cache + const metaMetricsId = this.#getMetaMetricsId(); const normalizedMetaMetricsId = metaMetricsId.trim().toLowerCase(); - const explicitMatch = findExplicitIdMatch( - processedValue, - normalizedMetaMetricsId, - ); + const explicitMatch = normalizedMetaMetricsId + ? findExplicitIdMatch(processedValue, normalizedMetaMetricsId) + : undefined; if (explicitMatch) { processedValue = explicitMatch.value; @@ -468,13 +487,21 @@ export class RemoteFeatureFlagController extends BaseController< explicitMatch.name; } } else { + const segmentationId = this.#getSegmentationId(remoteFeatureFlagName); + + if (!segmentationId) { + processedFlags[remoteFeatureFlagName] = processedValue; + continue; + } + // Fall back to hash-based threshold selection with cache - const cacheKey = `${metaMetricsId}:${remoteFeatureFlagName}` as const; + const cacheKey = + `${segmentationId}:${remoteFeatureFlagName}` as const; let thresholdValue = this.state.thresholdCache?.[cacheKey]; if (thresholdValue === undefined) { thresholdValue = await calculateThresholdForFlag( - metaMetricsId, + segmentationId, remoteFeatureFlagName, ); diff --git a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts index 44e0b10b32..b06e0a6f18 100644 --- a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts +++ b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts @@ -124,15 +124,15 @@ describe('user-segmentation-utils', () => { expect(threshold).toBeLessThanOrEqual(1); }); - it('throws error when metaMetricsId is empty', async () => { + it('throws error when segmentation ID is empty', async () => { // Arrange - const emptyMetaMetricsId = ''; + const emptySegmentationId = ''; const flagName = 'testFlag'; // Act & Assert await expect( - calculateThresholdForFlag(emptyMetaMetricsId, flagName), - ).rejects.toThrow('MetaMetrics ID cannot be empty'); + calculateThresholdForFlag(emptySegmentationId, flagName), + ).rejects.toThrow('Segmentation ID cannot be empty'); }); it('throws error when featureFlagName is empty', async () => { diff --git a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts index f2f4877310..0715ea0ecc 100644 --- a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts +++ b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts @@ -22,27 +22,27 @@ const UUID_V4_VALUE_RANGE_BIGINT = MAX_UUID_V4_BIGINT - MIN_UUID_V4_BIGINT; /** * Calculates a deterministic threshold value between 0 and 1 for A/B testing. - * This function hashes the user's MetaMetrics ID combined with the feature flag name + * This function hashes the segmentation ID combined with the feature flag name * to ensure consistent group assignment across sessions while varying across different flags. * - * @param metaMetricsId - The user's MetaMetrics ID (must be non-empty) + * @param segmentationId - The identifier used for threshold segmentation (must be non-empty) * @param featureFlagName - The feature flag name to create unique threshold per flag * @returns A promise that resolves to a number between 0 and 1 - * @throws Error if metaMetricsId is empty + * @throws Error if segmentationId is empty */ export async function calculateThresholdForFlag( - metaMetricsId: string, + segmentationId: string, featureFlagName: string, ): Promise { - if (!metaMetricsId) { - throw new Error('MetaMetrics ID cannot be empty'); + if (!segmentationId) { + throw new Error('Segmentation ID cannot be empty'); } if (!featureFlagName) { throw new Error('Feature flag name cannot be empty'); } - const seed = metaMetricsId + featureFlagName; + const seed = segmentationId + featureFlagName; // Hash the combined seed const encoder = new TextEncoder(); diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 44af161a59..211d111ea0 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Pass optional `getCanonicalProfileId` and `metaMetricsFlags` through `RemoteFeatureFlagController` wallet initialization for threshold flag segmentation ([#9325](https://github.com/MetaMask/core/pull/9325)) + ## [7.0.0] ### Added diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts index 5f155deb02..3ea9fde7ed 100644 --- a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts @@ -17,6 +17,9 @@ export const remoteFeatureFlagController: InitializationConfiguration< messenger, clientConfigApiService: options.clientConfigApiService, getMetaMetricsId: options.getMetaMetricsId ?? ((): string => ''), + getCanonicalProfileId: + options.getCanonicalProfileId ?? ((): string => ''), + metaMetricsFlags: options.metaMetricsFlags, clientVersion: options.clientVersion ?? '0.0.0', prevClientVersion: options.prevClientVersion, fetchInterval: options.fetchInterval, diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts index 1477c632cf..4761b9c7bc 100644 --- a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts @@ -21,6 +21,16 @@ export type RemoteFeatureFlagControllerInstanceOptions = { * Defaults to `() => ''`. */ getMetaMetricsId?: RemoteFeatureFlagControllerOptions['getMetaMetricsId']; + /** + * Returns the canonical profile identifier used for threshold flags by + * default. Defaults to `() => ''`. + */ + getCanonicalProfileId?: RemoteFeatureFlagControllerOptions['getCanonicalProfileId']; + /** + * Map of feature flag names that should use MetaMetrics ID for threshold + * assignment. Flags not present in this map use the canonical profile ID. + */ + metaMetricsFlags?: RemoteFeatureFlagControllerOptions['metaMetricsFlags']; /** * The current client version for version-based flag filtering. Must be a * valid 3-part SemVer or the controller throws. Defaults to `'0.0.0'`.