diff --git a/README.md b/README.md index 72736b3fedb7..400260393bc1 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,79 @@ Our React Native Android app now uses the `Hermes` JS engine which requires your To make it easier to test things in web, we expose the Onyx object to the window, so you can easily do `Onyx.set('bla', 1)`. +---- + +# Release Profiler +Often, performance issue debugging occurs in debug builds, which can introduce errors from elements such as JS Garbage Collection, Hermes debug markers, or LLDB pauses. + +`react-native-release-profiler` facilitates profiling within release builds for accurate local problem-solving and broad performance analysis in production to spot regressions or collect extensive device data. Therefore, we will utilize the production build version + +### Getting Started with Source Maps +To accurately profile your application, generating source maps for Android and iOS is crucial. Here's how to enable them: +1. Enable source maps on Android +Ensure the following is set in your app's `android/app/build.gradle` file. + + ```jsx + project.ext.react = [ + enableHermes: true, + hermesFlagsRelease: ["-O", "-output-source-map"], // <-- here, plus whichever flag was required to set this away from default + ] + ``` + +2. Enable source maps on IOS +Within Xcode head to the build phase - `Bundle React Native code and images`. + + ```jsx + export SOURCEMAP_FILE="$(pwd)/../main.jsbundle.map" // <-- here; + + export NODE_BINARY=node + ../node_modules/react-native/scripts/react-native-xcode.sh + ``` +3. Install the necessary packages and CocoaPods dependencies: + ```jsx + npm i && npm run pod-install + ``` +7. Depending on the platform you are targeting, run your Android/iOS app in production mode. +8. Upon completion, the generated source map can be found at: + Android: `android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map` + IOS: `main.jsbundle.map` + +### Recording a Trace: +1. Ensure you have generated the source map as outlined above. +2. Launch the app in production mode. +2. Navigate to the feature you wish to profile. +3. Initiate the profiling session by tapping with four fingers to open the menu and selecting **`Use Profiling`**. +4. Close the menu and interact with the app. +5. After completing your interactions, tap with four fingers again and select to stop profiling. +6. You will be presented with a **`Share`** option to export the trace, which includes a trace file (`Profile.cpuprofile`) and build info (`AppInfo.json`). + +Build info: +```jsx +{ + appVersion: "1.0.0", + environment: "production", + platform: "IOS", + totalMemory: "3GB", + usedMemory: "300MB" +} +``` + +### How to symbolicate trace record: +1. You have two files: `AppInfo.json` and `Profile.cpuprofile` +2. Place the `Profile.cpuprofile` file at the root of your project. +3. If you have already generated a source map from the steps above for this branch, you can skip to the next step. Otherwise, obtain the app version from `AppInfo.json` switch to that branch and generate the source map as described. + +`IMPORTANT:` You should generate the source map from the same branch as the trace was recorded. + +4. Use the following commands to symbolicate the trace for Android and iOS, respectively: +Android: `npm run symbolicate-release:android` +IOS: `npm run symbolicate-release:ios` +5. A new file named `Profile_trace_for_-converted.json` will appear in your project's root folder. +6. Open this file in your tool of choice: + - SpeedScope ([https://www.speedscope.app](https://www.speedscope.app/)) + - Perfetto UI (https://ui.perfetto.dev/) + - Google Chrome's Tracing UI (chrome://tracing) + --- # App Structure and Conventions diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c93dfba50f5a..bb26b37d4015 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1198,6 +1198,10 @@ PODS: - React - React-callinvoker - React-Core + - react-native-release-profiler (0.1.6): + - glog + - RCT-Folly (= 2022.05.16.00) + - React-Core - react-native-render-html (6.3.1): - React-Core - react-native-safe-area-context (4.8.2): @@ -1443,6 +1447,8 @@ PODS: - glog - RCT-Folly (= 2022.05.16.00) - React-Core + - RNShare (10.0.2): + - React-Core - RNSound (0.11.2): - React-Core - RNSound/Core (= 0.11.2) @@ -1546,6 +1552,7 @@ DEPENDENCIES: - react-native-performance (from `../node_modules/react-native-performance`) - react-native-plaid-link-sdk (from `../node_modules/react-native-plaid-link-sdk`) - react-native-quick-sqlite (from `../node_modules/react-native-quick-sqlite`) + - react-native-release-profiler (from `../node_modules/react-native-release-profiler`) - react-native-render-html (from `../node_modules/react-native-render-html`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-view-shot (from `../node_modules/react-native-view-shot`) @@ -1591,6 +1598,7 @@ DEPENDENCIES: - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) + - RNShare (from `../node_modules/react-native-share`) - RNSound (from `../node_modules/react-native-sound`) - RNSVG (from `../node_modules/react-native-svg`) - VisionCamera (from `../node_modules/react-native-vision-camera`) @@ -1751,6 +1759,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-plaid-link-sdk" react-native-quick-sqlite: :path: "../node_modules/react-native-quick-sqlite" + react-native-release-profiler: + :path: "../node_modules/react-native-release-profiler" react-native-render-html: :path: "../node_modules/react-native-render-html" react-native-safe-area-context: @@ -1841,6 +1851,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-reanimated" RNScreens: :path: "../node_modules/react-native-screens" + RNShare: + :path: "../node_modules/react-native-share" RNSound: :path: "../node_modules/react-native-sound" RNSVG: @@ -1945,6 +1957,7 @@ SPEC CHECKSUMS: react-native-performance: cef2b618d47b277fb5c3280b81a3aad1e72f2886 react-native-plaid-link-sdk: df1618a85a615d62ff34e34b76abb7a56497fbc1 react-native-quick-sqlite: bcc7a7a250a40222f18913a97cd356bf82d0a6c4 + react-native-release-profiler: 86f2004d5f8c4fff17d90a5580513519a685d7ae react-native-render-html: 96c979fe7452a0a41559685d2f83b12b93edac8c react-native-safe-area-context: 0ee144a6170530ccc37a0fd9388e28d06f516a89 react-native-view-shot: 6b7ed61d77d88580fed10954d45fad0eb2d47688 @@ -1990,6 +2003,7 @@ SPEC CHECKSUMS: RNReactNativeHapticFeedback: ec56a5f81c3941206fd85625fa669ffc7b4545f9 RNReanimated: 3850671fd0c67051ea8e1e648e8c3e86bf3a28eb RNScreens: b582cb834dc4133307562e930e8fa914b8c04ef2 + RNShare: 859ff710211285676b0bcedd156c12437ea1d564 RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852 RNSVG: ba3e7232f45e34b7b47e74472386cf4e1a676d0a SDWebImage: 750adf017a315a280c60fde706ab1e552a3ae4e9 diff --git a/jest/setup.ts b/jest/setup.ts index 11b0d77ed7ac..488e3e36a1d3 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -47,3 +47,7 @@ jest.mock('react-native-sound', () => { return SoundMock; }); + +jest.mock('react-native-share', () => ({ + default: jest.fn(), +})); diff --git a/package-lock.json b/package-lock.json index 0f11dc0a485a..21edda824bdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -107,9 +107,11 @@ "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", "react-native-reanimated": "^3.7.2", + "react-native-release-profiler": "^0.1.6", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.8.2", "react-native-screens": "3.29.0", + "react-native-share": "^10.0.2", "react-native-sound": "^0.11.2", "react-native-svg": "14.1.0", "react-native-tab-view": "^3.5.2", @@ -44937,6 +44939,33 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/react-native-release-profiler": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/react-native-release-profiler/-/react-native-release-profiler-0.1.6.tgz", + "integrity": "sha512-kSAPYjO3PDzV4xbjgj2NoiHtL7EaXmBira/WOcyz6S7mz1MVBoF0Bj74z5jAZo6BoBJRKqmQWI4ep+m0xvoF+g==", + "dependencies": { + "@react-native-community/cli": "^12.2.1", + "commander": "^11.1.0" + }, + "bin": { + "react-native-release-profiler": "lib/commonjs/cli.js" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-release-profiler/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "engines": { + "node": ">=16" + } + }, "node_modules/react-native-render-html": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/react-native-render-html/-/react-native-render-html-6.3.1.tgz", @@ -45006,6 +45035,14 @@ "react-native": "*" } }, + "node_modules/react-native-share": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-10.0.2.tgz", + "integrity": "sha512-EZs4MtsyauAI1zP8xXT1hIFB/pXOZJNDCKcgCpEfTZFXgCUzz8MDVbI1ocP2hA59XHRSkqAQdbJ0BFTpjxOBlg==", + "engines": { + "node": ">=16" + } + }, "node_modules/react-native-sound": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/react-native-sound/-/react-native-sound-0.11.2.tgz", diff --git a/package.json b/package.json index 46e41b04ab2c..9f1342eed614 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", + "symbolicate-release:ios": "scripts/release-profile.js --platform=ios", + "symbolicate-release:android": "scripts/release-profile.js --platform=android", "test:e2e": "ts-node tests/e2e/testRunner.js --config ./config.local.ts", "test:e2e:dev": "ts-node tests/e2e/testRunner.js --config ./config.dev.js", "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", @@ -155,10 +157,12 @@ "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", + "react-native-release-profiler": "^0.1.6", "react-native-reanimated": "^3.7.2", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.8.2", "react-native-screens": "3.29.0", + "react-native-share": "^10.0.2", "react-native-sound": "^0.11.2", "react-native-svg": "14.1.0", "react-native-tab-view": "^3.5.2", diff --git a/scripts/release-profile.js b/scripts/release-profile.js new file mode 100755 index 000000000000..0f96232bcdca --- /dev/null +++ b/scripts/release-profile.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +const fs = require('fs'); +const {execSync} = require('child_process'); + +// Function to parse command-line arguments into a key-value object +function parseCommandLineArguments() { + const args = process.argv.slice(2); // Skip node and script paths + const argsMap = {}; + args.forEach((arg) => { + const [key, value] = arg.split('='); + if (key.startsWith('--')) { + argsMap[key.substring(2)] = value; + } + }); + return argsMap; +} + +// Function to find .cpuprofile files in the current directory +function findCpuProfileFiles() { + const files = fs.readdirSync(process.cwd()); + // eslint-disable-next-line rulesdir/prefer-underscore-method + return files.filter((file) => file.endsWith('.cpuprofile')); +} + +const argsMap = parseCommandLineArguments(); + +// Determine sourcemapPath based on the platform flag passed +let sourcemapPath; +if (argsMap.platform === 'ios') { + sourcemapPath = 'main.jsbundle.map'; +} else if (argsMap.platform === 'android') { + sourcemapPath = 'android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map'; +} else { + console.error('Please specify the platform using --platform=ios or --platform=android'); + process.exit(1); +} + +// Attempt to find .cpuprofile files +const cpuProfiles = findCpuProfileFiles(); +if (cpuProfiles.length === 0) { + console.error('No .cpuprofile files found in the root directory.'); + process.exit(1); +} else if (cpuProfiles.length > 1) { + console.error('Multiple .cpuprofile files found. Please specify which one to use by placing only one .cpuprofile in the root or specifying the filename as an argument.'); + process.exit(1); +} else { + // Construct the command + const cpuprofileName = cpuProfiles[0]; + const command = `npx react-native-release-profiler --local ${cpuprofileName} --sourcemap-path ${sourcemapPath}`; + + console.log(`Executing: ${command}`); + + // Execute the command + try { + const output = execSync(command, {stdio: 'inherit'}); + console.log(output.toString()); + } catch (error) { + console.error(`Error executing command: ${error}`); + process.exit(1); + } +} diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index bb1766f40e1f..371bd94e6225 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -216,6 +216,9 @@ const ONYXKEYS = { /** Is the test tools modal open? */ IS_TEST_TOOLS_MODAL_OPEN: 'isTestToolsModalOpen', + /** Is app in profiling mode */ + APP_PROFILING_IN_PROGRESS: 'isProfilingInProgress', + /** Stores information about active wallet transfer amount, selectedAccountID, status, etc */ WALLET_TRANSFER: 'walletTransfer', @@ -553,6 +556,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_PAYMENT_METHODS]: boolean; [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_TEST_TOOLS_MODAL_OPEN]: boolean; + [ONYXKEYS.APP_PROFILING_IN_PROGRESS]: boolean; [ONYXKEYS.IS_LOADING_APP]: boolean; [ONYXKEYS.IS_SWITCHING_TO_OLD_DOT]: boolean; [ONYXKEYS.WALLET_TRANSFER]: OnyxTypes.WalletTransfer; diff --git a/src/components/ProfilingToolMenu/index.native.tsx b/src/components/ProfilingToolMenu/index.native.tsx new file mode 100644 index 000000000000..e6a89a317ac7 --- /dev/null +++ b/src/components/ProfilingToolMenu/index.native.tsx @@ -0,0 +1,179 @@ +import React, {useCallback, useEffect, useState} from 'react'; +import DeviceInfo from 'react-native-device-info'; +import RNFS from 'react-native-fs'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import {startProfiling, stopProfiling} from 'react-native-release-profiler'; +import Share from 'react-native-share'; +import Button from '@components/Button'; +import Switch from '@components/Switch'; +import TestToolRow from '@components/TestToolRow'; +import Text from '@components/Text'; +import useThemeStyles from '@hooks/useThemeStyles'; +import toggleProfileTool from '@libs/actions/ProfilingTool'; +import getPlatform from '@libs/getPlatform'; +import Log from '@libs/Log'; +import CONFIG from '@src/CONFIG'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import pkg from '../../../package.json'; + +type ProfilingToolMenuOnyxProps = { + isProfilingInProgress: OnyxEntry; +}; + +type ProfilingToolMenuProps = ProfilingToolMenuOnyxProps; + +function formatBytes(bytes: number, decimals = 2) { + if (!+bytes) { + return '0 Bytes'; + } + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KiB', 'MiB', 'GiB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; +} + +function ProfilingToolMenu({isProfilingInProgress = false}: ProfilingToolMenuProps) { + const styles = useThemeStyles(); + const [pathIOS, setPathIOS] = useState(''); + const [sharePath, setSharePath] = useState(''); + const [totalMemory, setTotalMemory] = useState(0); + const [usedMemory, setUsedMemory] = useState(0); + + // eslint-disable-next-line @lwc/lwc/no-async-await + const stop = useCallback(async () => { + const path = await stopProfiling(getPlatform() === CONST.PLATFORM.IOS); + setPathIOS(path); + + const amountOfTotalMemory = await DeviceInfo.getTotalMemory(); + const amountOfUsedMemory = await DeviceInfo.getUsedMemory(); + setTotalMemory(amountOfTotalMemory); + setUsedMemory(amountOfUsedMemory); + }, []); + + const onToggleProfiling = useCallback(() => { + const shouldProfiling = !isProfilingInProgress; + if (shouldProfiling) { + startProfiling(); + } else { + stop(); + } + toggleProfileTool(); + return () => { + stop(); + }; + }, [isProfilingInProgress, stop]); + + const getAppInfo = useCallback( + () => + JSON.stringify({ + appVersion: pkg.version, + environment: CONFIG.ENVIRONMENT, + platform: getPlatform(), + totalMemory: formatBytes(totalMemory, 2), + usedMemory: formatBytes(usedMemory, 2), + }), + [totalMemory, usedMemory], + ); + + useEffect(() => { + if (!pathIOS) { + return; + } + + // eslint-disable-next-line @lwc/lwc/no-async-await + const rename = async () => { + const newFileName = `Profile_trace_for_${pkg.version}.cpuprofile`; + const newFilePath = `${RNFS.DocumentDirectoryPath}/${newFileName}`; + + try { + const fileExists = await RNFS.exists(newFilePath); + if (fileExists) { + await RNFS.unlink(newFilePath); + Log.hmmm('[ProfilingToolMenu] existing file deleted successfully'); + } + } catch (error) { + const typedError = error as Error; + Log.hmmm('[ProfilingToolMenu] error checking/deleting existing file: ', typedError.message); + } + + // Copy the file to a new location with the desired filename + await RNFS.copyFile(pathIOS, newFilePath) + .then(() => { + Log.hmmm('[ProfilingToolMenu] file copied successfully'); + }) + .catch((error) => { + Log.hmmm('[ProfilingToolMenu] error copying file: ', error); + }); + + setSharePath(newFilePath); + }; + + rename(); + }, [pathIOS]); + + const onDownloadProfiling = useCallback(() => { + // eslint-disable-next-line @lwc/lwc/no-async-await + const shareFiles = async () => { + try { + // Define new filename and path for the app info file + const infoFileName = `App_Info_${pkg.version}.json`; + const infoFilePath = `${RNFS.DocumentDirectoryPath}/${infoFileName}`; + const actualInfoFile = `file://${infoFilePath}`; + + await RNFS.writeFile(infoFilePath, getAppInfo(), 'utf8'); + + const shareOptions = { + urls: [`file://${sharePath}`, actualInfoFile], + }; + + await Share.open(shareOptions); + } catch (error) { + console.error('Error renaming and sharing file:', error); + } + }; + shareFiles(); + }, [getAppInfo, sharePath]); + + return ( + <> + + Release options + + + + + + {!!pathIOS && `path: ${pathIOS}`} + {!!pathIOS && ( + +