Skip to content

Commit 1d6a8fb

Browse files
authored
feat: implement react native release profiler (MetaMask#18109)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Implement react-native-release-profiler in the app which allows devs to profile release builds. This PR covers: - install react-native-release-profiler - implement profiler UI which appears on shake. This only applies to RC (release candidate) builds and should not appear in any other builds - introduce a new pipeline release_rc_builds_to_store_pipeline so we can build RC builds directly from Bitrise ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** MetaMask/mobile-planning#2246 ## **Manual testing steps** ```gherkin Feature: Release build profiling Scenario: Profiler menu toggles via shake in RC build Given the app is an RC/TestFlight build running on a physical device And the app is on any screen When user shakes the device Then the Profiler menu is visible Scenario: Start and Stop create a profiling session Given the Profiler menu is visible When user taps Start Then the recording status shows "Recording..." When user performs the target journey And user taps Stop Then the recording status shows "Stopped" And an internal profile path is created Scenario: Export on iOS shares the profile file Given the Profiler menu is visible on iOS And a profile session has been stopped When user taps Export Then the iOS share sheet appears And the user can AirDrop or Save to Files the .cpuprofile to the Mac Scenario: Profile file appears in Downloads on Android Given a profile session has been stopped on Android When the user opens the Downloads app Then a file named like "sampling-profiler-trace-<unique>.cpuprofile.txt" is present Scenario: Convert profile to Chrome tracing JSON Given the .cpuprofile file exists on the Mac When user runs "npx react-native-release-profiler --local <path-to.cpuprofile>" Then a JSON trace file is generated Scenario: View the trace in Chrome tracing Given the JSON trace file exists When user opens chrome://tracing and loads the JSON Then the timeline renders without errors ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** There was no profiler before ### **After** ![iOS profiler](https://github.com/user-attachments/assets/b0ad6a47-cc22-422f-b714-e65f0989faa1) ![android profiler](https://github.com/user-attachments/assets/0f8e1f87-22f9-46f0-a61e-298f3fa451d9) Screenshot on chrome tracing <img width="1920" height="662" alt="Screenshot 2025-08-12 at 11 40 23 AM" src="https://github.com/user-attachments/assets/6993d148-a09f-442c-80d6-1cf154e91c0f" /> ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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.
1 parent 49f40a4 commit 1d6a8fb

15 files changed

Lines changed: 622 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ To learn how to contribute to the MetaMask codebase, visit our [Contributor Docs
2121
- [Testing](./docs/readme/testing.md)
2222
- [Debugging](./docs/readme/debugging.md)
2323
- [Performance](./docs/readme/performance.md)
24+
- [Release Build Profiling](./docs/readme/release-build-profiler.md)
2425
- [API Call Logging for Debugging](./docs/readme/api-logging.md)
2526
- [Storybook](./docs/readme/storybook.md)
2627
- [Miscellaneous](./docs/readme/miscellaneous.md)

app/components/Nav/App/App.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ jest.mock('react-native/Libraries/Linking/Linking', () => ({
4949
removeEventListener: jest.fn(),
5050
}));
5151

52+
jest.mock('expo-sensors', () => ({
53+
Accelerometer: {
54+
setUpdateInterval: jest.fn(),
55+
addListener: jest.fn(),
56+
removeAllListeners: jest.fn(),
57+
},
58+
}));
59+
5260
jest.mock('../../../core/DeeplinkManager/SharedDeeplinkManager', () => ({
5361
init: jest.fn(),
5462
parse: jest.fn(),
@@ -209,6 +217,7 @@ jest.mock('react-native-branch', () => ({
209217

210218
jest.mock('react-native-device-info', () => ({
211219
getVersion: jest.fn().mockReturnValue('1.0.0'),
220+
getBundleId: jest.fn().mockReturnValue('io.metamask'),
212221
}));
213222

214223
jest.mock('../../../selectors/accountsController', () => ({

app/components/Nav/App/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import Toast, {
4848
} from '../../../component-library/components/Toast';
4949
import AccountSelector from '../../../components/Views/AccountSelector';
5050
import { TokenSortBottomSheet } from '../../../components/UI/Tokens/TokensBottomSheet/TokenSortBottomSheet';
51+
import ProfilerManager from '../../../components/UI/ProfilerManager';
5152
import { TokenFilterBottomSheet } from '../../../components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet';
5253
import NetworkManager from '../../../components/UI/NetworkManager';
5354
import AccountConnect from '../../../components/Views/AccountConnect';
@@ -1245,6 +1246,7 @@ const App: React.FC = () => {
12451246
<PPOMView />
12461247
<AppFlow />
12471248
<Toast ref={toastRef} />
1249+
<ProfilerManager />
12481250
</>
12491251
);
12501252
};
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import React from 'react';
2+
import { render, fireEvent, act } from '@testing-library/react-native';
3+
import { Platform, Share } from 'react-native';
4+
import RNFS from 'react-native-fs';
5+
import { startProfiling, stopProfiling } from 'react-native-release-profiler';
6+
import ShakeDetector from './ShakeDetector';
7+
import ProfilerManager from './ProfilerManager';
8+
9+
jest.mock('react-native-device-info', () => ({
10+
getBundleId: jest.fn(),
11+
getVersion: jest.fn(),
12+
}));
13+
14+
jest.mock('react-native-release-profiler', () => ({
15+
startProfiling: jest.fn(),
16+
stopProfiling: jest.fn(),
17+
}));
18+
19+
jest.mock('./ShakeDetector', () => jest.fn(() => null));
20+
21+
// Provide a local mock for RNFS so we can control paths and behaviors in tests
22+
jest.mock('react-native-fs', () => ({
23+
DocumentDirectoryPath: '/documents',
24+
exists: jest.fn(),
25+
copyFile: jest.fn(),
26+
}));
27+
28+
describe('ProfilerManager', () => {
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
});
32+
33+
describe('environment-based enabling', () => {
34+
it('enables profiler when enabled prop is true', () => {
35+
render(<ProfilerManager enabled />);
36+
expect(ShakeDetector).toHaveBeenCalled();
37+
});
38+
39+
it('disables profiler when enabled prop is false', () => {
40+
const { toJSON } = render(<ProfilerManager enabled={false} />);
41+
expect(toJSON()).toBeNull();
42+
expect(ShakeDetector).not.toHaveBeenCalled();
43+
});
44+
45+
it('disables profiler when enabled prop is undefined and no environment set', () => {
46+
const { toJSON } = render(<ProfilerManager />);
47+
expect(toJSON()).toBeNull();
48+
expect(ShakeDetector).not.toHaveBeenCalled();
49+
});
50+
});
51+
52+
describe('forced enabling via props', () => {
53+
it('enables profiler when forced via props regardless of environment', () => {
54+
render(<ProfilerManager enabled />);
55+
expect(ShakeDetector).toHaveBeenCalled();
56+
});
57+
58+
it('disables profiler when explicitly disabled via props', () => {
59+
const { toJSON } = render(<ProfilerManager enabled={false} />);
60+
expect(toJSON()).toBeNull();
61+
expect(ShakeDetector).not.toHaveBeenCalled();
62+
});
63+
});
64+
65+
describe('UI interactions and profiling flow', () => {
66+
beforeEach(() => {
67+
jest.clearAllMocks();
68+
});
69+
70+
it('toggles visibility on shake and can be closed via the close button on iOS', async () => {
71+
Platform.OS = 'ios';
72+
const { queryByText, getByText, getByTestId } = render(
73+
<ProfilerManager enabled />,
74+
);
75+
expect(queryByText('Performance Profiler')).toBeNull();
76+
// Trigger shake
77+
const firstCallProps = (ShakeDetector as jest.Mock).mock.calls[0][0];
78+
act(() => {
79+
firstCallProps.onShake();
80+
});
81+
expect(getByText('Performance Profiler')).toBeTruthy();
82+
expect(getByText('Shake device to toggle this menu.')).toBeTruthy();
83+
expect(
84+
queryByText(
85+
'You can find the profiling file in the Android Downloads folder.',
86+
),
87+
).toBeFalsy();
88+
expect(getByText('Start')).toBeTruthy();
89+
expect(getByText('Export')).toBeTruthy();
90+
// Close via "x"
91+
fireEvent.press(getByTestId('close-profiler-button'));
92+
expect(queryByText('Performance Profiler')).toBeNull();
93+
});
94+
95+
it('toggles visibility on shake and calls exportTrace when Export is pressed', async () => {
96+
Platform.OS = 'ios';
97+
const { queryByText, getByText } = render(<ProfilerManager enabled />);
98+
expect(queryByText('Performance Profiler')).toBeNull();
99+
100+
const firstCallProps = (ShakeDetector as jest.Mock).mock.calls[0][0];
101+
act(() => {
102+
firstCallProps.onShake();
103+
});
104+
105+
const mockProfilePath = '/tmp/mock-profile.cpuprofile';
106+
(startProfiling as jest.Mock).mockResolvedValue(undefined);
107+
(stopProfiling as jest.Mock).mockResolvedValue(mockProfilePath);
108+
(RNFS.exists as jest.Mock).mockResolvedValue(true);
109+
(RNFS.copyFile as jest.Mock).mockResolvedValue(undefined);
110+
jest.spyOn(Share, 'share').mockResolvedValue({
111+
action: Share.sharedAction,
112+
});
113+
114+
await act(async () => {
115+
fireEvent.press(getByText('Start'));
116+
});
117+
await act(async () => {
118+
fireEvent.press(getByText('Stop'));
119+
});
120+
121+
await act(async () => {
122+
fireEvent.press(getByText('Export'));
123+
});
124+
125+
expect(RNFS.exists).toHaveBeenCalledWith(mockProfilePath);
126+
expect(RNFS.copyFile).toHaveBeenCalledWith(
127+
mockProfilePath,
128+
expect.stringMatching(/^\/documents\/.+\.cpuprofile$/),
129+
);
130+
expect(Share.share).toHaveBeenCalledWith({
131+
url: expect.stringMatching(/^file:\/\/\/documents\/.+\.cpuprofile$/),
132+
});
133+
});
134+
135+
it('toggles visibility on shake and can be closed via the close button on Android', async () => {
136+
Platform.OS = 'android';
137+
const { queryByText, getByText, getByTestId } = render(
138+
<ProfilerManager enabled />,
139+
);
140+
expect(queryByText('Performance Profiler')).toBeNull();
141+
// Trigger shake
142+
const firstCallProps = (ShakeDetector as jest.Mock).mock.calls[0][0];
143+
act(() => {
144+
firstCallProps.onShake();
145+
});
146+
expect(getByText('Performance Profiler')).toBeTruthy();
147+
expect(
148+
getByText(
149+
'Shake device to toggle this menu. You can find the profiling file in the Android Downloads folder.',
150+
),
151+
).toBeTruthy();
152+
expect(getByText('Start')).toBeTruthy();
153+
expect(queryByText('Export')).toBeFalsy();
154+
// Close via "x"
155+
fireEvent.press(getByTestId('close-profiler-button'));
156+
expect(queryByText('Performance Profiler')).toBeNull();
157+
});
158+
});
159+
});
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import React, { useState, useCallback } from 'react';
2+
import { Platform, Pressable, Share } from 'react-native';
3+
import { getBundleId, getVersion } from 'react-native-device-info';
4+
import ShakeDetector from './ShakeDetector';
5+
import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
6+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
7+
import { startProfiling, stopProfiling } from 'react-native-release-profiler';
8+
import RNFS from 'react-native-fs';
9+
import ButtonIcon from '../../../component-library/components/Buttons/ButtonIcon';
10+
import {
11+
IconName,
12+
IconColor,
13+
} from '../../../component-library/components/Icons/Icon';
14+
import { isRc } from '../../../util/test/utils';
15+
16+
interface ProfilerManagerProps {
17+
enabled?: boolean;
18+
}
19+
20+
const ProfilerManager: React.FC<ProfilerManagerProps> = ({
21+
enabled = isRc,
22+
}) => {
23+
const [isVisible, setIsVisible] = useState(false);
24+
const [isRecording, setIsRecording] = useState(false);
25+
const [sessionId, setSessionId] = useState<string | null>(null);
26+
const [lastProfilePath, setLastProfilePath] = useState<string | null>(null);
27+
const appId = getBundleId();
28+
const tw = useTailwind();
29+
30+
const handleShake = useCallback(() => {
31+
setIsVisible((prev) => !prev);
32+
}, []);
33+
34+
const startProfiler = useCallback(async () => {
35+
try {
36+
const appVersion = getVersion();
37+
const timestamp = Date.now();
38+
const newSessionId = `${appId}_v${appVersion}_${timestamp}`;
39+
40+
await startProfiling();
41+
setIsRecording(true);
42+
setSessionId(newSessionId);
43+
} catch (error) {
44+
// fail silently
45+
}
46+
}, [appId]);
47+
48+
const stopProfiler = useCallback(async () => {
49+
if (!sessionId) return;
50+
51+
try {
52+
const path = await stopProfiling(true);
53+
if (typeof path === 'string' && path.length > 0) {
54+
setLastProfilePath(path);
55+
}
56+
} catch (error) {
57+
// fail silently
58+
}
59+
setIsRecording(false);
60+
setSessionId(null);
61+
}, [sessionId]);
62+
63+
// For iOS only. We can find the file in the Downloads folder on Android.
64+
const exportTrace = useCallback(async () => {
65+
if (!lastProfilePath) return;
66+
try {
67+
const exists = await RNFS.exists(lastProfilePath);
68+
if (!exists) return;
69+
70+
const appVersion = getVersion();
71+
const timestamp = Date.now();
72+
const fileName = `${appId}_v${appVersion}_${timestamp}.cpuprofile`;
73+
const destPath = `${RNFS.DocumentDirectoryPath}/${fileName}`;
74+
75+
await RNFS.copyFile(lastProfilePath, destPath);
76+
77+
await Share.share({ url: `file://${destPath}` });
78+
} catch (e) {
79+
// fail silently
80+
}
81+
}, [lastProfilePath, appId]);
82+
83+
const toggleProfiling = useCallback(() => {
84+
if (isRecording) {
85+
stopProfiler();
86+
} else {
87+
startProfiler();
88+
}
89+
}, [isRecording, startProfiler, stopProfiler]);
90+
91+
const hideProfiler = useCallback(() => {
92+
setIsVisible(false);
93+
}, []);
94+
95+
if (!enabled) {
96+
return null;
97+
}
98+
99+
return (
100+
<>
101+
<ShakeDetector onShake={handleShake} sensibility={3} />
102+
{isVisible && (
103+
<Box twClassName="absolute top-20 right-4 z-50 shadow-lg min-w-48">
104+
<Box twClassName="bg-default rounded-xl p-3 border border-muted">
105+
<Box twClassName="flex-row p-2 mb-3 justify-between">
106+
<Text variant={TextVariant.BodyXs}>Performance Profiler</Text>
107+
<ButtonIcon
108+
iconName={IconName.Close}
109+
iconColor={IconColor.Default}
110+
onPress={hideProfiler}
111+
testID="close-profiler-button"
112+
/>
113+
</Box>
114+
115+
<Box twClassName="flex-row items-center mb-3">
116+
<Box
117+
twClassName={`w-2 h-2 rounded-full mr-2 ${
118+
isRecording ? 'bg-error-default' : 'bg-primary-default'
119+
}`}
120+
/>
121+
<Text variant={TextVariant.BodyXs}>
122+
{isRecording ? 'Recording...' : 'Stopped'}
123+
</Text>
124+
</Box>
125+
<Box twClassName="mb-3 bg-muted rounded-md p-2">
126+
<Text variant={TextVariant.BodyXs}>
127+
Build Type: {process.env.METAMASK_BUILD_TYPE || 'undefined'}
128+
</Text>
129+
<Text variant={TextVariant.BodyXs}>
130+
Environment: {process.env.METAMASK_ENVIRONMENT || 'undefined'}
131+
</Text>
132+
</Box>
133+
<Box twClassName="flex-row gap-2 mb-3">
134+
<Pressable
135+
style={tw.style(
136+
'flex-1 p-2 rounded-md items-center justify-center',
137+
isRecording ? 'bg-error-default' : 'bg-primary-default',
138+
)}
139+
onPress={toggleProfiling}
140+
>
141+
<Text twClassName="text-white" variant={TextVariant.BodySm}>
142+
{isRecording ? 'Stop' : 'Start'}
143+
</Text>
144+
</Pressable>
145+
{Platform.OS === 'ios' && (
146+
<Pressable
147+
disabled={isRecording || !lastProfilePath}
148+
style={({ pressed }) =>
149+
tw.style(
150+
'flex-1 p-2 rounded-md items-center justify-center',
151+
isRecording || !lastProfilePath
152+
? 'bg-muted'
153+
: pressed
154+
? 'bg-pressed'
155+
: 'bg-primary-default',
156+
)
157+
}
158+
onPress={exportTrace}
159+
>
160+
<Text
161+
twClassName={
162+
isRecording || !lastProfilePath
163+
? 'text-muted'
164+
: 'text-white'
165+
}
166+
variant={TextVariant.BodySm}
167+
>
168+
Export
169+
</Text>
170+
</Pressable>
171+
)}
172+
</Box>
173+
174+
<Box twClassName="pt-3 border-t border-muted max-w-48">
175+
<Text variant={TextVariant.BodyXs} twClassName="text-center">
176+
Shake device to toggle this menu.{' '}
177+
{Platform.OS === 'android' &&
178+
'You can find the profiling file in the Android Downloads folder.'}
179+
</Text>
180+
</Box>
181+
</Box>
182+
</Box>
183+
)}
184+
</>
185+
);
186+
};
187+
188+
export default ProfilerManager;

0 commit comments

Comments
 (0)