diff --git a/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts index 1baaaf72bf..c3ce4e8789 100644 --- a/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts +++ b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts @@ -134,6 +134,23 @@ describe('ClientConfigApiService', () => { }); }); + describe('getClient', () => { + it('returns configured client type', () => { + const clientConfigApiService = new ClientConfigApiService({ + fetch: createMockFetch({}), + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }); + + expect(clientConfigApiService.getClient()).toStrictEqual( + ClientType.Extension, + ); + }); + }); + describe('circuit breaker', () => { it('opens the circuit breaker after consecutive failures', async () => { const mockFetch = createMockFetch({ error: networkError }); diff --git a/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts index 7cd9177e0b..0333a2c7a4 100644 --- a/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts +++ b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts @@ -148,6 +148,14 @@ export class ClientConfigApiService { }; } + /** + * Gets the client type configured for this service. + * @returns The configured ClientType + */ + public getClient(): ClientType { + return this.#client; + } + /** * Flattens an array of feature flag objects into a single feature flags object. * @param responseData - Array of objects containing feature flag key-value pairs 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 84e1e224a4..7ec1cfb76a 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 @@ -14,7 +14,7 @@ import type { RemoteFeatureFlagControllerStateChangeEvent, } from './remote-feature-flag-controller'; import type { FeatureFlags } from './remote-feature-flag-controller-types'; -import * as userSegmentationUtils from './utils/user-segmentation-utils'; +import { ClientType } from './remote-feature-flag-controller-types'; const MOCK_FLAGS: FeatureFlags = { feature1: true, @@ -42,7 +42,6 @@ const MOCK_FLAGS_WITH_THRESHOLD = { }; const MOCK_METRICS_ID = 'f9e8d7c6-b5a4-4210-9876-543210fedcba'; -const MOCK_METRICS_ID_2 = '987fcdeb-51a2-4c4b-9876-543210fedcba'; /** * Creates a controller instance with default parameters for testing @@ -51,6 +50,7 @@ const MOCK_METRICS_ID_2 = '987fcdeb-51a2-4c4b-9876-543210fedcba'; * @param options.state - The initial controller state * @param options.clientConfigApiService - The client config API service instance * @param options.disabled - Whether the controller should start disabled + * @param options.getMetaMetricsId - The function to get the metaMetricsId * @returns A configured RemoteFeatureFlagController instance */ function createController( @@ -59,7 +59,7 @@ function createController( state: Partial; clientConfigApiService: AbstractClientConfigApiService; disabled: boolean; - getMetaMetricsId: Promise; + getMetaMetricsId: () => Promise | string; }> = {}, ) { return new RemoteFeatureFlagController({ @@ -68,7 +68,8 @@ function createController( clientConfigApiService: options.clientConfigApiService ?? buildClientConfigApiService(), disabled: options.disabled, - getMetaMetricsId: options.getMetaMetricsId, + getMetaMetricsId: + options.getMetaMetricsId ?? (() => Promise.resolve(MOCK_METRICS_ID)), }); } @@ -219,6 +220,7 @@ describe('RemoteFeatureFlagController', () => { }); const clientConfigApiService = { + getClient: () => ClientType.Mobile, fetchRemoteFeatureFlags: fetchSpy, } as AbstractClientConfigApiService; @@ -271,7 +273,7 @@ describe('RemoteFeatureFlagController', () => { }); const controller = createController({ clientConfigApiService, - getMetaMetricsId: Promise.resolve(MOCK_METRICS_ID), + getMetaMetricsId: () => Promise.resolve(MOCK_METRICS_ID), }); await controller.updateRemoteFeatureFlags(); @@ -289,7 +291,7 @@ describe('RemoteFeatureFlagController', () => { }); const controller = createController({ clientConfigApiService, - getMetaMetricsId: Promise.resolve(MOCK_METRICS_ID), + getMetaMetricsId: () => Promise.resolve(MOCK_METRICS_ID), }); await controller.updateRemoteFeatureFlags(); @@ -298,23 +300,22 @@ describe('RemoteFeatureFlagController', () => { expect(nonThresholdFlags).toStrictEqual(MOCK_FLAGS); }); - it('uses a fallback metaMetricsId if none is provided', async () => { - jest - .spyOn(userSegmentationUtils, 'generateFallbackMetaMetricsId') - .mockReturnValue(MOCK_METRICS_ID_2); + it('handles synchronous metaMetricsId', async () => { const clientConfigApiService = buildClientConfigApiService({ remoteFeatureFlags: MOCK_FLAGS_WITH_THRESHOLD, }); const controller = createController({ clientConfigApiService, + getMetaMetricsId: () => MOCK_METRICS_ID, }); + await controller.updateRemoteFeatureFlags(); expect( controller.state.remoteFeatureFlags.testFlagForThreshold, ).toStrictEqual({ - name: 'groupA', - value: 'valueA', + name: 'groupC', + value: 'valueC', }); }); }); @@ -399,18 +400,22 @@ function getControllerMessenger( * @param options.remoteFeatureFlags - Optional feature flags data to return * @param options.cacheTimestamp - Optional timestamp to use for the cache * @param options.error - Optional error to simulate API failure + * @param options.client - The client type to return from getClient * @returns A mock client config API service */ function buildClientConfigApiService({ remoteFeatureFlags, cacheTimestamp, error, + client, }: { remoteFeatureFlags?: FeatureFlags; cacheTimestamp?: number; error?: Error; + client?: ClientType; } = {}): AbstractClientConfigApiService { return { + getClient: jest.fn().mockReturnValue(client ?? ClientType.Mobile), fetchRemoteFeatureFlags: jest.fn().mockImplementation(() => { if (error) { return Promise.reject(error); 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 81f78f5afa..54076f0fb4 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,7 +14,6 @@ import type { import { generateDeterministicRandomNumber, isFeatureFlagWithScopeValue, - generateFallbackMetaMetricsId, } from './utils/user-segmentation-utils'; // === GENERAL === @@ -103,7 +102,7 @@ export class RemoteFeatureFlagController extends BaseController< #inProgressFlagUpdate?: Promise; - #getMetaMetricsId?: Promise; + #getMetaMetricsId: () => string | Promise; /** * Constructs a new RemoteFeatureFlagController instance. @@ -127,7 +126,7 @@ export class RemoteFeatureFlagController extends BaseController< messenger: RemoteFeatureFlagControllerMessenger; state?: Partial; clientConfigApiService: AbstractClientConfigApiService; - getMetaMetricsId?: Promise; + getMetaMetricsId: () => string | Promise; fetchInterval?: number; disabled?: boolean; }) { @@ -209,9 +208,17 @@ export class RemoteFeatureFlagController extends BaseController< remoteFeatureFlags: FeatureFlags, ): Promise { const processedRemoteFeatureFlags: FeatureFlags = {}; + const metaMetricsIdResult = this.#getMetaMetricsId(); const metaMetricsId = - (await this.#getMetaMetricsId) || generateFallbackMetaMetricsId(); - const thresholdValue = generateDeterministicRandomNumber(metaMetricsId); + metaMetricsIdResult instanceof Promise + ? await metaMetricsIdResult + : metaMetricsIdResult; + + const clientType = this.#clientConfigApiService.getClient(); + const thresholdValue = generateDeterministicRandomNumber( + clientType, + metaMetricsId, + ); for (const [ 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 be5443cbd6..205581f666 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,17 +1,20 @@ -import { validate as uuidValidate, version as uuidVersion } from 'uuid'; +import { v4 as uuidv4 } from 'uuid'; +import { ClientType } from '../remote-feature-flag-controller-types'; import { generateDeterministicRandomNumber, isFeatureFlagWithScopeValue, - generateFallbackMetaMetricsId, } from './user-segmentation-utils'; -const MOCK_METRICS_IDS = [ - '123e4567-e89b-4456-a456-426614174000', - '987fcdeb-51a2-4c4b-9876-543210fedcba', - 'a1b2c3d4-e5f6-4890-abcd-ef1234567890', - 'f9e8d7c6-b5a4-4210-9876-543210fedcba', -]; +const MOCK_METRICS_IDS = { + MOBILE_VALID: '123e4567-e89b-4456-a456-426614174000', + EXTENSION_VALID: + '0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420', + MOBILE_MIN: '00000000-0000-0000-0000-000000000000', + MOBILE_MAX: 'ffffffff-ffff-ffff-ffff-ffffffffffff', + EXTENSION_MIN: `0x${'0'.repeat(64)}`, + EXTENSION_MAX: `0x${'f'.repeat(64)}`, +}; const MOCK_FEATURE_FLAGS = { VALID: { @@ -31,26 +34,94 @@ const MOCK_FEATURE_FLAGS = { describe('user-segmentation-utils', () => { describe('generateDeterministicRandomNumber', () => { - it('generates consistent numbers for the same input', () => { - const result1 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]); - const result2 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]); + describe('Mobile client (uuidv4)', () => { + it('generates consistent results for same uuidv4', () => { + const result1 = generateDeterministicRandomNumber( + ClientType.Mobile, + MOCK_METRICS_IDS.MOBILE_VALID, + ); + const result2 = generateDeterministicRandomNumber( + ClientType.Mobile, + MOCK_METRICS_IDS.MOBILE_VALID, + ); + expect(result1).toBe(result2); + }); - expect(result1).toBe(result2); - }); + it('handles minimum uuidv4 value', () => { + const result = generateDeterministicRandomNumber( + ClientType.Mobile, + MOCK_METRICS_IDS.MOBILE_MIN, + ); + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(1); + }); - it('generates numbers between 0 and 1', () => { - MOCK_METRICS_IDS.forEach((id) => { - const result = generateDeterministicRandomNumber(id); + it('handles maximum uuidv4 value', () => { + const result = generateDeterministicRandomNumber( + ClientType.Mobile, + MOCK_METRICS_IDS.MOBILE_MAX, + ); expect(result).toBeGreaterThanOrEqual(0); expect(result).toBeLessThanOrEqual(1); }); }); - it('generates different numbers for different inputs', () => { - const result1 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]); - const result2 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[1]); + describe('Extension client (hex string)', () => { + it('generates consistent results for same hex', () => { + const result1 = generateDeterministicRandomNumber( + ClientType.Extension, + MOCK_METRICS_IDS.EXTENSION_VALID, + ); + const result2 = generateDeterministicRandomNumber( + ClientType.Extension, + MOCK_METRICS_IDS.EXTENSION_VALID, + ); + expect(result1).toBe(result2); + }); - expect(result1).not.toBe(result2); + it('handles minimum hex value', () => { + const result = generateDeterministicRandomNumber( + ClientType.Extension, + MOCK_METRICS_IDS.EXTENSION_MIN, + ); + expect(result).toBe(0); + }); + + it('handles maximum hex value', () => { + const result = generateDeterministicRandomNumber( + ClientType.Extension, + MOCK_METRICS_IDS.EXTENSION_MAX, + ); + expect(result).toBe(1); + }); + }); + + describe('Distribution validation', () => { + it('produces uniform distribution', () => { + const samples = 1000; + const buckets = 10; + const distribution = new Array(buckets).fill(0); + + // Generate samples using valid UUIDs + Array.from({ length: samples }).forEach(() => { + const randomUUID = uuidv4(); + const value = generateDeterministicRandomNumber( + ClientType.Mobile, + randomUUID, + ); + const bucketIndex = Math.floor(value * buckets); + distribution[bucketIndex] += 1; + }); + + // Check distribution + const expectedPerBucket = samples / buckets; + const tolerance = expectedPerBucket * 0.3; // 30% tolerance + + distribution.forEach((count) => { + expect(count).toBeGreaterThanOrEqual(expectedPerBucket - tolerance); + expect(count).toBeLessThanOrEqual(expectedPerBucket + tolerance); + }); + }); }); }); @@ -75,12 +146,4 @@ describe('user-segmentation-utils', () => { ).toBe(false); }); }); - - describe('generateFallbackMetaMetricsId', () => { - it('returns a valid uuidv4', () => { - const result = generateFallbackMetaMetricsId(); - expect(uuidValidate(result)).toBe(true); - expect(uuidVersion(result)).toBe(4); - }); - }); }); 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 811f09e106..fe49fc9844 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,26 +1,38 @@ import type { Json } from '@metamask/utils'; -import { v4 as uuidV4 } from 'uuid'; import type { FeatureFlagScopeValue } from '../remote-feature-flag-controller-types'; +import { ClientType } from '../remote-feature-flag-controller-types'; + +const BITS_PER_CHAR = 5; +const MAX_SAFE_BITS = 30; // Use 30 bits to stay well within 32-bit integer limits /* eslint-disable no-bitwise */ /** * Generates a deterministic random number between 0 and 1 based on a metaMetricsId. - * This is useful for A/B testing and feature flag rollouts where we want - * consistent group assignment for the same user. + * Handles both mobile (uuidv4) and extension (hex) formats. * - * @param metaMetricsId - The unique identifier used to generate the deterministic random number - * @returns A number between 0 and 1 that is deterministic for the given metaMetricsId + * @param client - The client type (Mobile or Extension) + * @param id - The unique identifier (uuidv4 for mobile, hex for extension) + * @returns A number between 0 and 1 */ export function generateDeterministicRandomNumber( - metaMetricsId: string, + client: ClientType, + id: string, ): number { - const hash = [...metaMetricsId].reduce((acc, char) => { - const chr = char.charCodeAt(0); - return ((acc << 5) - acc + chr) | 0; - }, 0); + if (client === ClientType.Mobile) { + const maxValue = (1 << MAX_SAFE_BITS) - 1; + const hash = [...id].reduce((acc, char) => { + const chr = char.charCodeAt(0); + return ((acc << BITS_PER_CHAR) - acc + chr) & maxValue; + }, 0); + return hash / maxValue; + } - return (hash >>> 0) / 0xffffffff; + // Extension case + const cleanHex = id.startsWith('0x') ? id.slice(2) : id; + const value = BigInt(`0x${cleanHex}`); + const maxValue = BigInt(`0x${'f'.repeat(cleanHex.length)}`); + return Number(value) / Number(maxValue); } /** @@ -39,11 +51,3 @@ export const isFeatureFlagWithScopeValue = ( 'scope' in featureFlag ); }; - -/** - * Generates UUIDv4 as a fallback metaMetricsId - * @returns A UUIDv4 string - */ -export function generateFallbackMetaMetricsId(): string { - return uuidV4(); -}