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 10, 2024
1 parent 1fd0d3a commit f9326fa
Show file tree
Hide file tree
Showing 8 changed files with 315 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]
2 changes: 2 additions & 0 deletions packages/remote-feature-flag-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
"dependencies": {
"@metamask/base-controller": "^7.0.2",
"@metamask/utils": "^10.0.0",
"@noble/ciphers": "^0.5.2",
"@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 @@ -17,10 +18,30 @@ import type { FeatureFlags } from './remote-feature-flag-controller-types';
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 +57,7 @@ function createController(
state: Partial<RemoteFeatureFlagControllerState>;
clientConfigApiService: AbstractClientConfigApiService;
disabled: boolean;
metaMetricsId: string;
}> = {},
) {
return new RemoteFeatureFlagController({
Expand All @@ -44,6 +66,7 @@ function createController(
clientConfigApiService:
options.clientConfigApiService ?? buildClientConfigApiService(),
disabled: options.disabled,
metaMetricsId: options.metaMetricsId ?? MOCK_METRICS_ID,
});
}

Expand Down Expand Up @@ -237,6 +260,58 @@ describe('RemoteFeatureFlagController', () => {
).rejects.toThrow('API Error');
expect(controller.state.remoteFeatureFlags).toStrictEqual(MOCK_FLAGS);
});

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', () => {
Expand Down Expand Up @@ -273,6 +348,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {

Check failure on line 1 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

There should be at least one empty line between import groups
generateDeterministicRandomNumber,
isFeatureFlagWithScopeValue,
generateFallbackMetaMetricsId,

Check failure on line 4 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

'generateFallbackMetaMetricsId' is defined but never used
} from './user-segmentation-utils';
import { webcrypto } from 'crypto';

Check failure on line 6 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

`crypto` import should occur before import of `./user-segmentation-utils`

const MOCK_METRICS_IDS = [
'0x1234567890abcdef',
'0xdeadbeefdeadbeef',
'0xabcdef0123456789',
'0xfedcba9876543210',
];

const MOCK_FEATURE_FLAGS = {
VALID: {
name: 'test-flag',
value: true,
scope: {
type: 'threshold',
value: 0.5,
},
},
INVALID_NO_SCOPE: {
name: 'test-flag',
value: true,
},
INVALID_VALUES: ['string', 123, true, null, []],
};

describe('user-segmentation-utils', () => {
beforeAll(() => {
// Set up crypto for tests
Object.defineProperty(global, 'crypto', {
value: webcrypto,
writable: true,
configurable: true,
});
});

describe('generateDeterministicRandomNumber', () => {
it('generates consistent numbers for the same input', () => {
const result1 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]);
const result2 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]);

expect(result1).toBe(result2);
});

it('generates numbers between 0 and 1', () => {
MOCK_METRICS_IDS.forEach((id) => {
const result = generateDeterministicRandomNumber(id);
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]);

expect(result1).not.toBe(result2);
});
});

describe('isFeatureFlagWithScopeValue', () => {
it('returns true for valid feature flag with scope', () => {
expect(isFeatureFlagWithScopeValue(MOCK_FEATURE_FLAGS.VALID)).toBe(true);
});

it('returns false for null', () => {
expect(isFeatureFlagWithScopeValue(null)).toBe(false);
});

it('returns false for non-objects', () => {
MOCK_FEATURE_FLAGS.INVALID_VALUES.forEach((value) => {
expect(isFeatureFlagWithScopeValue(value)).toBe(false);
});
});

it('returns false for objects without scope', () => {
expect(
isFeatureFlagWithScopeValue(MOCK_FEATURE_FLAGS.INVALID_NO_SCOPE),
).toBe(false);
});
});

// describe('generateFallbackMetaMetricsId', () => {

Check warning on line 87 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

Some tests seem to be commented

Check failure on line 87 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

Insert `··`
// it('returns a properly formatted hex string', () => {

Check warning on line 88 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

Some tests seem to be commented

Check failure on line 88 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

Insert `··`
// const result = generateFallbackMetaMetricsId();

Check failure on line 89 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

Insert `··`
// expect(typeof result).toBe('string');

Check failure on line 90 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

Insert `··`
// expect(result.startsWith('0x')).toBe(true);

Check failure on line 91 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

Insert `··`
// expect(result).toHaveLength(66);

Check failure on line 92 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

Insert `··`
// expect(result.slice(2)).toMatch(/^[0-9a-f]+$/u);

Check failure on line 93 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

Insert `··`
// });

// it('generates unique values for each revoke', () => {

Check warning on line 96 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

Some tests seem to be commented
// const result1 = generateFallbackMetaMetricsId();
// const result2 = generateFallbackMetaMetricsId();

// expect(result1).not.toBe(result2);
// });
// });
});
Loading

0 comments on commit f9326fa

Please sign in to comment.