Skip to content

Commit 0875256

Browse files
authored
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 <!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? * If you had to upgrade a dependency, why did you do so? --> ## References Addresses: #5051 (comment) <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Changelog <!-- If you're making any consumer-facing changes, list those changes here as if you were updating a changelog, using the template below as a guide. (CATEGORY is one of BREAKING, ADDED, CHANGED, DEPRECATED, REMOVED, or FIXED. For security-related issues, follow the Security Advisory process.) Please take care to name the exact pieces of the API you've added or changed (e.g. types, interfaces, functions, or methods). If there are any breaking changes, make sure to offer a solution for consumers to follow once they upgrade to the changes. Finally, if you're only making changes to development scripts or tests, you may replace the template below with "None". --> ### `@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
1 parent 3b94a7c commit 0875256

File tree

2 files changed

+206
-27
lines changed

2 files changed

+206
-27
lines changed

packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

+145-18
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
1+
import { v4 as uuidV4 } from 'uuid';
2+
13
import {
24
generateDeterministicRandomNumber,
35
isFeatureFlagWithScopeValue,
46
} from './user-segmentation-utils';
57

6-
const MOCK_METRICS_IDS = [
7-
'123e4567-e89b-4456-a456-426614174000',
8-
'987fcdeb-51a2-4c4b-9876-543210fedcba',
9-
'a1b2c3d4-e5f6-4890-abcd-ef1234567890',
10-
'f9e8d7c6-b5a4-4210-9876-543210fedcba',
11-
];
8+
const MOCK_METRICS_IDS = {
9+
MOBILE_VALID: '123e4567-e89b-4456-a456-426614174000',
10+
EXTENSION_VALID:
11+
'0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420',
12+
MOBILE_MIN: '00000000-0000-4000-8000-000000000000',
13+
MOBILE_MAX: 'ffffffff-ffff-4fff-bfff-ffffffffffff',
14+
EXTENSION_MIN: `0x${'0'.repeat(64) as string}`,
15+
EXTENSION_MAX: `0x${'f'.repeat(64) as string}`,
16+
UUID_V3: '00000000-0000-3000-8000-000000000000',
17+
INVALID_HEX_NO_PREFIX:
18+
'86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420',
19+
INVALID_HEX_SHORT:
20+
'0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d13642',
21+
INVALID_HEX_LONG:
22+
'0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d1364200',
23+
INVALID_HEX_INVALID_CHARS:
24+
'0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d13642g',
25+
};
1226

1327
const MOCK_FEATURE_FLAGS = {
1428
VALID: {
@@ -28,26 +42,139 @@ const MOCK_FEATURE_FLAGS = {
2842

2943
describe('user-segmentation-utils', () => {
3044
describe('generateDeterministicRandomNumber', () => {
31-
it('generates consistent numbers for the same input', () => {
32-
const result1 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]);
33-
const result2 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]);
45+
describe('Mobile client new implementation (uuidv4)', () => {
46+
it('generates consistent results for same uuidv4', () => {
47+
const result1 = generateDeterministicRandomNumber(
48+
MOCK_METRICS_IDS.MOBILE_VALID,
49+
);
50+
const result2 = generateDeterministicRandomNumber(
51+
MOCK_METRICS_IDS.MOBILE_VALID,
52+
);
53+
expect(result1).toBe(result2);
54+
});
3455

35-
expect(result1).toBe(result2);
36-
});
56+
it('handles minimum uuidv4 value', () => {
57+
const result = generateDeterministicRandomNumber(
58+
MOCK_METRICS_IDS.MOBILE_MIN,
59+
);
60+
expect(result).toBe(0);
61+
});
62+
63+
it('handles maximum uuidv4 value', () => {
64+
const result = generateDeterministicRandomNumber(
65+
MOCK_METRICS_IDS.MOBILE_MAX,
66+
);
67+
// For practical purposes, 0.999999 is functionally equivalent to 1 in this context
68+
// the small deviation from exactly 1.0 is a limitation of floating-point arithmetic, not a bug in the logic.
69+
expect(result).toBeCloseTo(1, 5);
70+
});
3771

38-
it('generates numbers between 0 and 1', () => {
39-
MOCK_METRICS_IDS.forEach((id) => {
40-
const result = generateDeterministicRandomNumber(id);
72+
it('results a random number between 0 and 1', () => {
73+
const result = generateDeterministicRandomNumber(
74+
MOCK_METRICS_IDS.MOBILE_VALID,
75+
);
4176
expect(result).toBeGreaterThanOrEqual(0);
4277
expect(result).toBeLessThanOrEqual(1);
4378
});
4479
});
4580

46-
it('generates different numbers for different inputs', () => {
47-
const result1 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]);
48-
const result2 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[1]);
81+
describe('Mobile client old implementation and Extension client (hex string)', () => {
82+
it('generates consistent results for same hex', () => {
83+
const result1 = generateDeterministicRandomNumber(
84+
MOCK_METRICS_IDS.EXTENSION_VALID,
85+
);
86+
const result2 = generateDeterministicRandomNumber(
87+
MOCK_METRICS_IDS.EXTENSION_VALID,
88+
);
89+
expect(result1).toBe(result2);
90+
});
91+
92+
it('handles minimum hex value', () => {
93+
const result = generateDeterministicRandomNumber(
94+
MOCK_METRICS_IDS.EXTENSION_MIN,
95+
);
96+
expect(result).toBe(0);
97+
});
98+
99+
it('handles maximum hex value', () => {
100+
const result = generateDeterministicRandomNumber(
101+
MOCK_METRICS_IDS.EXTENSION_MAX,
102+
);
103+
expect(result).toBe(1);
104+
});
105+
});
106+
107+
describe('Distribution validation', () => {
108+
it('produces uniform distribution across 1000 samples', () => {
109+
const samples = 1000;
110+
const buckets = 10;
111+
const tolerance = 0.3;
112+
const distribution = new Array(buckets).fill(0);
113+
114+
// Generate samples using valid UUIDs
115+
Array.from({ length: samples }).forEach(() => {
116+
const uuid = uuidV4();
117+
const value = generateDeterministicRandomNumber(uuid);
118+
const bucketIndex = Math.floor(value * buckets);
119+
// Handle edge case where value === 1
120+
distribution[
121+
bucketIndex === buckets ? buckets - 1 : bucketIndex
122+
] += 1;
123+
});
124+
125+
// Check distribution
126+
const expectedPerBucket = samples / buckets;
127+
const allowedDeviation = expectedPerBucket * tolerance;
128+
129+
distribution.forEach((count) => {
130+
const minExpected = Math.floor(expectedPerBucket - allowedDeviation);
131+
const maxExpected = Math.ceil(expectedPerBucket + allowedDeviation);
132+
expect(count).toBeGreaterThanOrEqual(minExpected);
133+
expect(count).toBeLessThanOrEqual(maxExpected);
134+
});
135+
});
136+
});
137+
138+
describe('MetaMetrics ID validation', () => {
139+
it('throws an error if the MetaMetrics ID is empty', () => {
140+
expect(() => generateDeterministicRandomNumber('')).toThrow(
141+
'MetaMetrics ID cannot be empty',
142+
);
143+
});
144+
145+
it('throws an error if the MetaMetrics ID is not a valid UUIDv4', () => {
146+
expect(() =>
147+
generateDeterministicRandomNumber(MOCK_METRICS_IDS.UUID_V3),
148+
).toThrow('Invalid UUID version. Expected v4, got v3');
149+
});
49150

50-
expect(result1).not.toBe(result2);
151+
it('throws an error if the MetaMetrics ID is not a valid hex string', () => {
152+
expect(() =>
153+
generateDeterministicRandomNumber(
154+
MOCK_METRICS_IDS.INVALID_HEX_NO_PREFIX,
155+
),
156+
).toThrow('Hex ID must start with 0x prefix');
157+
});
158+
159+
it('throws an error if the MetaMetrics ID is a short hex string', () => {
160+
expect(() =>
161+
generateDeterministicRandomNumber(MOCK_METRICS_IDS.INVALID_HEX_SHORT),
162+
).toThrow('Invalid hex ID length. Expected 64 characters, got 63');
163+
});
164+
165+
it('throws an error if the MetaMetrics ID is a long hex string', () => {
166+
expect(() =>
167+
generateDeterministicRandomNumber(MOCK_METRICS_IDS.INVALID_HEX_LONG),
168+
).toThrow('Invalid hex ID length. Expected 64 characters, got 65');
169+
});
170+
171+
it('throws an error if the MetaMetrics ID contains invalid hex characters', () => {
172+
expect(() =>
173+
generateDeterministicRandomNumber(
174+
MOCK_METRICS_IDS.INVALID_HEX_INVALID_CHARS,
175+
),
176+
).toThrow('Hex ID contains invalid characters');
177+
});
51178
});
52179
});
53180

packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts

+61-9
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,77 @@
11
import type { Json } from '@metamask/utils';
2+
import { validate as uuidValidate, version as uuidVersion } from 'uuid';
23

34
import type { FeatureFlagScopeValue } from '../remote-feature-flag-controller-types';
45

5-
/* eslint-disable no-bitwise */
6+
/**
7+
* Converts a UUID string to a BigInt by removing dashes and converting to hexadecimal.
8+
* @param uuid - The UUID string to convert
9+
* @returns The UUID as a BigInt value
10+
*/
11+
function uuidStringToBigInt(uuid: string): bigint {
12+
return BigInt(`0x${uuid.replace(/-/gu, '')}`);
13+
}
14+
15+
const MIN_UUID_V4 = '00000000-0000-4000-8000-000000000000';
16+
const MAX_UUID_V4 = 'ffffffff-ffff-4fff-bfff-ffffffffffff';
17+
const MIN_UUID_V4_BIGINT = uuidStringToBigInt(MIN_UUID_V4);
18+
const MAX_UUID_V4_BIGINT = uuidStringToBigInt(MAX_UUID_V4);
19+
const UUID_V4_VALUE_RANGE_BIGINT = MAX_UUID_V4_BIGINT - MIN_UUID_V4_BIGINT;
20+
621
/**
722
* Generates a deterministic random number between 0 and 1 based on a metaMetricsId.
823
* This is useful for A/B testing and feature flag rollouts where we want
924
* consistent group assignment for the same user.
10-
*
11-
* @param metaMetricsId - The unique identifier used to generate the deterministic random number
12-
* @returns A number between 0 and 1 that is deterministic for the given metaMetricsId
25+
* @param metaMetricsId - The unique identifier used to generate the deterministic random number. Must be either:
26+
* - A UUIDv4 string (e.g., '123e4567-e89b-12d3-a456-426614174000'
27+
* - A hex string with '0x' prefix (e.g., '0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420')
28+
* @returns A number between 0 and 1, deterministically generated from the input ID.
29+
* The same input will always produce the same output.
1330
*/
1431
export function generateDeterministicRandomNumber(
1532
metaMetricsId: string,
1633
): number {
17-
const hash = [...metaMetricsId].reduce((acc, char) => {
18-
const chr = char.charCodeAt(0);
19-
return ((acc << 5) - acc + chr) | 0;
20-
}, 0);
34+
if (!metaMetricsId) {
35+
throw new Error('MetaMetrics ID cannot be empty');
36+
}
37+
38+
let idValue: bigint;
39+
let maxValue: bigint;
40+
41+
// uuidv4 format
42+
if (uuidValidate(metaMetricsId)) {
43+
if (uuidVersion(metaMetricsId) !== 4) {
44+
throw new Error(
45+
`Invalid UUID version. Expected v4, got v${uuidVersion(metaMetricsId)}`,
46+
);
47+
}
48+
idValue = uuidStringToBigInt(metaMetricsId) - MIN_UUID_V4_BIGINT;
49+
maxValue = UUID_V4_VALUE_RANGE_BIGINT;
50+
} else {
51+
// hex format with 0x prefix
52+
if (!metaMetricsId.startsWith('0x')) {
53+
throw new Error('Hex ID must start with 0x prefix');
54+
}
55+
56+
const cleanId = metaMetricsId.slice(2);
57+
const EXPECTED_HEX_LENGTH = 64; // 32 bytes = 64 hex characters
58+
59+
if (cleanId.length !== EXPECTED_HEX_LENGTH) {
60+
throw new Error(
61+
`Invalid hex ID length. Expected ${EXPECTED_HEX_LENGTH} characters, got ${cleanId.length}`,
62+
);
63+
}
64+
65+
if (!/^[0-9a-f]+$/iu.test(cleanId)) {
66+
throw new Error('Hex ID contains invalid characters');
67+
}
68+
69+
idValue = BigInt(`0x${cleanId}`);
70+
maxValue = BigInt(`0x${'f'.repeat(cleanId.length)}`);
71+
}
2172

22-
return (hash >>> 0) / 0xffffffff;
73+
// Use BigInt division first, then convert to number to maintain precision
74+
return Number((idValue * BigInt(1_000_000)) / maxValue) / 1_000_000;
2375
}
2476

2577
/**

0 commit comments

Comments
 (0)