From 08752568b145898c72805b4bdd17b1a186dffff1 Mon Sep 17 00:00:00 2001 From: Danica Shen Date: Fri, 10 Jan 2025 11:45:04 +0000 Subject: [PATCH] fix(3742): improve user segmentation with BigInt-based random generation (#5110) ## Explanation Replace hash-based random number generation with BigInt-based implementation for better distribution and format support. The new implementation properly handles both UUIDv4 and hex-format metaMetricsIds, providing more consistent and reliable user segmentation. - Add support for UUIDv4 format with proper bit normalization - Improve hex format handling using BigInt for precise calculations - Remove char-by-char hashing algorithm to prevent potential collisions ## References Addresses: https://github.com/MetaMask/core/pull/5051#discussion_r1883056128 ## Changelog ### `@metamask/remote-feature-flag-controller` - **CHANGED**: Modify `generateDeterministicRandomNumber` to handle both uuidv4(mobile new) and hex(mobile old and extension) side ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- .../src/utils/user-segmentation-utils.test.ts | 163 ++++++++++++++++-- .../src/utils/user-segmentation-utils.ts | 70 +++++++- 2 files changed, 206 insertions(+), 27 deletions(-) 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 6ec5933e02..6a8b96f3cd 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,14 +1,28 @@ +import { v4 as uuidV4 } from 'uuid'; + import { generateDeterministicRandomNumber, isFeatureFlagWithScopeValue, } 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-4000-8000-000000000000', + MOBILE_MAX: 'ffffffff-ffff-4fff-bfff-ffffffffffff', + EXTENSION_MIN: `0x${'0'.repeat(64) as string}`, + EXTENSION_MAX: `0x${'f'.repeat(64) as string}`, + UUID_V3: '00000000-0000-3000-8000-000000000000', + INVALID_HEX_NO_PREFIX: + '86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420', + INVALID_HEX_SHORT: + '0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d13642', + INVALID_HEX_LONG: + '0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d1364200', + INVALID_HEX_INVALID_CHARS: + '0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d13642g', +}; const MOCK_FEATURE_FLAGS = { VALID: { @@ -28,26 +42,139 @@ 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 new implementation (uuidv4)', () => { + it('generates consistent results for same uuidv4', () => { + const result1 = generateDeterministicRandomNumber( + MOCK_METRICS_IDS.MOBILE_VALID, + ); + const result2 = generateDeterministicRandomNumber( + MOCK_METRICS_IDS.MOBILE_VALID, + ); + expect(result1).toBe(result2); + }); - expect(result1).toBe(result2); - }); + it('handles minimum uuidv4 value', () => { + const result = generateDeterministicRandomNumber( + MOCK_METRICS_IDS.MOBILE_MIN, + ); + expect(result).toBe(0); + }); + + it('handles maximum uuidv4 value', () => { + const result = generateDeterministicRandomNumber( + MOCK_METRICS_IDS.MOBILE_MAX, + ); + // For practical purposes, 0.999999 is functionally equivalent to 1 in this context + // the small deviation from exactly 1.0 is a limitation of floating-point arithmetic, not a bug in the logic. + expect(result).toBeCloseTo(1, 5); + }); - it('generates numbers between 0 and 1', () => { - MOCK_METRICS_IDS.forEach((id) => { - const result = generateDeterministicRandomNumber(id); + it('results a random number between 0 and 1', () => { + const result = generateDeterministicRandomNumber( + MOCK_METRICS_IDS.MOBILE_VALID, + ); 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('Mobile client old implementation and Extension client (hex string)', () => { + it('generates consistent results for same hex', () => { + const result1 = generateDeterministicRandomNumber( + MOCK_METRICS_IDS.EXTENSION_VALID, + ); + const result2 = generateDeterministicRandomNumber( + MOCK_METRICS_IDS.EXTENSION_VALID, + ); + expect(result1).toBe(result2); + }); + + it('handles minimum hex value', () => { + const result = generateDeterministicRandomNumber( + MOCK_METRICS_IDS.EXTENSION_MIN, + ); + expect(result).toBe(0); + }); + + it('handles maximum hex value', () => { + const result = generateDeterministicRandomNumber( + MOCK_METRICS_IDS.EXTENSION_MAX, + ); + expect(result).toBe(1); + }); + }); + + describe('Distribution validation', () => { + it('produces uniform distribution across 1000 samples', () => { + const samples = 1000; + const buckets = 10; + const tolerance = 0.3; + const distribution = new Array(buckets).fill(0); + + // Generate samples using valid UUIDs + Array.from({ length: samples }).forEach(() => { + const uuid = uuidV4(); + const value = generateDeterministicRandomNumber(uuid); + const bucketIndex = Math.floor(value * buckets); + // Handle edge case where value === 1 + distribution[ + bucketIndex === buckets ? buckets - 1 : bucketIndex + ] += 1; + }); + + // Check distribution + const expectedPerBucket = samples / buckets; + const allowedDeviation = expectedPerBucket * tolerance; + + distribution.forEach((count) => { + const minExpected = Math.floor(expectedPerBucket - allowedDeviation); + const maxExpected = Math.ceil(expectedPerBucket + allowedDeviation); + expect(count).toBeGreaterThanOrEqual(minExpected); + expect(count).toBeLessThanOrEqual(maxExpected); + }); + }); + }); + + describe('MetaMetrics ID validation', () => { + it('throws an error if the MetaMetrics ID is empty', () => { + expect(() => generateDeterministicRandomNumber('')).toThrow( + 'MetaMetrics ID cannot be empty', + ); + }); + + it('throws an error if the MetaMetrics ID is not a valid UUIDv4', () => { + expect(() => + generateDeterministicRandomNumber(MOCK_METRICS_IDS.UUID_V3), + ).toThrow('Invalid UUID version. Expected v4, got v3'); + }); - expect(result1).not.toBe(result2); + it('throws an error if the MetaMetrics ID is not a valid hex string', () => { + expect(() => + generateDeterministicRandomNumber( + MOCK_METRICS_IDS.INVALID_HEX_NO_PREFIX, + ), + ).toThrow('Hex ID must start with 0x prefix'); + }); + + it('throws an error if the MetaMetrics ID is a short hex string', () => { + expect(() => + generateDeterministicRandomNumber(MOCK_METRICS_IDS.INVALID_HEX_SHORT), + ).toThrow('Invalid hex ID length. Expected 64 characters, got 63'); + }); + + it('throws an error if the MetaMetrics ID is a long hex string', () => { + expect(() => + generateDeterministicRandomNumber(MOCK_METRICS_IDS.INVALID_HEX_LONG), + ).toThrow('Invalid hex ID length. Expected 64 characters, got 65'); + }); + + it('throws an error if the MetaMetrics ID contains invalid hex characters', () => { + expect(() => + generateDeterministicRandomNumber( + MOCK_METRICS_IDS.INVALID_HEX_INVALID_CHARS, + ), + ).toThrow('Hex ID contains invalid characters'); + }); }); }); 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 481d0d21c0..b14056c453 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,25 +1,77 @@ import type { Json } from '@metamask/utils'; +import { validate as uuidValidate, version as uuidVersion } from 'uuid'; import type { FeatureFlagScopeValue } from '../remote-feature-flag-controller-types'; -/* eslint-disable no-bitwise */ +/** + * Converts a UUID string to a BigInt by removing dashes and converting to hexadecimal. + * @param uuid - The UUID string to convert + * @returns The UUID as a BigInt value + */ +function uuidStringToBigInt(uuid: string): bigint { + return BigInt(`0x${uuid.replace(/-/gu, '')}`); +} + +const MIN_UUID_V4 = '00000000-0000-4000-8000-000000000000'; +const MAX_UUID_V4 = 'ffffffff-ffff-4fff-bfff-ffffffffffff'; +const MIN_UUID_V4_BIGINT = uuidStringToBigInt(MIN_UUID_V4); +const MAX_UUID_V4_BIGINT = uuidStringToBigInt(MAX_UUID_V4); +const UUID_V4_VALUE_RANGE_BIGINT = MAX_UUID_V4_BIGINT - MIN_UUID_V4_BIGINT; + /** * 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. - * - * @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 metaMetricsId - The unique identifier used to generate the deterministic random number. Must be either: + * - A UUIDv4 string (e.g., '123e4567-e89b-12d3-a456-426614174000' + * - A hex string with '0x' prefix (e.g., '0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420') + * @returns A number between 0 and 1, deterministically generated from the input ID. + * The same input will always produce the same output. */ export function generateDeterministicRandomNumber( metaMetricsId: string, ): number { - const hash = [...metaMetricsId].reduce((acc, char) => { - const chr = char.charCodeAt(0); - return ((acc << 5) - acc + chr) | 0; - }, 0); + if (!metaMetricsId) { + throw new Error('MetaMetrics ID cannot be empty'); + } + + let idValue: bigint; + let maxValue: bigint; + + // uuidv4 format + if (uuidValidate(metaMetricsId)) { + if (uuidVersion(metaMetricsId) !== 4) { + throw new Error( + `Invalid UUID version. Expected v4, got v${uuidVersion(metaMetricsId)}`, + ); + } + idValue = uuidStringToBigInt(metaMetricsId) - MIN_UUID_V4_BIGINT; + maxValue = UUID_V4_VALUE_RANGE_BIGINT; + } else { + // hex format with 0x prefix + if (!metaMetricsId.startsWith('0x')) { + throw new Error('Hex ID must start with 0x prefix'); + } + + const cleanId = metaMetricsId.slice(2); + const EXPECTED_HEX_LENGTH = 64; // 32 bytes = 64 hex characters + + if (cleanId.length !== EXPECTED_HEX_LENGTH) { + throw new Error( + `Invalid hex ID length. Expected ${EXPECTED_HEX_LENGTH} characters, got ${cleanId.length}`, + ); + } + + if (!/^[0-9a-f]+$/iu.test(cleanId)) { + throw new Error('Hex ID contains invalid characters'); + } + + idValue = BigInt(`0x${cleanId}`); + maxValue = BigInt(`0x${'f'.repeat(cleanId.length)}`); + } - return (hash >>> 0) / 0xffffffff; + // Use BigInt division first, then convert to number to maintain precision + return Number((idValue * BigInt(1_000_000)) / maxValue) / 1_000_000; } /**