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: script for downloading sourcemaps + symbolicating them #43894

Merged
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
397ada7
feat: script for getting uploaded sourcemaps
hannojg Jun 18, 2024
639d89b
finish implementation of download source map step
hannojg Jun 18, 2024
758a861
symbolicate
hannojg Jun 18, 2024
58588b9
fix help command + lint
hannojg Jun 18, 2024
a892234
lint
hannojg Jun 18, 2024
1f48622
fix bugs
hannojg Jun 18, 2024
9fa77dd
simplify fetching artifact by using artifact name
hannojg Jun 19, 2024
a9a31cb
run prettier
hannojg Jun 19, 2024
2ca85c6
Update scripts/symbolicate-profile.ts
hannojg Jun 19, 2024
7016406
Update src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx
hannojg Jun 19, 2024
cf70af8
rename artefact -> artifact
hannojg Jun 20, 2024
4d939c0
Merge branch 'feat/script-for-symbolicating-app-profiles' of github.c…
hannojg Jun 20, 2024
95bdfa8
improve GithubUtils.getArtifactByName to not paginate through all art…
hannojg Jun 20, 2024
e445528
refactor to allow for setup with token + add getArtifactDownloadURL h…
hannojg Jun 20, 2024
9577da0
allow async/await in scripts
hannojg Jun 20, 2024
6896ff1
use GithubUtils
hannojg Jun 20, 2024
33369b9
re=compile gh actions
hannojg Jun 20, 2024
c27a4d2
comment
hannojg Jun 20, 2024
765a77f
setup tsconfig-paths + tsconfig using it in ./scripts to use paths
hannojg Jun 20, 2024
e3229b2
run prettier
hannojg Jun 20, 2024
0aae6f4
use region comments
hannojg Jun 20, 2024
2a717f6
make script executable from any location within the project
hannojg Jun 20, 2024
d35947b
remove unwanted changes
hannojg Jun 20, 2024
742d9d7
ran gh-actions-build
hannojg Jun 20, 2024
eb165d8
add EOF
hannojg Jun 20, 2024
0849924
fix parameter name spelling
hannojg Jun 20, 2024
2942479
jsdoc comment
hannojg Jun 20, 2024
4b70c73
ran gh-actions-build
hannojg Jun 20, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,6 @@ config/webpack/*.pem
.expo
dist/
web-build/

# Storage location for downloaded app source maps (see scripts/symbolicate-profile.ts)
.sourcemaps/
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"symbolicate:ios": "npx metro-symbolicate main.jsbundle.map",
"symbolicate-release:ios": "scripts/release-profile.ts --platform=ios",
"symbolicate-release:android": "scripts/release-profile.ts --platform=android",
"symbolicate-profile": "scripts/symbolicate-profile.ts",
"test:e2e": "ts-node tests/e2e/testRunner.ts --config ./config.local.ts",
"test:e2e:dev": "ts-node tests/e2e/testRunner.ts --config ./config.dev.ts",
"gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh",
Expand Down
16 changes: 1 addition & 15 deletions scripts/release-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,7 @@
/* eslint-disable no-console */
import {execSync} from 'child_process';
import fs from 'fs';

type ArgsMap = Record<string, string>;

// Function to parse command-line arguments into a key-value object
function parseCommandLineArguments(): ArgsMap {
const args = process.argv.slice(2); // Skip node and script paths
const argsMap: ArgsMap = {};
args.forEach((arg) => {
const [key, value] = arg.split('=');
if (key.startsWith('--')) {
argsMap[key.substring(2)] = value;
}
});
return argsMap;
}
import parseCommandLineArguments from './utils/parseCommandLineArguments';

// Function to find .cpuprofile files in the current directory
function findCpuProfileFiles() {
Expand Down
208 changes: 208 additions & 0 deletions scripts/symbolicate-profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#!/usr/bin/env ts-node

/* eslint-disable @typescript-eslint/naming-convention */

/**
* This script helps to symbolicate a .cpuprofile file that was obtained from a specific (staging) app version (usually provided by a user using the app).
*
* @abstract
*
* 1. When creating a new deployment in our github actions, we upload the source map for android and iOS as artifacts.
* 2. The profiles created by the app on the user's device have the app version encoded in the filename.
* 3. This script takes in a .cpuprofile file, reads the app version from the filename, and downloads the corresponding source map from the artifacts using github's API.
* 4. It then uses the source map to symbolicate the .cpuprofile file using the `react-native-release-profiler` cli.
*
* @note For downloading an artefact a github token is required.
*/
import {Octokit} from '@octokit/core';
import {execSync} from 'child_process';
import fs from 'fs';
import https from 'https';
import path from 'path';
import * as Logger from './utils/Logger';
import parseCommandLineArguments from './utils/parseCommandLineArguments';

const argsMap = parseCommandLineArguments();

// #region Input validation
if (Object.keys(argsMap).length === 0 || argsMap.help !== undefined) {
Logger.log('Symbolicates a .cpuprofile file obtained from a specific app version by downloading the source map from the github action runs.');
Logger.log('Usage: npm run symbolicate-profile -- --profile=<filename> --platform=<ios|android>');
Logger.log('Options:');
Logger.log(' --profile=<filename> The .cpuprofile file to symbolicate');
Logger.log(' --platform=<ios|android> The platform for which the source map was uploaded');
Logger.log(' --gh-token Token to use for requests send to the GitHub API. By default tries to pick up from the environment variable GITHUB_TOKEN');
Logger.log(' --help Display this help message');
process.exit(0);
}

if (argsMap.profile === undefined) {
Logger.error('Please specify the .cpuprofile file to symbolicate using --profile=<filename>');
process.exit(1);
}
if (!fs.existsSync(argsMap.profile)) {
Logger.error(`File ${argsMap.profile} does not exist.`);
process.exit(1);
}

if (argsMap.platform === undefined) {
Logger.error('Please specify the platform using --platform=ios or --platform=android');
process.exit(1);
}

const githubToken = argsMap.ghToken ?? process.env.GITHUB_TOKEN;
if (githubToken === undefined) {
Logger.error('No GitHub token provided. Either set a GITHUB_TOKEN environment variable or pass it using --gh-token');
process.exit(1);
}
// #endregion

// #region Get the app version

// Formatted as "Profile_trace_for_1.4.81-9.cpuprofile"
const appVersionRegex = /\d+\.\d+\.\d+(-\d+)?/;
const appVersion = argsMap.profile.match(appVersionRegex)?.[0];
if (appVersion === undefined) {
Logger.error('Could not extract the app version from the profile filename.');
process.exit(1);
}
Logger.info(`Found app version ${appVersion} in the profile filename`);
// #endregion

// #region Utility functions
// We need the token for the download step
const octokit = new Octokit({auth: githubToken});
const OWNER = 'Expensify';
const REPO = 'App';

function getWorkflowRunArtifact() {
const artefactName = `${argsMap.platform}-sourcemap-${appVersion}`;
Logger.info(`Fetching sourcemap artifact with name "${artefactName}"`);
return octokit
.request('GET /repos/{owner}/{repo}/actions/artifacts', {
owner: OWNER,
repo: REPO,
per_page: 1,
name: artefactName,
})
.then((artifactsResponse) => {
const artifact = artifactsResponse.data.artifacts[0];
if (artifact === undefined) {
throw new Error(`Could not find the artifact ${artefactName}!`);
}
return artifact.id;
})
.catch((error) => {
Logger.error('Failed to get artifact!');
Logger.error(error);
throw error;
});
}

function getDownloadUrl(artifactId: number) {
// https://docs.github.com/en/rest/actions/artifacts?apiVersion=2022-11-28#download-an-artifact
// Gets a redirect URL to download an archive for a repository. This URL expires after 1 minute.
// Look for Location: in the response header to find the URL for the download.

Logger.log(`Getting download URL for artifact…`);
return octokit
.request('GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format}', {
owner: OWNER,
repo: REPO,
artifact_id: artifactId,
archive_format: 'zip',
})
.then((response) => {
// The response should be a redirect to the actual download URL
const downloadUrl = response.url;

if (downloadUrl === undefined) {
throw new Error(`Could not find the download URL in:\n${JSON.stringify(response, null, 2)}`);
}

return downloadUrl;
})
.catch((error) => {
Logger.error('Failed to download artifact!');
Logger.error(error);
throw error;
});
}

const dirName = '.sourcemaps';
const sourcemapDir = path.join(process.cwd(), dirName);

function downloadFile(url: string) {
Logger.log(`Downloading file from URL: ${url}`);
if (!fs.existsSync(sourcemapDir)) {
Logger.info(`Creating download directory ${sourcemapDir}`);
fs.mkdirSync(sourcemapDir);
}

const destination = path.join(sourcemapDir, `${argsMap.platform}-sourcemap-${appVersion}.zip`);
const file = fs.createWriteStream(destination);
return new Promise<string>((resolve, reject) => {
https
.get(url, (response) => {
response.pipe(file);
file.on('finish', () => {
file.close();
Logger.success(`Downloaded file to ${destination}`);
resolve(destination);
});
})
.on('error', (error) => {
fs.unlink(destination, () => {
reject(error);
});
});
});
}

function unpackZipFile(zipPath: string) {
Logger.info(`Unpacking file ${zipPath}`);
const command = `unzip -o ${zipPath} -d ${sourcemapDir}`;
execSync(command, {stdio: 'inherit'});
Logger.info(`Deleting zip file ${zipPath}`);
return new Promise<void>((resolve, reject) => {
fs.unlink(zipPath, (error) => (error ? reject(error) : resolve()));
});
}

const localSourceMapPath = path.join(sourcemapDir, `${appVersion}-${argsMap.platform}.map`);
function renameDownloadedSourcemapFile() {
const androidName = 'index.android.bundle.map';
const iosName = 'main.jsbundle.map';
const downloadSourcemapPath = path.join(sourcemapDir, argsMap.platform === 'ios' ? iosName : androidName);

if (!fs.existsSync(downloadSourcemapPath)) {
Logger.error(`Could not find the sourcemap file ${downloadSourcemapPath}`);
process.exit(1);
}

Logger.info(`Renaming sourcemap file to ${localSourceMapPath}`);
fs.renameSync(downloadSourcemapPath, localSourceMapPath);
}

// Symbolicate using the downloaded source map
function symbolicateProfile() {
const command = `npx react-native-release-profiler --local ${argsMap.profile} --sourcemap-path ${localSourceMapPath}`;
execSync(command, {stdio: 'inherit'});
}

// #endregion

// Step: check if source map locally already exists (if so we can skip the download)
if (fs.existsSync(localSourceMapPath)) {
Logger.success(`Found local source map at ${localSourceMapPath}`);
Logger.info('Skipping download step');
symbolicateProfile();
} else {
// Step: Download the source map for the app version:
getWorkflowRunArtifact()
.then((artifactId) => getDownloadUrl(artifactId))
.then((downloadUrl) => downloadFile(downloadUrl))
.then((zipPath) => unpackZipFile(zipPath))
.then(() => renameDownloadedSourcemapFile())
.then(() => symbolicateProfile());
}
37 changes: 37 additions & 0 deletions scripts/utils/Logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const COLOR_DIM = '\x1b[2m';
const COLOR_RESET = '\x1b[0m';
const COLOR_YELLOW = '\x1b[33m';
const COLOR_RED = '\x1b[31m';
const COLOR_GREEN = '\x1b[32m';

const log = (...args: unknown[]) => {
console.debug(...args);
};

const info = (...args: unknown[]) => {
log('▶️', ...args);
};

const success = (...args: unknown[]) => {
const lines = ['✅', COLOR_GREEN, ...args, COLOR_RESET];
log(...lines);
};

const warn = (...args: unknown[]) => {
const lines = ['⚠️', COLOR_YELLOW, ...args, COLOR_RESET];
log(...lines);
};

const note = (...args: unknown[]) => {
const lines = [COLOR_DIM, ...args, COLOR_RESET];
log(...lines);
};

const error = (...args: unknown[]) => {
const lines = ['🔴', COLOR_RED, ...args, COLOR_RESET];
log(...lines);
};

const formatLink = (name: string | number, url: string) => `\x1b]8;;${url}\x1b\\${name}\x1b]8;;\x1b\\`;

export {log, info, warn, note, error, success, formatLink};
19 changes: 19 additions & 0 deletions scripts/utils/parseCommandLineArguments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
type ArgsMap = Record<string, string | undefined>;

// Function to parse command-line arguments into a key-value object
export default function parseCommandLineArguments(): ArgsMap {
const args = process.argv.slice(2); // Skip node and script paths
const argsMap: ArgsMap = {};
args.forEach((arg) => {
const [key, value] = arg.split('=');
if (key.startsWith('--')) {
const name = key.substring(2);
argsMap[name] = value;
// User may provide a help arg without any value
if (name.toLowerCase() === 'help' && !value) {
argsMap[name] = 'true';
}
}
});
return argsMap;
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function formatBytes(bytes: number, decimals = 2) {
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}

// NOTE: When changing this name make sure that the "scripts/symbolicate-profile.ts" script is still working!
const newFileName = `Profile_trace_for_${pkg.version}.cpuprofile`;

function BaseProfilingToolMenu({isProfilingInProgress = false, pathToBeUsed, displayPath}: BaseProfilingToolMenuProps) {
Expand Down
Loading