Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/remote-feature-flag-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add optional `defaultFeatureFlags` constructor option to `RemoteFeatureFlagController` for client-side defaults as the lowest-precedence layer under processed remote flags and local overrides ([#9747](https://github.com/MetaMask/core/pull/9747))
- **BREAKING:** Add `instanceOptions.remoteFeatureFlagController.getCanonicalProfileId` and `instanceOptions.remoteFeatureFlagController.metaMetricsFlags` constructor options to `RemoteFeatureFlagController` for threshold flag segmentation ([#9325](https://github.com/MetaMask/core/pull/9325))
- Canonical profile ID is used by default, but MetaMetrics ID can be used when the flag name is present in `metaMetricsFlags`, typically for scenarios when canonical profile ID is unavailable.

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,17 @@ const MOCK_FLAGS_WITH_THRESHOLD = {
scope: { type: 'threshold', value: 0.5 },
value: 'valueB',
},
{ name: 'groupC', scope: { type: 'threshold', value: 1 }, value: 'valueC' },
{
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';

/**
Expand All @@ -57,6 +63,8 @@ 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.getCanonicalProfileId - Returns canonicalProfileId
* @param options.metaMetricsFlags - Feature flags that should use MetaMetrics ID
* @param options.clientVersion - The client version string
* @param options.prevClientVersion - The previous client version string
* @param options.defaultFeatureFlags - Client-side default feature flags
Expand All @@ -68,6 +76,8 @@ function createController(
clientConfigApiService: AbstractClientConfigApiService;
disabled: boolean;
getMetaMetricsId: () => string;
getCanonicalProfileId: () => string;
metaMetricsFlags: Record<string, boolean>;
clientVersion: string;
prevClientVersion: string;
defaultFeatureFlags: FeatureFlags;
Expand All @@ -83,6 +93,10 @@ function createController(
getMetaMetricsId:
options.getMetaMetricsId ??
((): typeof MOCK_METRICS_ID => MOCK_METRICS_ID),
getCanonicalProfileId:
options.getCanonicalProfileId ??
((): typeof MOCK_CANONICAL_ID => MOCK_CANONICAL_ID),
metaMetricsFlags: options.metaMetricsFlags,
clientVersion: options.clientVersion ?? MOCK_BASE_VERSION,
prevClientVersion: options.prevClientVersion,
defaultFeatureFlags: options.defaultFeatureFlags,
Expand Down Expand Up @@ -474,6 +488,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { testFlagForThreshold: true },
});
await messenger.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
Expand Down Expand Up @@ -509,6 +524,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { testFlag: true },
});

await messenger.call(
Expand Down Expand Up @@ -575,6 +591,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { testFlag: true },
});
await messenger.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
Expand Down Expand Up @@ -619,6 +636,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { featureA: true, featureB: true },
});

// Act
Expand Down Expand Up @@ -651,6 +669,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { testFlag: true },
});

// Act
Expand Down Expand Up @@ -687,6 +706,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { mixedArray: true },
});

// Act
Expand Down Expand Up @@ -728,6 +748,7 @@ describe('RemoteFeatureFlagController', () => {
createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { testFlag: true },
});
await messenger1.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
Expand All @@ -738,6 +759,7 @@ describe('RemoteFeatureFlagController', () => {
createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { testFlag: true },
});
await messenger2.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
Expand All @@ -752,10 +774,74 @@ describe('RemoteFeatureFlagController', () => {
testFlag: 'control',
});
});

it('uses getCanonicalProfileId for threshold flags absent from metaMetricsFlags', async () => {
const mockFlags = {
canonicalThresholdFlag: [
{
name: 'groupA',
scope: { type: 'threshold', value: 0.5 },
value: 'canonicalA',
},
{
name: 'groupB',
scope: { type: 'threshold', value: 1.0 },
value: 'canonicalB',
},
],
};
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: mockFlags,
});
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => '',
getCanonicalProfileId: () => 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 profile id is empty', async () => {
const mockFlags = {
canonicalThresholdFlag: [
{
name: 'groupA',
scope: { type: 'threshold', value: 1.0 },
value: 'canonicalA',
},
],
};
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: mockFlags,
});
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
getCanonicalProfileId: () => '',
});

await messenger.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
);

expect(
controller.state.remoteFeatureFlags.canonicalThresholdFlag,
).toStrictEqual(mockFlags.canonicalThresholdFlag);
});
});

describe('metaMetricsIds explicit targeting', () => {
const MOCK_FLAGS_WITH_EXPLICIT_IDS = {
const MOCK_FLAGS_WITH_EXPLICIT_IDS: FeatureFlags = {
testFlag: [
{
name: 'qaGroup',
Expand Down Expand Up @@ -783,6 +869,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { testFlag: true },
});

await messenger.call(
Expand All @@ -796,7 +883,7 @@ describe('RemoteFeatureFlagController', () => {
});

it('first entry with a matching metaMetricsId wins when multiple entries match', async () => {
const mockFlags = {
const mockFlags: FeatureFlags = {
testFlag: [
{
name: 'first',
Expand All @@ -818,6 +905,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { testFlag: true },
});

await messenger.call(
Expand All @@ -831,7 +919,7 @@ describe('RemoteFeatureFlagController', () => {
});

it('falls back to hash-based threshold when no entry matches the metaMetricsId', async () => {
const mockFlags = {
const mockFlags: FeatureFlags = {
testFlag: [
{
name: 'qaGroup',
Expand All @@ -857,6 +945,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { testFlag: true },
});

await messenger.call(
Expand All @@ -871,7 +960,7 @@ describe('RemoteFeatureFlagController', () => {
});

it('ignores entries with a malformed metaMetricsIds (non-array) and falls back to hash-based threshold', async () => {
const mockFlags = {
const mockFlags: FeatureFlags = {
testFlag: [
{
name: 'badGroup',
Expand Down Expand Up @@ -911,7 +1000,7 @@ describe('RemoteFeatureFlagController', () => {
});

it('ignores non-string items within metaMetricsIds when matching', async () => {
const mockFlags = {
const mockFlags: FeatureFlags = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hash fallback test misses MetaMetrics opt-in

Medium Severity

The malformed-metaMetricsIds hash-fallback case still expects the MetaMetrics bucketing result for testFlag, but never lists that flag in metaMetricsFlags. With the new canonical default, this path hashes getCanonicalProfileId instead, so the assertion no longer validates the intended MetaMetrics fallback behavior.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1e9b936. Configure here.

testFlag: [
{
name: 'badGroup',
Expand Down Expand Up @@ -946,7 +1035,7 @@ describe('RemoteFeatureFlagController', () => {
});

it('normalizes metaMetricsId with trim and toLowerCase before matching', async () => {
const mockFlags = {
const mockFlags: FeatureFlags = {
testFlag: [
{
name: 'qaGroup',
Expand Down Expand Up @@ -1000,7 +1089,7 @@ describe('RemoteFeatureFlagController', () => {
});

it('still populates the threshold cache for hash-based fallback when no explicit ID matches', async () => {
const mockFlags = {
const mockFlags: FeatureFlags = {
testFlag: [
{
name: 'qaGroup',
Expand All @@ -1021,6 +1110,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { testFlag: true },
});

await messenger.call(
Expand Down Expand Up @@ -1054,9 +1144,12 @@ describe('RemoteFeatureFlagController', () => {
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: MOCK_FLAGS_WITH_EXPLICIT_IDS,
});
// This flag segments by MetaMetrics ID, so an unavailable ID leaves the
// threshold array unprocessed.
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => '',
metaMetricsFlags: { testFlag: true },
});

await messenger.call(
Expand Down Expand Up @@ -1091,7 +1184,7 @@ describe('RemoteFeatureFlagController', () => {
});

it('supports ThresholdVersion.DirectValue entries with explicit-ID matching', async () => {
const mockFlags = {
const mockFlags: FeatureFlags = {
testFlag: [
{
thresholdName: 'qaGroup',
Expand Down Expand Up @@ -1424,6 +1517,7 @@ describe('RemoteFeatureFlagController', () => {
clientConfigApiService: mockApiService,
clientVersion: '13.1.5', // Qualifies for 13.1.0 version but not 13.2.0
getMetaMetricsId: () => MOCK_METRICS_ID, // This generates threshold > 0.7
metaMetricsFlags: { multiVersionABFlag: true },
});

await messenger.call(
Expand Down Expand Up @@ -2110,6 +2204,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { flagA: true, flagB: true },
});

// Act - First update: both flags processed
Expand Down Expand Up @@ -2240,6 +2335,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { persistentFlag: true },
});

// Act - Multiple updates with same flag
Expand Down Expand Up @@ -2286,6 +2382,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { testFlag: true },
state: {
thresholdCache: {
[`${differentUserId}:oldFlag`]: 0.123, // Different user's cache
Expand Down Expand Up @@ -2322,6 +2419,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { newFlag: true },
});

// Act - Process with empty cache
Expand Down Expand Up @@ -2351,6 +2449,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { oldFlag: true, newFlag: true },
});

await messenger.call(
Expand Down Expand Up @@ -2413,6 +2512,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => '', // Empty metaMetricsId
metaMetricsFlags: { thresholdFlag: true },
});

// Act
Expand Down Expand Up @@ -2443,6 +2543,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { 'feature:v2': true },
});

// Act
Expand Down Expand Up @@ -2491,6 +2592,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: { flagA: true, flagB: true },
});

// Act - First update populates cache
Expand Down
Loading