Skip to content

Commit

Permalink
feat(3742): Feature Flag Values with Scope Based on threshold
Browse files Browse the repository at this point in the history
  • Loading branch information
DDDDDanica committed Dec 11, 2024
1 parent 1fd0d3a commit e025bda
Show file tree
Hide file tree
Showing 8 changed files with 319 additions and 2 deletions.
11 changes: 10 additions & 1 deletion packages/remote-feature-flag-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.2.0]

### Added

- Added support for threshold-based feature flag scoping ([#5051](https://github.com/MetaMask/core/pull/5051))
- Enables percentage-based feature flag distribution across user base
- Uses deterministic random group assignment based on metaMetricsId

## [1.1.0]

### Added
Expand All @@ -26,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial release of the RemoteFeatureFlagController. ([#4931](https://github.com/MetaMask/core/pull/4931))
- This controller manages the retrieval and caching of remote feature flags. It fetches feature flags from a remote API, caches them, and provides methods to access and manage these flags. The controller ensures that feature flags are refreshed based on a specified interval and handles cases where the controller is disabled or the network is unavailable.

[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/[email protected]
[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/[email protected]
[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/[email protected]...@metamask/[email protected]
[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/[email protected]...@metamask/[email protected]
[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/[email protected]
1 change: 1 addition & 0 deletions packages/remote-feature-flag-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"dependencies": {
"@metamask/base-controller": "^7.0.2",
"@metamask/utils": "^10.0.0",
"@noble/hashes": "^1.4.0",
"cockatiel": "^3.1.2"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ export type FeatureFlags = {
[key: string]: Json;
};

export type FeatureFlagScope = {
type: string;
value: number;
};

export type FeatureFlagScopeValue = {
name: string;
scope: FeatureFlagScope;
value: Json;
};

export type ApiDataResponse = FeatureFlags[];

export type ServiceResponse = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
RemoteFeatureFlagController,
controllerName,
DEFAULT_CACHE_DURATION,
getDefaultRemoteFeatureFlagControllerState,
} from './remote-feature-flag-controller';
import type {
RemoteFeatureFlagControllerActions,
Expand All @@ -13,14 +14,35 @@ 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';

const MOCK_FLAGS: FeatureFlags = {
feature1: true,
feature2: { chrome: '<109' },
feature3: [1, 2, 3],
};

const MOCK_FLAGS_TWO = { different: true };

const MOCK_FLAGS_WITH_THRESHOLD = {
...MOCK_FLAGS,
testFlagForThreshold: [
{
name: 'groupA',
scope: { type: 'threshold', value: 0.3 },
value: 'valueA',
},
{
name: 'groupB',
scope: { type: 'threshold', value: 0.5 },
value: 'valueB',
},
{ name: 'groupC', scope: { type: 'threshold', value: 1 }, value: 'valueC' },
],
};

const MOCK_METRICS_ID = '0x1234567890abcdef';

/**
* Creates a controller instance with default parameters for testing
* @param options - The controller configuration options
Expand All @@ -36,6 +58,7 @@ function createController(
state: Partial<RemoteFeatureFlagControllerState>;
clientConfigApiService: AbstractClientConfigApiService;
disabled: boolean;
metaMetricsId: string;
}> = {},
) {
return new RemoteFeatureFlagController({
Expand All @@ -44,10 +67,17 @@ function createController(
clientConfigApiService:
options.clientConfigApiService ?? buildClientConfigApiService(),
disabled: options.disabled,
metaMetricsId: options.metaMetricsId,
});
}

describe('RemoteFeatureFlagController', () => {
beforeEach(() => {
jest
.spyOn(userSegmentationUtils, 'generateFallbackMetaMetricsId')
.mockReturnValue(MOCK_METRICS_ID);
});

describe('constructor', () => {
it('initializes with default state', () => {
const controller = createController();
Expand Down Expand Up @@ -239,6 +269,58 @@ describe('RemoteFeatureFlagController', () => {
});
});

describe('threshold feature flags', () => {
it('processes threshold feature flags based on provided metaMetricsId', async () => {
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: MOCK_FLAGS_WITH_THRESHOLD,
});
const controller = createController({
clientConfigApiService,
metaMetricsId: MOCK_METRICS_ID,
});
await controller.updateRemoteFeatureFlags();

expect(
controller.state.remoteFeatureFlags.testFlagForThreshold,
).toStrictEqual({
name: 'groupB',
value: 'valueB',
});
});

it('preserves non-threshold feature flags unchanged', async () => {
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: MOCK_FLAGS_WITH_THRESHOLD,
});
const controller = createController({
clientConfigApiService,
metaMetricsId: MOCK_METRICS_ID,
});
await controller.updateRemoteFeatureFlags();

const { testFlagForThreshold, ...nonThresholdFlags } =
controller.state.remoteFeatureFlags;
expect(nonThresholdFlags).toStrictEqual(MOCK_FLAGS);
});

it('uses a fallback metaMetricsId if none is provided', async () => {
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: MOCK_FLAGS_WITH_THRESHOLD,
});
const controller = createController({
clientConfigApiService,
});
await controller.updateRemoteFeatureFlags();

expect(
controller.state.remoteFeatureFlags.testFlagForThreshold,
).toStrictEqual({
name: 'groupB',
value: 'valueB',
});
});
});

describe('enable and disable', () => {
it('enables the controller and makes a network request to fetch', async () => {
const clientConfigApiService = buildClientConfigApiService();
Expand Down Expand Up @@ -273,6 +355,15 @@ describe('RemoteFeatureFlagController', () => {
expect(controller.state.remoteFeatureFlags).toStrictEqual(MOCK_FLAGS);
});
});

describe('getDefaultRemoteFeatureFlagControllerState', () => {
it('should return default state', () => {
expect(getDefaultRemoteFeatureFlagControllerState()).toStrictEqual({
remoteFeatureFlags: {},
cacheTimestamp: 0,
});
});
});
});

type RootAction = RemoteFeatureFlagControllerActions;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import type { AbstractClientConfigApiService } from './client-config-api-service
import type {
FeatureFlags,
ServiceResponse,
FeatureFlagScopeValue,
} from './remote-feature-flag-controller-types';
import {
generateDeterministicRandomNumber,
isFeatureFlagWithScopeValue,
generateFallbackMetaMetricsId,
} from './utils/user-segmentation-utils';

// === GENERAL ===

Expand Down Expand Up @@ -97,6 +103,8 @@ export class RemoteFeatureFlagController extends BaseController<

#inProgressFlagUpdate?: Promise<ServiceResponse>;

#metaMetricsId?: string | undefined;

/**
* Constructs a new RemoteFeatureFlagController instance.
*
Expand All @@ -106,17 +114,20 @@ export class RemoteFeatureFlagController extends BaseController<
* @param options.clientConfigApiService - The service instance to fetch remote feature flags.
* @param options.fetchInterval - The interval in milliseconds before cached flags expire. Defaults to 1 day.
* @param options.disabled - Determines if the controller should be disabled initially. Defaults to false.
* @param options.metaMetricsId - Determines the threshold value for the feature flag to return
*/
constructor({
messenger,
state,
clientConfigApiService,
fetchInterval = DEFAULT_CACHE_DURATION,
disabled = false,
metaMetricsId,
}: {
messenger: RemoteFeatureFlagControllerMessenger;
state?: Partial<RemoteFeatureFlagControllerState>;
clientConfigApiService: AbstractClientConfigApiService;
metaMetricsId?: string | undefined;
fetchInterval?: number;
disabled?: boolean;
}) {
Expand All @@ -133,6 +144,7 @@ export class RemoteFeatureFlagController extends BaseController<
this.#fetchInterval = fetchInterval;
this.#disabled = disabled;
this.#clientConfigApiService = clientConfigApiService;
this.#metaMetricsId = metaMetricsId;
}

/**
Expand Down Expand Up @@ -182,14 +194,51 @@ export class RemoteFeatureFlagController extends BaseController<
* @private
*/
#updateCache(remoteFeatureFlags: FeatureFlags) {
const processedRemoteFeatureFlags =
this.#processRemoteFeatureFlags(remoteFeatureFlags);
this.update(() => {
return {
remoteFeatureFlags,
remoteFeatureFlags: processedRemoteFeatureFlags,
cacheTimestamp: Date.now(),
};
});
}

#processRemoteFeatureFlags(remoteFeatureFlags: FeatureFlags): FeatureFlags {
const processedRemoteFeatureFlags: FeatureFlags = {};
const thresholdValue = generateDeterministicRandomNumber(
this.#metaMetricsId || generateFallbackMetaMetricsId(),
);

for (const [
remoteFeatureFlagName,
remoteFeatureFlagValue,
] of Object.entries(remoteFeatureFlags)) {
let processedValue = remoteFeatureFlagValue;
if (Array.isArray(remoteFeatureFlagValue) && thresholdValue) {
const selectedGroup = remoteFeatureFlagValue.find(
(featureFlag): featureFlag is FeatureFlagScopeValue => {
if (!isFeatureFlagWithScopeValue(featureFlag)) {
return false;
}

return thresholdValue <= featureFlag.scope.value;
},
);
if (selectedGroup) {
processedValue = {
name: selectedGroup.name,
value: selectedGroup.value,
};
}
}

processedRemoteFeatureFlags[remoteFeatureFlagName] = processedValue;
}

return processedRemoteFeatureFlags;
}

/**
* Enables the controller, allowing it to make network requests.
*/
Expand Down
Loading

0 comments on commit e025bda

Please sign in to comment.