Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement remote feature flag controller #12427

Open
wants to merge 32 commits into
base: main
Choose a base branch
from

Conversation

joaoloureirop
Copy link
Contributor

@joaoloureirop joaoloureirop commented Nov 26, 2024

Description

Introduction of @metamask/remote-feature-flag-controller library.

Remote feature flag controller manages data flow, retry policy, and cache expiry.
The controller consumer manages default values, data persistency, and data distribution.

See ADR for a in-depth description

Technical decisions

Controller init on Engine.ts with feature flags fetching only on cold app starts.

@metamask/remote-feature-flag-controller is only asked to fetch feature flags after its init in Engine.ts. Ensures feature flags are only fetched on cold app starts.

Fallback values

Default values are used when remote feature flags are undefined.
The fallback mechanism is implemented by each feature flag selector app/selectors/featureFlagsController/<featureFlagName>

In this PR we include minimumAppVersion selector, which manages the LD feature flag mobile-minimum-versions

One selector per each feature flag

LD feature flags can be boolean, number, strings on JSON objects.
We've decided to have each feature flag with its own selector

A feature flag selector contains:

  • state selectors for each feature flag value.
  • business logic
  • defaults for when feature flags values are undefined.
  • TS types.
  • unit tests and mocked data.

This architecture offers a clear separation between each feature flag and the logic behind it, allowing easier manipulation.
Code owners are assigned to each feature flag.

Related issues

Fixes: https://github.com/MetaMask/mobile-planning/issues/2054
Fixes: https://github.com/MetaMask/mobile-planning/issues/1975

Manual testing steps

  1. Go to this page...

Screenshots/Recordings

Before

After

Pre-merge author checklist

Pre-merge reviewer checklist

  • I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed).
  • I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.

Copy link
Contributor

CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes.

@joaoloureirop joaoloureirop self-assigned this Nov 26, 2024
@joaoloureirop joaoloureirop changed the title feat: feature-flag-controller feat: implement remote feature flag controller Nov 26, 2024
app/core/Engine.ts Outdated Show resolved Hide resolved
Comment on lines +11 to +22
const path = require("path");

const featureFlagModuleDir = path.resolve(__dirname, "../../core/feature-flags/packages/remote-feature-flag-controller");

const extraNodeModules = {
"@metamask/remote-feature-flag-controller": featureFlagModuleDir,
};

const watchFolders = [
featureFlagModuleDir,
];

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To be removed once controller has been published to NPM

Will revert 00a3bd1

Comment on lines +30 to +39
watchFolders,
resolver: {
assetExts: assetExts.filter((ext) => ext !== 'svg'),
sourceExts: [...sourceExts, 'svg', 'cjs', 'mjs'],
resolverMainFields: ['sbmodern', 'react-native', 'browser', 'main'],
extraNodeModules: new Proxy (extraNodeModules, {
get: (target, name) =>
name in target ? target[name] : path.join(process.cwd(), `node_modules/${name}`),
}),
unstable_enableSymlinks: true,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

To be removed once controller has been published to NPM

Will revert 00a3bd1

@@ -175,6 +175,7 @@
"@metamask/react-native-payments": "^2.0.0",
"@metamask/react-native-search-api": "1.0.1",
"@metamask/react-native-webview": "^14.0.4",
"@metamask/remote-feature-flag-controller": "link:../../core/feature-flags/packages/remote-feature-flag-controller",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

update once controller has been published to NPM

yarn.lock Outdated
Comment on lines 5348 to 5352
"@metamask/remote-feature-flag-controller@link:../../core/feature-flags/packages/remote-feature-flag-controller":
version "0.0.0"
uid ""

"@metamask/[email protected]", "@metamask/rpc-errors@^6.0.0", "@metamask/rpc-errors@^6.2.1", "@metamask/rpc-errors@^6.3.1", "@metamask/rpc-errors@^7.0.0", "@metamask/rpc-errors@^7.0.1":
Copy link
Contributor Author

Choose a reason for hiding this comment

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

update once controller has been published to NPM

@joaoloureirop joaoloureirop added the needs-qa Any New Features that needs a full manual QA prior to being added to a release. label Nov 27, 2024
@joaoloureirop joaoloureirop marked this pull request as ready for review November 27, 2024 13:55
@joaoloureirop joaoloureirop requested review from a team as code owners November 27, 2024 13:55
@joaoloureirop joaoloureirop requested review from sethkfman and removed request for sethkfman November 27, 2024 13:55
@joaoloureirop joaoloureirop added needs-dev-review PR needs reviews from other engineers (in order to receive required approvals) Run Smoke E2E Triggers smoke e2e on Bitrise labels Nov 27, 2024
Copy link
Contributor

github-actions bot commented Nov 27, 2024

https://bitrise.io/ Bitrise

❌❌❌ pr_smoke_e2e_pipeline failed on Bitrise! ❌❌❌

Commit hash: ebc940e
Build link: https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/53e344f4-a5a0-43e1-8b09-0df88e619bf2

Note

  • You can kick off another pr_smoke_e2e_pipeline on Bitrise by removing and re-applying the Run Smoke E2E label on the pull request

Tip

  • Check the documentation if you have any doubts on how to understand the failure on bitrise

@@ -933,8 +945,7 @@ export class Engine {
encryptor,
getMnemonic: getPrimaryKeyringMnemonic.bind(this),
getFeatureFlags: () => ({
disableSnaps:
store.getState().settings.basicFunctionalityEnabled === false,
disableSnaps: !isBasicFunctionalityEnabled,
Copy link
Contributor

Choose a reason for hiding this comment

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

Cautious warning.
Before: it would recall getState() and pick out the basicFunctionalityEnabled property.
After: Now it is a constant. Meaning that is won't re-fire getState() to get the latest value.

So hypothetical, if we turned off basic functionality, and this snap getFeatureFlags is executed, it won't get the up-to-date field from state, but instead the constant when the app was initialised.

Copy link
Contributor

@Prithpal-Sooriya Prithpal-Sooriya Nov 27, 2024

Choose a reason for hiding this comment

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

We could make the constant a function instead so we can evaluate lazily (once called) instead of just once at app init?

- const isBasicFunctionalityEnabled = ...
+ const isBasicFunctionalityEnabled = () => ...

Copy link
Contributor Author

@joaoloureirop joaoloureirop Nov 27, 2024

Choose a reason for hiding this comment

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

Great catch, thank you!

addressed in 8014b45

Comment on lines 17 to 35
it('should return feature flag object', () => {
const {
appMinimumBuild,
appleMinimumOS,
androidMinimumAPIVersion,
} = selectMobileMinimumVersions(mockedState as RootState);
expect(appleMinimumOS).toBeDefined();
expect(appMinimumBuild).toBeDefined();
expect(androidMinimumAPIVersion).toBeDefined();
});
it('should return fallback values', () => {
const {
appMinimumBuild,
appleMinimumOS,
androidMinimumAPIVersion,
} = selectMobileMinimumVersions(mockedEmptyFlagsState as RootState);
expect(appMinimumBuild).toBe(1024);
expect(appleMinimumOS).toBe(1025);
expect(androidMinimumAPIVersion).toBe(1026);
Copy link
Contributor Author

@joaoloureirop joaoloureirop Nov 27, 2024

Choose a reason for hiding this comment

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

Test feature flag state selectors separate

replace hardcoded numbers with mocked values.

} from './utils';


const init = ({
Copy link
Contributor Author

@joaoloureirop joaoloureirop Nov 27, 2024

Choose a reason for hiding this comment

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

add remote feature flag controller init unit tests

Copy link
Contributor

Choose a reason for hiding this comment

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

is this a TODO?

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks. Yes unit tests please!

Copy link
Contributor

@NicolasMassart NicolasMassart left a comment

Choose a reason for hiding this comment

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

First pass of review

app/core/Analytics/ @MetaMask/mobile-platform
app/util/metrics/ @MetaMask/mobile-platform
app/components/hooks/useMetrics/ @MetaMask/mobile-platform
app/selectors/featureFlagController/index.ts @MetaMask/mobile-platform
Copy link
Contributor

Choose a reason for hiding this comment

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

S: maybe you can make the team own the whole app/selectors/featureFlagController directory instead of individual files?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We want app/selectors/featureFlagController/<featureFlagName> to be owned by other teams

I'll update the CODEOWNERS syntax to match files like featureFlagController/index.ts but no further nested files

Copy link
Contributor Author

Choose a reason for hiding this comment

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

addressed on b784e89

(state: RootState) => state.featureFlags,
);

const appMinimumBuild = useSelector((state: RootState) => selectAppMinimumBuild(state));
Copy link
Contributor

Choose a reason for hiding this comment

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

Q: not sure if this change makes the mock in the test obsolete and how this test doesn't need any changes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch, the tests for this hook need to be updated

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm surprised the test is passing given the changes in the hook... so maybe rewrite the test in a more robust way. A change like this should make it fail otherwise it probably means it's not testing anything but the mocks...

} from './utils';


const init = ({
Copy link
Contributor

Choose a reason for hiding this comment

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

is this a TODO?

Copy link
Contributor

@NicolasMassart NicolasMassart left a comment

Choose a reason for hiding this comment

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

Thanks for this awesome work @joaoloureirop!
Added some guideline suggestions and a few questions on some code parts I'm not sure to fully understand.

@@ -266,6 +268,10 @@ export class Engine {
) {
this.controllerMessenger = new ExtendedControllerMessenger();

// Basic Functionality toggle defaults to true
const getBasicFunctionalityToggleState = () =>
Copy link
Contributor

Choose a reason for hiding this comment

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

S: should we use a selector here?

initialState,
controllerMessenger: this.controllerMessenger,
fetchFunction,
disabled: getBasicFunctionalityToggleState() === false
Copy link
Contributor

Choose a reason for hiding this comment

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

Q: why testing the exact boolean false equality here?
If the value is true, it's true.
If the value is false, it's true.
If the value eis anything like undefined or null, it will be true because of the getBasicFunctionalityToggleState default.
So if we have a false here, it's necessarily a real false as a value in the state. So the following would be enough:

Suggested change
disabled: getBasicFunctionalityToggleState() === false
disabled: !getBasicFunctionalityToggleState()

and same on line 949

} from './utils';


const init = ({
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks. Yes unit tests please!

});


it('should return feature flag initial state', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
it('should return feature flag initial state', () => {
it('returns feature flag initial state', () => {

jest.clearAllMocks();
});

it('should return feature flag object', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
it('should return feature flag object', () => {
it('returns feature flag object', () => {

expect(appMinimumBuild).toBeDefined();
expect(androidMinimumAPIVersion).toBeDefined();
});
it('should return fallback values', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
it('should return fallback values', () => {
it('returns fallback values', () => {

Comment on lines +11 to +13
appMinimumBuild: 1243,
appleMinimumOS: 6,
androidMinimumAPIVersion: 21,
Copy link
Contributor

Choose a reason for hiding this comment

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

S: should we have this in a constants file?

expect(appMinimumBuild).toBeDefined();
expect(androidMinimumAPIVersion).toBeDefined();
});
it('should return fallback values', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Q: we only have this test that tests the fallback values. How do we test the normal values? Could we have a mocked test for this?

];

for (const { errorMessage, scenario, state } of invalidStates) {
it(`should capture exception if ${scenario}`, async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
it(`should capture exception if ${scenario}`, async () => {
it(`captures exception if ${scenario}`, async () => {

});
}

it('remove featureFlags property from redux state', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
it('remove featureFlags property from redux state', async () => {
it('removes featureFlags property from redux state', async () => {

Copy link
Contributor

@Cal-L Cal-L left a comment

Choose a reason for hiding this comment

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

Left some comments

Copy link
Contributor

Choose a reason for hiding this comment

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

To keep things consistent, let's follow the pattern that resembles that of what is currently in Engine/controllers/accounts The directory name RemoteFeatureFlagController is fine. We can keep the index file that you have and use it to re-export utils and anything else in the future.

Copy link
Contributor

Choose a reason for hiding this comment

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

In this case, it's debatable whether or not we should just put all of the functions in index or utils file. IMO, creating the utils file and re-exporting via index allows us to be more flexible in the future, where index can export other things such as constants, etc.

} from './utils';


const init = ({
Copy link
Contributor

Choose a reason for hiding this comment

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

Rename to createRemoteFeatureFlagsController

Suggested change
const init = ({
const createRemoteFeatureFlagsController = ({

Copy link
Contributor

@Cal-L Cal-L Nov 27, 2024

Choose a reason for hiding this comment

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

Move into utils file

allowedEvents: [],
}),
state: {
...initialState.RemoteFeatureFlagController
Copy link
Contributor

Choose a reason for hiding this comment

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

No need to spread in an object. Just assign the value. Also pass initialState.RemoteFeatureFlagController into initialState instead of deconstructing from here

}: RemoteFeatureFlagInitParamTypes) => {

const remoteFeatureFlagController = new RemoteFeatureFlagController({
messenger: controllerMessenger.getRestricted({
Copy link
Contributor

Choose a reason for hiding this comment

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

Move messenger creation back into Engine file since we as a platform team (at least) need to be aware of messenger related changes. You can reference how the accounts controller is set up.

Copy link
Contributor

Choose a reason for hiding this comment

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

Discussed with Mark a few days ago - Here's the PR that updated the accounts controller side - #12416

import { ControllerMessenger, EngineState } from '../../types';

export interface RemoteFeatureFlagInitParamTypes {
initialState: Partial<EngineState>;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
initialState: Partial<EngineState>;
initialState: RemoteFeatureFlagControllerState;


export interface RemoteFeatureFlagInitParamTypes {
initialState: Partial<EngineState>;
controllerMessenger: ControllerMessenger,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
controllerMessenger: ControllerMessenger,
controllerMessenger: RemoteFeatureFlagControllerMessenger,

@@ -87,11 +87,6 @@ const createStoreAndPersistor = async () => {
basicFunctionalityEnabled:
store.getState().settings.basicFunctionalityEnabled,
});
// Fetch feature flags only if basic functionality is enabled
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's confirm that the code above this is also no longer needed. Rehydration should automatically populate it already

if (hasProperty(state, 'featureFlags')) {
delete state.featureFlags;
}
return state;
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's update this migration to include the default state for RemoteFeatureFlagController to be safe

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
needs-dev-review PR needs reviews from other engineers (in order to receive required approvals) needs-qa Any New Features that needs a full manual QA prior to being added to a release. Run Smoke E2E Triggers smoke e2e on Bitrise team-mobile-platform
Projects
Status: Needs dev review
Development

Successfully merging this pull request may close these issues.

5 participants