diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 0b7be74d54..bc46f5c893 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add support for `metaMetricsId` scoped feature flag entries to target a specific user with an override value ([#8910](https://github.com/MetaMask/core/pull/8910)) + ### Changed - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) diff --git a/packages/remote-feature-flag-controller/src/index.ts b/packages/remote-feature-flag-controller/src/index.ts index 666660e482..caec69d4d4 100644 --- a/packages/remote-feature-flag-controller/src/index.ts +++ b/packages/remote-feature-flag-controller/src/index.ts @@ -20,8 +20,17 @@ export { ClientType, DistributionType, EnvironmentType, + FeatureFlagScopeType, } from './remote-feature-flag-controller-types'; -export type { FeatureFlags } from './remote-feature-flag-controller-types'; +export type { + FeatureFlagMetaMetricsIdScope, + FeatureFlagMetaMetricsIdScopeValue, + FeatureFlags, + FeatureFlagScope, + FeatureFlagScopeValue, + FeatureFlagThresholdScope, + FeatureFlagThresholdScopeValue, +} from './remote-feature-flag-controller-types'; export { ClientConfigApiService } from './client-config-api-service/client-config-api-service'; export { generateDeterministicRandomNumber } from './utils/user-segmentation-utils'; 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 6bf8e5ea2f..43b6e22d6c 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 @@ -34,17 +34,39 @@ export type FeatureFlags = { [key: string]: Json; }; -export type FeatureFlagScope = { - type: string; +export enum FeatureFlagScopeType { + Threshold = 'threshold', + MetaMetricsId = 'metaMetricsId', +} + +export type FeatureFlagThresholdScope = { + type: FeatureFlagScopeType.Threshold; value: number; }; +export type FeatureFlagMetaMetricsIdScope = { + type: FeatureFlagScopeType.MetaMetricsId; + value: string; +}; + +export type FeatureFlagScope = + | FeatureFlagThresholdScope + | FeatureFlagMetaMetricsIdScope; + export type FeatureFlagScopeValue = { name: string; scope: FeatureFlagScope; value: Json; }; +export type FeatureFlagThresholdScopeValue = FeatureFlagScopeValue & { + scope: FeatureFlagThresholdScope; +}; + +export type FeatureFlagMetaMetricsIdScopeValue = FeatureFlagScopeValue & { + scope: FeatureFlagMetaMetricsIdScope; +}; + export type ApiDataResponse = FeatureFlags[]; export type ServiceResponse = { 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 142995b345..dc10c30627 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 @@ -625,6 +625,116 @@ describe('RemoteFeatureFlagController', () => { }); }); + describe('metaMetricsId override feature flags', () => { + it('uses the value from an exact metaMetricsId override match', async () => { + const mockFlags: FeatureFlags = { + testFlag: [ + { + name: 'specificUser', + scope: { type: 'metaMetricsId', value: MOCK_METRICS_ID }, + value: { enabled: true, variant: 'override' }, + }, + { + name: 'default', + scope: { type: 'threshold', value: 1.0 }, + value: { enabled: false, variant: 'default' }, + }, + ], + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + getMetaMetricsId: () => MOCK_METRICS_ID, + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect(controller.state.remoteFeatureFlags.testFlag).toStrictEqual({ + name: 'specificUser', + value: { enabled: true, variant: 'override' }, + }); + expect( + controller.state.thresholdCache?.[`${MOCK_METRICS_ID}:testFlag`], + ).toBeUndefined(); + }); + + it('falls back to threshold selection when metaMetricsId override does not match', async () => { + const mockFlags: FeatureFlags = { + testFlag: [ + { + name: 'specificUser', + scope: { + type: 'metaMetricsId', + value: '00000000-0000-4000-8000-000000000000', + }, + value: { enabled: true, variant: 'override' }, + }, + { + name: 'default', + scope: { type: 'threshold', value: 1.0 }, + value: { enabled: false, variant: 'default' }, + }, + ], + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + getMetaMetricsId: () => MOCK_METRICS_ID, + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect(controller.state.remoteFeatureFlags.testFlag).toStrictEqual({ + name: 'default', + value: { enabled: false, variant: 'default' }, + }); + expect( + controller.state.thresholdCache?.[`${MOCK_METRICS_ID}:testFlag`], + ).toBeDefined(); + }); + + it('uses metaMetricsId override before threshold selection', async () => { + const mockFlags: FeatureFlags = { + testFlag: [ + { + name: 'specificUser', + scope: { type: 'metaMetricsId', value: MOCK_METRICS_ID }, + value: 'overrideValue', + }, + { + name: 'control', + scope: { type: 'threshold', value: 1.0 }, + value: 'thresholdValue', + }, + ], + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + getMetaMetricsId: () => MOCK_METRICS_ID, + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect(controller.state.remoteFeatureFlags.testFlag).toStrictEqual({ + name: 'specificUser', + value: 'overrideValue', + }); + }); + }); + describe('enable and disable', () => { it('enables the controller and makes a network request to fetch', async () => { const clientConfigApiService = buildClientConfigApiService(); @@ -938,6 +1048,57 @@ describe('RemoteFeatureFlagController', () => { }); expect(regularFlag).toBe(true); }); + + it('combines multi-version flags with metaMetricsId overrides', async () => { + const mockApiService = buildClientConfigApiService(); + const mockFlags = { + multiVersionOverrideFlag: { + versions: { + '13.1.0': [ + { + name: 'specificUser', + scope: { type: 'metaMetricsId', value: MOCK_METRICS_ID }, + value: { enabled: true, variant: 'override' }, + }, + { + name: 'default', + scope: { type: 'threshold', value: 1.0 }, + value: { enabled: false, variant: 'default' }, + }, + ], + '13.2.0': [ + { + name: 'newDefault', + scope: { type: 'threshold', value: 1.0 }, + value: { enabled: true, variant: 'new-default' }, + }, + ], + }, + }, + }; + + jest.spyOn(mockApiService, 'fetchRemoteFeatureFlags').mockResolvedValue({ + remoteFeatureFlags: mockFlags, + cacheTimestamp: Date.now(), + }); + + const { controller, messenger } = createController({ + clientConfigApiService: mockApiService, + clientVersion: '13.1.5', + getMetaMetricsId: () => MOCK_METRICS_ID, + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect( + controller.state.remoteFeatureFlags.multiVersionOverrideFlag, + ).toStrictEqual({ + name: 'specificUser', + value: { enabled: true, variant: 'override' }, + }); + }); }); describe('getDefaultRemoteFeatureFlagControllerState', () => { 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 8f8fbbb4db..e271dce0a8 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 @@ -10,13 +10,15 @@ import type { Json, SemVerVersion } from '@metamask/utils'; import type { AbstractClientConfigApiService } from './client-config-api-service/abstract-client-config-api-service'; import type { RemoteFeatureFlagControllerMethodActions } from './remote-feature-flag-controller-method-action-types'; import type { + FeatureFlagMetaMetricsIdScopeValue, FeatureFlags, ServiceResponse, - FeatureFlagScopeValue, + FeatureFlagThresholdScopeValue, } from './remote-feature-flag-controller-types'; import { calculateThresholdForFlag, - isFeatureFlagWithScopeValue, + isFeatureFlagWithMetaMetricsIdScopeValue, + isFeatureFlagWithThresholdScopeValue, } from './utils/user-segmentation-utils'; import { isVersionFeatureFlag, getVersionData } from './utils/version'; @@ -329,53 +331,73 @@ export class RemoteFeatureFlagController extends BaseController< } if (Array.isArray(processedValue)) { - // Validate array has valid threshold items before doing expensive crypto operation - const hasValidThresholds = processedValue.some( - isFeatureFlagWithScopeValue, - ); - - if (!hasValidThresholds) { - // Not a threshold array - preserve as-is - processedFlags[remoteFeatureFlagName] = processedValue; - 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; - } - - // Check cache first, calculate only if needed - const cacheKey = `${metaMetricsId}:${remoteFeatureFlagName}` as const; - let thresholdValue = this.state.thresholdCache?.[cacheKey]; - - if (thresholdValue === undefined) { - thresholdValue = await calculateThresholdForFlag( - metaMetricsId, - remoteFeatureFlagName, - ); - - // Collect new threshold for batched state update - thresholdCacheUpdates[cacheKey] = thresholdValue; - } - - const threshold = thresholdValue; - const selectedGroup = processedValue.find( - (featureFlag): featureFlag is FeatureFlagScopeValue => { - if (!isFeatureFlagWithScopeValue(featureFlag)) { - return false; - } - - return threshold <= featureFlag.scope.value; - }, - ); - if (selectedGroup) { + const selectedMetaMetricsIdOverride = metaMetricsId + ? processedValue.find( + ( + featureFlag, + ): featureFlag is FeatureFlagMetaMetricsIdScopeValue => { + return ( + isFeatureFlagWithMetaMetricsIdScopeValue(featureFlag) && + featureFlag.scope.value === metaMetricsId + ); + }, + ) + : undefined; + + if (selectedMetaMetricsIdOverride) { processedValue = { - name: selectedGroup.name, - value: selectedGroup.value, + name: selectedMetaMetricsIdOverride.name, + value: selectedMetaMetricsIdOverride.value, }; + } else { + // Validate array has valid threshold items before doing expensive crypto operation + const hasValidThresholds = processedValue.some( + isFeatureFlagWithThresholdScopeValue, + ); + + if (!hasValidThresholds) { + // Not a supported scoped array - preserve as-is + processedFlags[remoteFeatureFlagName] = processedValue; + 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; + } + + // Check cache first, calculate only if needed + const cacheKey = `${metaMetricsId}:${remoteFeatureFlagName}` as const; + let thresholdValue = this.state.thresholdCache?.[cacheKey]; + + if (thresholdValue === undefined) { + thresholdValue = await calculateThresholdForFlag( + metaMetricsId, + remoteFeatureFlagName, + ); + + // Collect new threshold for batched state update + thresholdCacheUpdates[cacheKey] = thresholdValue; + } + + const threshold = thresholdValue; + const selectedGroup = processedValue.find( + (featureFlag): featureFlag is FeatureFlagThresholdScopeValue => { + if (!isFeatureFlagWithThresholdScopeValue(featureFlag)) { + return false; + } + + return threshold <= featureFlag.scope.value; + }, + ); + if (selectedGroup) { + processedValue = { + name: selectedGroup.name, + value: selectedGroup.value, + }; + } } } 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..444b04c521 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 @@ -3,7 +3,9 @@ import { v4 as uuidV4 } from 'uuid'; import { calculateThresholdForFlag, generateDeterministicRandomNumber, + isFeatureFlagWithMetaMetricsIdScopeValue, isFeatureFlagWithScopeValue, + isFeatureFlagWithThresholdScopeValue, } from './user-segmentation-utils'; const MOCK_METRICS_IDS = { @@ -34,6 +36,22 @@ const MOCK_FEATURE_FLAGS = { value: 0.5, }, }, + VALID_METAMETRICS_ID_OVERRIDE: { + name: 'test-flag', + value: true, + scope: { + type: 'metaMetricsId', + value: 'f9e8d7c6-b5a4-4210-9876-543210fedcba', + }, + }, + INVALID_METAMETRICS_ID_OVERRIDE_NUMBER_VALUE: { + name: 'test-flag', + value: true, + scope: { + type: 'metaMetricsId', + value: 0.5, + }, + }, INVALID_NO_SCOPE: { name: 'test-flag', value: true, @@ -318,4 +336,44 @@ describe('user-segmentation-utils', () => { ).toBe(false); }); }); + + describe('isFeatureFlagWithThresholdScopeValue', () => { + it('returns true for valid threshold feature flag scope', () => { + expect( + isFeatureFlagWithThresholdScopeValue(MOCK_FEATURE_FLAGS.VALID), + ).toBe(true); + }); + + it('returns false for metaMetricsId override scope', () => { + expect( + isFeatureFlagWithThresholdScopeValue( + MOCK_FEATURE_FLAGS.VALID_METAMETRICS_ID_OVERRIDE, + ), + ).toBe(false); + }); + }); + + describe('isFeatureFlagWithMetaMetricsIdScopeValue', () => { + it('returns true for valid metaMetricsId override scope', () => { + expect( + isFeatureFlagWithMetaMetricsIdScopeValue( + MOCK_FEATURE_FLAGS.VALID_METAMETRICS_ID_OVERRIDE, + ), + ).toBe(true); + }); + + it('returns false for threshold scope', () => { + expect( + isFeatureFlagWithMetaMetricsIdScopeValue(MOCK_FEATURE_FLAGS.VALID), + ).toBe(false); + }); + + it('returns false when metaMetricsId override value is not a string', () => { + expect( + isFeatureFlagWithMetaMetricsIdScopeValue( + MOCK_FEATURE_FLAGS.INVALID_METAMETRICS_ID_OVERRIDE_NUMBER_VALUE, + ), + ).toBe(false); + }); + }); }); 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..5eb85b0bfb 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 @@ -1,8 +1,13 @@ import type { Json } from '@metamask/utils'; -import { sha256, bytesToHex } from '@metamask/utils'; +import { bytesToHex, hasProperty, sha256 } from '@metamask/utils'; import { validate as uuidValidate, version as uuidVersion } from 'uuid'; -import type { FeatureFlagScopeValue } from '../remote-feature-flag-controller-types'; +import type { + FeatureFlagMetaMetricsIdScopeValue, + FeatureFlagScopeValue, + FeatureFlagThresholdScopeValue, +} from '../remote-feature-flag-controller-types'; +import { FeatureFlagScopeType } from '../remote-feature-flag-controller-types'; /** * Converts a UUID string to a BigInt by removing dashes and converting to hexadecimal. @@ -124,9 +129,58 @@ export function generateDeterministicRandomNumber( export const isFeatureFlagWithScopeValue = ( featureFlag: Json, ): featureFlag is FeatureFlagScopeValue => { + if ( + typeof featureFlag !== 'object' || + featureFlag === null || + Array.isArray(featureFlag) || + !hasProperty(featureFlag, 'scope') + ) { + return false; + } + + const { scope } = featureFlag; + + return ( + hasProperty(featureFlag, 'name') && + typeof featureFlag.name === 'string' && + hasProperty(featureFlag, 'value') && + typeof scope === 'object' && + scope !== null && + !Array.isArray(scope) && + hasProperty(scope, 'type') && + typeof scope.type === 'string' && + hasProperty(scope, 'value') + ); +}; + +/** + * Type guard to check if a feature flag entry uses threshold-based scoping. + * + * @param featureFlag - The value to check. + * @returns True if the value is a threshold-scoped feature flag entry. + */ +export const isFeatureFlagWithThresholdScopeValue = ( + featureFlag: Json, +): featureFlag is FeatureFlagThresholdScopeValue => { + return ( + isFeatureFlagWithScopeValue(featureFlag) && + featureFlag.scope.type === FeatureFlagScopeType.Threshold && + typeof featureFlag.scope.value === 'number' + ); +}; + +/** + * Type guard to check if a feature flag entry targets a specific MetaMetrics ID. + * + * @param featureFlag - The value to check. + * @returns True if the value is a MetaMetrics ID-scoped feature flag entry. + */ +export const isFeatureFlagWithMetaMetricsIdScopeValue = ( + featureFlag: Json, +): featureFlag is FeatureFlagMetaMetricsIdScopeValue => { return ( - typeof featureFlag === 'object' && - featureFlag !== null && - 'scope' in featureFlag + isFeatureFlagWithScopeValue(featureFlag) && + featureFlag.scope.type === FeatureFlagScopeType.MetaMetricsId && + typeof featureFlag.scope.value === 'string' ); };