Skip to content
Draft
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
4 changes: 4 additions & 0 deletions packages/remote-feature-flag-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
11 changes: 10 additions & 1 deletion packages/remote-feature-flag-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
};
}
}
}

Expand Down
Loading
Loading