diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 74eacc03c9..ad872ec267 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 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 `featureFlagThresholdGroups` field to `RemoteFeatureFlagControllerState` to map feature flag names to their selected threshold group names ([#9325](https://github.com/MetaMask/core/pull/9325)) +- Add optional `getCanonicalId` constructor callback and `FeatureFlagIdType` enum so threshold flags segment users by canonical ID by default, or by MetaMetrics ID when configured with `idType: "metametrics"` ([#9325](https://github.com/MetaMask/core/pull/9325)) ### Changed -- **BREAKING:** Threshold feature flags now return the selected `value` directly instead of a `{ name, value }` wrapper. The selected threshold group name is stored separately in `featureFlagThresholdGroups` on controller state when the selected threshold entry includes `name` ([#9289](https://github.com/MetaMask/core/pull/9289)) +- **BREAKING:** Threshold feature flags now return the selected `value` directly instead of a `{ name, value }` wrapper. The selected threshold group name is stored separately in `featureFlagThresholdGroups` on controller state when the selected threshold entry includes `name` ([#9325](https://github.com/MetaMask/core/pull/9325)) - Merge `localOverrides` into `remoteFeatureFlags` at the controller level so consumers receive effective flag values directly ([#9259](https://github.com/MetaMask/core/pull/9259)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) diff --git a/packages/remote-feature-flag-controller/src/index.ts b/packages/remote-feature-flag-controller/src/index.ts index 00c1c1c2d1..3bafeccab2 100644 --- a/packages/remote-feature-flag-controller/src/index.ts +++ b/packages/remote-feature-flag-controller/src/index.ts @@ -20,6 +20,7 @@ export { ClientType, DistributionType, EnvironmentType, + FeatureFlagIdType, ThresholdVersion, } from './remote-feature-flag-controller-types'; diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts index 0e6ab906ca..e89295e006 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts @@ -43,6 +43,11 @@ export enum ThresholdVersion { DirectValue = 2, } +export enum FeatureFlagIdType { + MetaMetrics = 'metametrics', + Canonical = 'canonical', +} + export type FeatureFlagScopeValue = { name: string; /** @@ -50,6 +55,11 @@ export type FeatureFlagScopeValue = { * v2 configurations and is not emitted in processed controller state. */ thresholdName?: string; + /** + * Selects which client identifier is used for deterministic threshold + * assignment. Defaults to `canonical` when omitted. + */ + idType?: FeatureFlagIdType; /** * Selects the threshold entry output shape. Unrecognized versions fall back * to the legacy `{ name, value }` wrapper for backwards compatibility. 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 478a0ffa67..454bdf3407 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 @@ -18,7 +18,10 @@ import type { RemoteFeatureFlagControllerMessenger, RemoteFeatureFlagControllerState, } from './remote-feature-flag-controller'; -import { ThresholdVersion } from './remote-feature-flag-controller-types'; +import { + ThresholdVersion, + FeatureFlagIdType, +} from './remote-feature-flag-controller-types'; import type { FeatureFlags } from './remote-feature-flag-controller-types'; const MOCK_FLAGS: FeatureFlags = { @@ -33,20 +36,29 @@ const MOCK_FLAGS_WITH_THRESHOLD = { ...MOCK_FLAGS, testFlagForThreshold: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 0.3 }, value: 'valueA', }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 0.5 }, value: 'valueB', }, - { name: 'groupC', scope: { type: 'threshold', value: 1 }, value: 'valueC' }, + { + idType: FeatureFlagIdType.MetaMetrics, + 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 +69,7 @@ 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.getCanonicalId - Returns canonicalId * @param options.clientVersion - The client version string * @param options.prevClientVersion - The previous client version string * @returns The controller and the root messenger @@ -67,6 +80,7 @@ function createController( clientConfigApiService: AbstractClientConfigApiService; disabled: boolean; getMetaMetricsId: () => string; + getCanonicalId: () => string; clientVersion: string; prevClientVersion: string; }> = {}, @@ -81,6 +95,9 @@ function createController( getMetaMetricsId: options.getMetaMetricsId ?? ((): typeof MOCK_METRICS_ID => MOCK_METRICS_ID), + getCanonicalId: + options.getCanonicalId ?? + ((): typeof MOCK_CANONICAL_ID => MOCK_CANONICAL_ID), clientVersion: options.clientVersion ?? MOCK_BASE_VERSION, prevClientVersion: options.prevClientVersion, }); @@ -100,6 +117,36 @@ describe('RemoteFeatureFlagController', () => { }); }); + it('defaults getCanonicalId to an empty string when omitted', async () => { + const mockFlags = { + canonicalThresholdFlag: [ + { + idType: FeatureFlagIdType.Canonical, + 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 }); @@ -542,11 +589,13 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { featureA: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA1', scope: { type: 'threshold', value: 0.5 }, value: 'A1', }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA2', scope: { type: 'threshold', value: 1.0 }, value: 'A2', @@ -554,11 +603,13 @@ describe('RemoteFeatureFlagController', () => { ], featureB: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB1', scope: { type: 'threshold', value: 0.5 }, value: 'B1', }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB2', scope: { type: 'threshold', value: 1.0 }, value: 'B2', @@ -627,6 +678,7 @@ describe('RemoteFeatureFlagController', () => { mixedArray: [ { name: 'invalid', value: 'no scope' }, // Invalid - missing scope property { + idType: FeatureFlagIdType.MetaMetrics, name: 'validGroup', scope: { type: 'threshold', value: 1.0 }, value: 'selectedValue', @@ -660,11 +712,13 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { testFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'control', scope: { type: 'threshold', value: 0.5 }, value: false, }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'treatment', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -704,6 +758,73 @@ describe('RemoteFeatureFlagController', () => { testFlag: 'control', }); }); + + it('uses getCanonicalId for threshold flags with canonical idType', async () => { + const mockFlags = { + canonicalThresholdFlag: [ + { + idType: FeatureFlagIdType.Canonical, + name: 'groupA', + scope: { type: 'threshold', value: 0.5 }, + value: 'canonicalA', + }, + { + idType: FeatureFlagIdType.Canonical, + name: 'groupB', + scope: { type: 'threshold', value: 1.0 }, + value: 'canonicalB', + }, + ], + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + getMetaMetricsId: () => '', + getCanonicalId: () => 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 id is empty for canonical idType flags', async () => { + const mockFlags = { + canonicalThresholdFlag: [ + { + idType: FeatureFlagIdType.Canonical, + name: 'groupA', + scope: { type: 'threshold', value: 1.0 }, + value: 'canonicalA', + }, + ], + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + getMetaMetricsId: () => MOCK_METRICS_ID, + getCanonicalId: () => '', + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect( + controller.state.remoteFeatureFlags.canonicalThresholdFlag, + ).toStrictEqual(mockFlags.canonicalThresholdFlag); + }); }); describe('enable and disable', () => { @@ -961,16 +1082,19 @@ describe('RemoteFeatureFlagController', () => { versions: { '13.1.0': [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 0.3 }, value: { feature: 'A', enabled: true }, }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 0.7 }, value: { feature: 'B', enabled: false }, }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupC', scope: { type: 'threshold', value: 1.0 }, value: { feature: 'C', enabled: true }, @@ -978,11 +1102,13 @@ describe('RemoteFeatureFlagController', () => { ], '13.2.0': [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'newGroupA', scope: { type: 'threshold', value: 0.5 }, value: { feature: 'NewA', enabled: false }, }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'newGroupB', scope: { type: 'threshold', value: 1.0 }, value: { feature: 'NewB', enabled: true }, @@ -1289,6 +1415,7 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { flagA: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1296,6 +1423,7 @@ describe('RemoteFeatureFlagController', () => { ], flagB: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 1.0 }, value: false, @@ -1322,6 +1450,7 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { flagB: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 1.0 }, value: false, @@ -1424,6 +1553,7 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { persistentFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1467,6 +1597,7 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { testFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1506,6 +1637,7 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { newFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1537,6 +1669,7 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { oldFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1563,6 +1696,7 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { newFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: false, @@ -1592,11 +1726,13 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { thresholdFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 0.5 }, value: 'A', }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 1.0 }, value: 'B', @@ -1627,6 +1763,7 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { 'feature:v2': [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1670,6 +1807,7 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { flagA: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1677,6 +1815,7 @@ describe('RemoteFeatureFlagController', () => { ], flagB: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 1.0 }, value: false, 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 fe6348357f..29fdb9e391 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 @@ -14,8 +14,10 @@ import type { ServiceResponse, FeatureFlagScopeValue, } from './remote-feature-flag-controller-types'; +import { FeatureFlagIdType } from './remote-feature-flag-controller-types'; import { calculateThresholdForFlag, + getThresholdIdType, isFeatureFlagWithScopeValue, } from './utils/user-segmentation-utils'; import { isVersionFeatureFlag, getVersionData } from './utils/version'; @@ -146,6 +148,8 @@ export class RemoteFeatureFlagController extends BaseController< readonly #getMetaMetricsId: () => string; + readonly #getCanonicalId: () => string; + readonly #clientVersion: SemVerVersion; #processedRemoteFeatureFlags: FeatureFlags = {}; @@ -160,6 +164,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.getCanonicalId - Returns the canonical client identifier used + * for threshold flags configured with a non-metametrics idType. * @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. */ @@ -170,6 +176,7 @@ export class RemoteFeatureFlagController extends BaseController< fetchInterval = DEFAULT_CACHE_DURATION, disabled = false, getMetaMetricsId, + getCanonicalId = (): string => '', clientVersion, prevClientVersion, }: { @@ -177,6 +184,7 @@ export class RemoteFeatureFlagController extends BaseController< state?: Partial; clientConfigApiService: AbstractClientConfigApiService; getMetaMetricsId: () => string; + getCanonicalId?: () => string; fetchInterval?: number; disabled?: boolean; clientVersion: string; @@ -228,6 +236,7 @@ export class RemoteFeatureFlagController extends BaseController< this.#disabled = disabled; this.#clientConfigApiService = clientConfigApiService; this.#getMetaMetricsId = getMetaMetricsId; + this.#getCanonicalId = getCanonicalId; this.#clientVersion = clientVersion; this.messenger.registerMethodActionHandlers( @@ -288,6 +297,7 @@ export class RemoteFeatureFlagController extends BaseController< } = await this.#processRemoteFeatureFlags(remoteFeatureFlags); const metaMetricsId = this.#getMetaMetricsId(); + const canonicalId = this.#getCanonicalId(); const currentFlagNames = Object.keys(remoteFeatureFlags); // Build updated threshold cache @@ -300,16 +310,36 @@ 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 === canonicalId) && !currentFlagNames.includes(cachedFlagName) ) { delete updatedThresholdCache[cacheKey]; } } + const updatedFeatureFlagThresholdGroups = { + ...(this.state.featureFlagThresholdGroups ?? {}), + }; + + for (const [flagName, thresholdGroup] of Object.entries( + featureFlagThresholdGroupUpdates, + )) { + if (currentFlagNames.includes(flagName)) { + updatedFeatureFlagThresholdGroups[flagName] = thresholdGroup; + } + } + + for (const flagName of Object.keys(updatedFeatureFlagThresholdGroups)) { + if (!currentFlagNames.includes(flagName)) { + delete updatedFeatureFlagThresholdGroups[flagName]; + } + } + // Single state update with all changes batched together this.#processedRemoteFeatureFlags = processedFlags; @@ -342,13 +372,19 @@ export class RemoteFeatureFlagController extends BaseController< return getVersionData(flagValue, this.#clientVersion); } + #getSegmentationId(idType: FeatureFlagIdType): string { + if (idType === FeatureFlagIdType.MetaMetrics) { + return this.#getMetaMetricsId(); + } + return this.#getCanonicalId(); + } + async #processRemoteFeatureFlags(remoteFeatureFlags: FeatureFlags): Promise<{ processedFlags: FeatureFlags; thresholdCacheUpdates: Record; featureFlagThresholdGroupUpdates: Record; }> { const processedFlags: FeatureFlags = {}; - const metaMetricsId = this.#getMetaMetricsId(); const thresholdCacheUpdates: Record = {}; const featureFlagThresholdGroupUpdates: Record = {}; @@ -375,20 +411,22 @@ 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 + // Skip threshold processing if the configured identifier is not available + const idType = getThresholdIdType(processedValue); + const segmentationId = this.#getSegmentationId(idType); + + if (!segmentationId) { processedFlags[remoteFeatureFlagName] = processedValue; continue; } // Check cache first, calculate only if needed - 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..85412a09ea 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 @@ -1,8 +1,10 @@ import { v4 as uuidV4 } from 'uuid'; +import { FeatureFlagIdType } from '../remote-feature-flag-controller-types'; import { calculateThresholdForFlag, generateDeterministicRandomNumber, + getThresholdIdType, isFeatureFlagWithScopeValue, } from './user-segmentation-utils'; @@ -318,4 +320,35 @@ describe('user-segmentation-utils', () => { ).toBe(false); }); }); + + describe('getThresholdIdType', () => { + it('defaults to canonical when idType is absent', () => { + expect( + getThresholdIdType([ + { + name: 'groupA', + scope: { type: 'threshold', value: 1.0 }, + value: true, + }, + ]), + ).toBe(FeatureFlagIdType.Canonical); + }); + + it('returns metametrics when configured on threshold entries', () => { + expect( + getThresholdIdType([ + { + idType: FeatureFlagIdType.MetaMetrics, + name: 'groupA', + scope: { type: 'threshold', value: 1.0 }, + value: true, + }, + ]), + ).toBe(FeatureFlagIdType.MetaMetrics); + }); + + it('defaults to canonical for non-array flag values', () => { + expect(getThresholdIdType(true)).toBe(FeatureFlagIdType.Canonical); + }); + }); }); 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..9197b6f3fe 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 @@ -3,6 +3,7 @@ import { sha256, bytesToHex } from '@metamask/utils'; import { validate as uuidValidate, version as uuidVersion } from 'uuid'; import type { FeatureFlagScopeValue } from '../remote-feature-flag-controller-types'; +import { FeatureFlagIdType } from '../remote-feature-flag-controller-types'; /** * Converts a UUID string to a BigInt by removing dashes and converting to hexadecimal. @@ -130,3 +131,20 @@ export const isFeatureFlagWithScopeValue = ( 'scope' in featureFlag ); }; + +/** + * Returns the identifier type used for deterministic threshold assignment. + * Defaults to Canonical when no threshold entry specifies an idType. + * + * @param flagValue - The feature flag value to inspect. + * @returns The identifier type for threshold processing. + */ +export function getThresholdIdType(flagValue: Json): FeatureFlagIdType { + if (!Array.isArray(flagValue)) { + return FeatureFlagIdType.Canonical; + } + + const firstThresholdEntry = flagValue.find(isFeatureFlagWithScopeValue); + + return firstThresholdEntry?.idType ?? FeatureFlagIdType.Canonical; +} diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index ea64a68972..0138c9872d 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Pass optional `getCanonicalId` through `RemoteFeatureFlagController` wallet initialization for threshold flags that segment by canonical ID ([#9325](https://github.com/MetaMask/core/pull/9325)) - Bump `@metamask/accounts-controller` from `^39.0.3` to `^39.0.4` ([#9349](https://github.com/MetaMask/core/pull/9349)) - Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) 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..058e8bdd63 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,7 @@ export const remoteFeatureFlagController: InitializationConfiguration< messenger, clientConfigApiService: options.clientConfigApiService, getMetaMetricsId: options.getMetaMetricsId ?? ((): string => ''), + getCanonicalId: options.getCanonicalId ?? ((): string => ''), 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..7711d6be98 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,11 @@ export type RemoteFeatureFlagControllerInstanceOptions = { * Defaults to `() => ''`. */ getMetaMetricsId?: RemoteFeatureFlagControllerOptions['getMetaMetricsId']; + /** + * Returns the canonical client identifier for threshold flags configured + * with a non-metametrics idType. Defaults to `() => ''`. + */ + getCanonicalId?: RemoteFeatureFlagControllerOptions['getCanonicalId']; /** * 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'`.