diff --git a/.craft.yml b/.craft.yml index 9022463820..9f7b168af8 100644 --- a/.craft.yml +++ b/.craft.yml @@ -8,3 +8,5 @@ targets: sdks: npm:@sentry/react-native: includeNames: /^sentry-react-native-\d.*\.tgz$/ + npm:@sentry/expo-upload-sourcemaps: + includeNames: /^sentry-expo-upload-sourcemaps-\d.*\.tgz$/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bd9a8a469a..f206090b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Features +- Add new `@sentry/expo-upload-sourcemaps` package for uploading JavaScript bundles and source maps from Expo builds to Sentry ([#6027](https://github.com/getsentry/sentry-react-native/pull/6027)) + - The existing `sentry-expo-upload-sourcemaps` bin bundled with `@sentry/react-native` is superseded by the new package; it continues to work unchanged for now - Expose scope-level attributes API (`setAttribute`, `setAttributes`, `removeAttribute`) bridging to native SDKs ([#6009](https://github.com/getsentry/sentry-react-native/pull/6009)) - Expose screenshot masking options (`screenshot.maskAllText`, `screenshot.maskAllImages`, `screenshot.maskedViewClasses`, `screenshot.unmaskedViewClasses`) for error screenshots ([#6007](https://github.com/getsentry/sentry-react-native/pull/6007)) - Warn Expo users at Metro startup when prebuilt native projects are missing Sentry configuration ([#5984](https://github.com/getsentry/sentry-react-native/pull/5984)) diff --git a/dev-packages/e2e-tests/cli.mjs b/dev-packages/e2e-tests/cli.mjs index fa111b001f..44b9dae1d2 100755 --- a/dev-packages/e2e-tests/cli.mjs +++ b/dev-packages/e2e-tests/cli.mjs @@ -55,6 +55,7 @@ const e2eTestPackageName = JSON.parse(fs.readFileSync(`${e2eDir}/package.json`, const patchScriptsDir = path.resolve(e2eDir, 'patch-scripts'); const workspaceRootDir = path.resolve(__dirname, '../..'); const corePackageDir = path.resolve(workspaceRootDir, 'packages/core'); +const expoUploadSourcemapsPackageDir = path.resolve(workspaceRootDir, 'packages/expo-upload-sourcemaps'); const corePackageJson = JSON.parse(fs.readFileSync(`${corePackageDir}/package.json`, 'utf8')); const RNVersion = env.RN_VERSION ? env.RN_VERSION : corePackageJson.devDependencies['react-native']; const RNEngine = env.RN_ENGINE ? env.RN_ENGINE : 'hermes'; @@ -109,6 +110,7 @@ function patchBoostIfNeeded(rnVersion, patchScriptsDir) { if (actions.includes('create') || (env.CI === undefined && actions.includes('build'))) { execSync(`yarn build`, { stdio: 'inherit', cwd: workspaceRootDir, env: env }); execSync(`yalc publish --private`, { stdio: 'inherit', cwd: e2eDir, env: env }); + execSync(`yalc publish`, { stdio: 'inherit', cwd: expoUploadSourcemapsPackageDir, env: env }); execSync(`yalc publish`, { stdio: 'inherit', cwd: corePackageDir, env: env }); } @@ -122,7 +124,14 @@ if (actions.includes('create')) { // Install dependencies // yalc add doesn't fail if the package is not found - it skips silently. - let yalcAddOutput = execSync(`yalc add @sentry/react-native`, { cwd: appDir, env: env, encoding: 'utf-8' }); + let yalcAddOutput = execSync(`yalc add @sentry/expo-upload-sourcemaps`, { cwd: appDir, env: env, encoding: 'utf-8' }); + if (!yalcAddOutput.match(/Package .* added ==>/)) { + console.error(yalcAddOutput); + process.exit(1); + } else { + console.log(yalcAddOutput.trim()); + } + yalcAddOutput = execSync(`yalc add @sentry/react-native`, { cwd: appDir, env: env, encoding: 'utf-8' }); if (!yalcAddOutput.match(/Package .* added ==>/)) { console.error(yalcAddOutput); process.exit(1); @@ -137,6 +146,19 @@ if (actions.includes('create')) { console.log(yalcAddOutput.trim()); } + // Force yarn to resolve the transitive @sentry/expo-upload-sourcemaps dep + // to the local yalc copy. Without this, yarn tries to fetch it from the + // npm registry (because yalc rewrites the workspace:* spec in core's + // published package.json to a concrete version) and 404s until the package + // is released. + const appPackageJsonPath = `${appDir}/package.json`; + const appPackageJson = JSON.parse(fs.readFileSync(appPackageJsonPath, 'utf-8')); + appPackageJson.resolutions = { + ...appPackageJson.resolutions, + '@sentry/expo-upload-sourcemaps': 'file:.yalc/@sentry/expo-upload-sourcemaps', + }; + fs.writeFileSync(appPackageJsonPath, JSON.stringify(appPackageJson, null, 2) + '\n'); + // original yarnrc contains the exact yarn version which causes corepack to fail to install yarn v3 fs.writeFileSync(`${appDir}/.yarnrc.yml`, 'nodeLinker: node-modules', { encoding: 'utf-8' }); // yarn v3 won't install dependencies in a sub project without a yarn.lock file present diff --git a/dev-packages/type-check/run-type-check.sh b/dev-packages/type-check/run-type-check.sh index 71920eff8a..ddfe25d699 100755 --- a/dev-packages/type-check/run-type-check.sh +++ b/dev-packages/type-check/run-type-check.sh @@ -4,18 +4,24 @@ set -ex __dirpath=$(dirname $(realpath "$0")) +cd "${__dirpath}/../../packages/expo-upload-sourcemaps" + +yalc publish + cd "${__dirpath}/../../packages/core" yalc publish cd "${__dirpath}/ts3.8-test" -# Add yalc package (creates .yalc/ directory and updates package.json) +# Add yalc packages (creates .yalc/ directory and updates package.json) +yalc add @sentry/expo-upload-sourcemaps yalc add @sentry/react-native yarn install -# Re-add yalc package to ensure it's in node_modules (yarn might have removed it) +# Re-add yalc packages to ensure they are in node_modules (yarn might have removed them) +yalc add @sentry/expo-upload-sourcemaps yalc add @sentry/react-native echo "Removing duplicate React types..." diff --git a/dev-packages/type-check/ts3.8-test/package.json b/dev-packages/type-check/ts3.8-test/package.json index daf8ea0be0..f718ddf707 100644 --- a/dev-packages/type-check/ts3.8-test/package.json +++ b/dev-packages/type-check/ts3.8-test/package.json @@ -14,8 +14,12 @@ "typescript": "3.8.3" }, "dependencies": { + "@sentry/expo-upload-sourcemaps": "file:.yalc/@sentry/expo-upload-sourcemaps", "@sentry/react-native": "file:.yalc/@sentry/react-native", "react": "17.0.2", "react-native": "0.65.3" + }, + "resolutions": { + "@sentry/expo-upload-sourcemaps": "file:.yalc/@sentry/expo-upload-sourcemaps" } } diff --git a/package.json b/package.json index 355756ef3f..fd8a1bb263 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ }, "workspaces": [ "packages/core", + "packages/expo-upload-sourcemaps", "dev-packages/e2e-tests", "dev-packages/type-check", "dev-packages/utils", diff --git a/packages/core/package.json b/packages/core/package.json index 3658aabdb7..d820f5fc3a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -75,6 +75,7 @@ "@sentry/browser": "10.49.0", "@sentry/cli": "3.4.0", "@sentry/core": "10.49.0", + "@sentry/expo-upload-sourcemaps": "workspace:*", "@sentry/react": "10.49.0", "@sentry/types": "10.49.0" }, diff --git a/packages/core/scripts/expo-upload-sourcemaps.js b/packages/core/scripts/expo-upload-sourcemaps.js index e7953b2f70..02e42dcf55 100755 --- a/packages/core/scripts/expo-upload-sourcemaps.js +++ b/packages/core/scripts/expo-upload-sourcemaps.js @@ -1,311 +1,13 @@ #!/usr/bin/env node -const { spawnSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const process = require('process'); - -const SENTRY_URL = 'SENTRY_URL'; -const SENTRY_ORG = 'SENTRY_ORG'; -const SENTRY_PROJECT = 'SENTRY_PROJECT'; -const SENTRY_AUTH_TOKEN = 'SENTRY_AUTH_TOKEN'; -const SENTRY_CLI_EXECUTABLE = 'SENTRY_CLI_EXECUTABLE'; - -function getEnvVar(varname) { - return process.env[varname]; -} - -function getSentryPluginPropertiesFromExpoConfig() { - try { - const result = spawnSync('npx', ['expo', 'config', '--json'], { encoding: 'utf8' }); - if (result.error || result.status !== 0) { - throw result.error || new Error(`expo config exited with status ${result.status}`); - } - const config = JSON.parse(result.stdout); - const plugins = config.plugins || []; - const sentryPlugin = plugins.find(plugin => { - if (!Array.isArray(plugin) || plugin.length < 2) { - return false; - } - const [pluginName] = plugin; - return pluginName === '@sentry/react-native/expo'; - }); - - if (sentryPlugin) { - const [, pluginConfig] = sentryPlugin; - return pluginConfig; - } - - // When withSentry is used programmatically in app.config.ts, the plugin - // doesn't appear in the plugins array. Check config._internal where the - // plugin stashes build-time properties as a fallback. - if (config._internal?.sentryBuildProperties) { - return config._internal.sentryBuildProperties; - } - - return null; - } catch (error) { - console.error('Error fetching expo config:', error); - return null; - } -} - -function getSentryPropertiesFromFile() { - const candidates = [ - path.join(projectRoot, 'android', 'sentry.properties'), - path.join(projectRoot, 'ios', 'sentry.properties'), - ]; - for (const candidate of candidates) { - if (!fs.existsSync(candidate)) { - continue; - } - try { - const content = fs.readFileSync(candidate, 'utf8'); - const props = {}; - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { - continue; - } - const eqIndex = trimmed.indexOf('='); - if (eqIndex === -1) { - continue; - } - const key = trimmed.substring(0, eqIndex).trim(); - const value = trimmed.substring(eqIndex + 1).trim(); - if (key === 'defaults.org') { - props.organization = value; - } else if (key === 'defaults.project') { - props.project = value; - } else if (key === 'defaults.url') { - props.url = value; - } - } - if (props.organization || props.project) { - console.log(`Found sentry properties in ${candidate}`); - return props; - } - } catch (_e) { - // continue to next candidate - } - } - return null; -} - -function readAndPrintJSONFile(filePath) { - if (!fs.existsSync(filePath)) { - throw new Error(`The file "${filePath}" does not exist.`); - } - try { - const data = fs.readFileSync(filePath, 'utf8'); - return JSON.parse(data); - } catch (err) { - console.error('Error reading or parsing JSON file:', err); - throw err; - } -} - -function writeJSONFile(filePath, object) { - // Convert the updated JavaScript object back to a JSON string - const updatedJsonString = JSON.stringify(object, null, 2); - fs.writeFileSync(filePath, updatedJsonString, 'utf8', writeErr => { - if (writeErr) { - console.error('Error writing to the file:', writeErr); - } else { - console.log('File updated successfully.'); - } - }); -} - -function isAsset(filename) { - return filename.endsWith('.map') || filename.endsWith('.js') || filename.endsWith('.hbc'); -} - -function getAssetPathsSync(directory) { - const files = []; - const items = fs.readdirSync(directory, { withFileTypes: true }); - - for (const item of items) { - const fullPath = path.join(directory, item.name); - if (item.isDirectory()) { - files.push(...getAssetPathsSync(fullPath)); - } else if (item.isFile() && isAsset(item.name)) { - files.push(fullPath); - } - } - return files; -} - -function groupAssets(assetPaths) { - const groups = {}; - for (const assetPath of assetPaths) { - const parsedPath = path.parse(assetPath); - const extname = parsedPath.ext; - const assetGroupName = extname === '.map' ? path.join(parsedPath.dir, parsedPath.name) : path.format(parsedPath); - if (!groups[assetGroupName]) { - groups[assetGroupName] = [assetPath]; - } else { - groups[assetGroupName].push(assetPath); - } - } - return groups; -} - -function loadDotenv(dotenvPath) { - try { - const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); - // NOTE: Do not use the dotenv.config API directly to read the dotenv file! For some ungodly reason, it falls back to reading `${process.cwd()}/.env` which is absolutely not what we want. - // dotenv is dependency of @expo/env, so we can just require it here - const dotenvResult = require('dotenv').parse(dotenvFile); - - Object.assign(process.env, dotenvResult); - } catch (error) { - if (error.code === 'ENOENT') { - // noop if file does not exist - } else { - console.warn('⚠️ Failed to load environment variables using dotenv.'); - console.warn(error); - } - } -} - -process.env.NODE_ENV = process.env.NODE_ENV || 'development'; // Ensures precedence .env.development > .env (the same as @expo/cli) -const projectRoot = '.'; // Assume script is run from the project root try { - require('@expo/env').load(projectRoot); -} catch (error) { - console.warn('⚠️ Failed to load environment variables using @expo/env.'); - console.warn(error); -} - -const sentryBuildPluginPath = path.join(projectRoot, '.env.sentry-build-plugin'); -if (fs.existsSync(sentryBuildPluginPath)) { - loadDotenv(sentryBuildPluginPath); -} - -let sentryOrg = getEnvVar(SENTRY_ORG); -let sentryUrl = getEnvVar(SENTRY_URL); -let sentryProject = getEnvVar(SENTRY_PROJECT); -let authToken = getEnvVar(SENTRY_AUTH_TOKEN); -const sentryCliBin = getEnvVar(SENTRY_CLI_EXECUTABLE) || require.resolve('@sentry/cli/bin/sentry-cli'); - -if (!sentryOrg || !sentryProject || !sentryUrl) { - console.log('🐕 Fetching from expo config...'); - let pluginConfig = getSentryPluginPropertiesFromExpoConfig(); - if (!pluginConfig) { - console.log('Could not fetch from expo config, trying sentry.properties files...'); - pluginConfig = getSentryPropertiesFromFile(); - } - if (!pluginConfig) { + require.resolve('@sentry/expo-upload-sourcemaps/cli.js'); +} catch (e) { + if (e && e.code === 'MODULE_NOT_FOUND') { console.error( - "Could not resolve Sentry configuration. Set SENTRY_ORG, SENTRY_PROJECT, and SENTRY_URL environment variables, " + - "or ensure '@sentry/react-native/expo' is in your plugins array in app.json/app.config.ts." + "The '@sentry/expo-upload-sourcemaps' package is missing. Reinstall @sentry/react-native, or invoke `npx @sentry/expo-upload-sourcemaps dist` directly." ); process.exit(1); } - - if (!sentryOrg) { - if (!pluginConfig.organization) { - console.error( - `Could not resolve sentry org, set it in the environment variable ${SENTRY_ORG} or in the '@sentry/react-native' plugin properties in your expo config.`, - ); - process.exit(1); - } - - sentryOrg = pluginConfig.organization; - console.log(`${SENTRY_ORG} resolved to ${sentryOrg} from expo config.`); - } - - if (!sentryProject) { - if (!pluginConfig.project) { - console.error( - `Could not resolve sentry project, set it in the environment variable ${SENTRY_PROJECT} or in the '@sentry/react-native' plugin properties in your expo config.`, - ); - process.exit(1); - } - - sentryProject = pluginConfig.project; - console.log(`${SENTRY_PROJECT} resolved to ${sentryProject} from expo config.`); - } - if (!sentryUrl) { - if (pluginConfig.url) { - sentryUrl = pluginConfig.url; - console.log(`${SENTRY_URL} resolved to ${sentryUrl} from expo config.`); - } - else { - sentryUrl = 'https://sentry.io/'; - console.log( - `Since it wasn't specified in the Expo config or environment variable, ${SENTRY_URL} now points to ${sentryUrl}.` - ); - } - } -} - -if (!authToken) { - console.error(`${SENTRY_AUTH_TOKEN} environment variable must be set.`); - process.exit(1); -} - -const outputDir = process.argv[2]; -if (!outputDir) { - console.error('Provide the directory with your bundles and sourcemaps as the first argument.'); - console.error('Example: node node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps dist'); - process.exit(1); -} - -const files = getAssetPathsSync(outputDir); -const groupedAssets = groupAssets(files); - -const totalAssets = Object.keys(groupedAssets).length; -let numAssetsUploaded = 0; -for (const [assetGroupName, assets] of Object.entries(groupedAssets)) { - const sourceMapPath = assets.find(asset => asset.endsWith('.map')); - if (sourceMapPath) { - const sourceMap = readAndPrintJSONFile(sourceMapPath); - if (sourceMap.debugId) { - sourceMap.debug_id = sourceMap.debugId; - } - writeJSONFile(sourceMapPath, sourceMap); - console.log(`⬆️ Uploading ${assetGroupName} bundle and sourcemap...`); - } else { - console.log(`❓ Sourcemap for ${assetGroupName} not found, skipping...`); - continue; - } - - const isHermes = assets.find(asset => asset.endsWith('.hbc')); - - // Build arguments array for spawnSync (no shell interpretation needed) - const args = ['sourcemaps', 'upload']; - if (isHermes) { - args.push('--debug-id-reference'); - } - args.push(...assets); - - const result = spawnSync(sentryCliBin, args, { - env: { - ...process.env, - [SENTRY_PROJECT]: sentryProject, - [SENTRY_ORG]: sentryOrg, - [SENTRY_URL]: sentryUrl - }, - stdio: 'inherit', - }); - - if (result.error) { - console.error('Failed to upload sourcemaps:', result.error); - process.exit(1); - } - if (result.status !== 0) { - console.error(`sentry-cli exited with status ${result.status}`); - process.exit(result.status); - } - numAssetsUploaded++; -} - -if (numAssetsUploaded === totalAssets) { - console.log('✅ Uploaded bundles and sourcemaps to Sentry successfully.'); -} else { - console.warn( - `⚠️ Uploaded ${numAssetsUploaded} of ${totalAssets} bundles and sourcemaps. ${numAssetsUploaded === 0 ? 'Ensure you are running `expo export` with the `--source-maps` flag.' : '' - }`, - ); + throw e; } +require('@sentry/expo-upload-sourcemaps/cli.js'); diff --git a/packages/expo-upload-sourcemaps/LICENSE.md b/packages/expo-upload-sourcemaps/LICENSE.md new file mode 100644 index 0000000000..f5dca60848 --- /dev/null +++ b/packages/expo-upload-sourcemaps/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017-2024 Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/expo-upload-sourcemaps/README.md b/packages/expo-upload-sourcemaps/README.md new file mode 100644 index 0000000000..7f688fffef --- /dev/null +++ b/packages/expo-upload-sourcemaps/README.md @@ -0,0 +1,54 @@ +

+ + + + + Sentry + + +

+ +# @sentry/expo-upload-sourcemaps + +Command-line tool for uploading JavaScript bundles and source maps from Expo builds to Sentry, so that production error stack traces are symbolicated back to original source. + +## Usage + +```bash +SENTRY_AUTH_TOKEN= \ +npx @sentry/expo-upload-sourcemaps dist +``` + +`dist` is the output directory produced by `npx expo export` or `eas update`. + +## Environment variables + +| Variable | Required | Description | +|---|---|---| +| `SENTRY_AUTH_TOKEN` | yes | Sentry auth token with `project:write` scope. | +| `SENTRY_ORG` | when no Expo plugin config is present | Sentry organization slug. Falls back to the `@sentry/react-native/expo` plugin config in `app.json` / `app.config.ts`, or the `defaults.org` entry in `android/sentry.properties` / `ios/sentry.properties`. | +| `SENTRY_PROJECT` | when no Expo plugin config is present | Sentry project slug. Same fallback order as above. | +| `SENTRY_URL` | optional | Sentry instance URL. Defaults to `https://sentry.io/`. | +| `SENTRY_CLI_EXECUTABLE` | optional | Path override for the `sentry-cli` binary. | + +Environment variables are also read from `.env.sentry-build-plugin` at the project root. + +## Relation to `@sentry/react-native` + +The same CLI is re-exposed inside `@sentry/react-native` under the bin name `sentry-expo-upload-sourcemaps`. Projects that already have `@sentry/react-native` installed can invoke either form: + +```bash +npx @sentry/expo-upload-sourcemaps dist +# or +npx sentry-expo-upload-sourcemaps dist +``` + +The new scoped form is preferred for new setups. + +## Documentation + +See the [Expo source maps guide](https://docs.sentry.io/platforms/react-native/sourcemaps/uploading/expo/) for end-to-end setup. + +## License + +Licensed under the MIT license. See [`LICENSE.md`](./LICENSE.md). diff --git a/packages/expo-upload-sourcemaps/cli.js b/packages/expo-upload-sourcemaps/cli.js new file mode 100755 index 0000000000..28cd4ac3b8 --- /dev/null +++ b/packages/expo-upload-sourcemaps/cli.js @@ -0,0 +1,308 @@ +#!/usr/bin/env node +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const process = require('process'); + +const SENTRY_URL = 'SENTRY_URL'; +const SENTRY_ORG = 'SENTRY_ORG'; +const SENTRY_PROJECT = 'SENTRY_PROJECT'; +const SENTRY_AUTH_TOKEN = 'SENTRY_AUTH_TOKEN'; +const SENTRY_CLI_EXECUTABLE = 'SENTRY_CLI_EXECUTABLE'; + +function getEnvVar(varname) { + return process.env[varname]; +} + +function getSentryPluginPropertiesFromExpoConfig() { + try { + const result = spawnSync('npx', ['expo', 'config', '--json'], { encoding: 'utf8' }); + if (result.error || result.status !== 0) { + throw result.error || new Error(`expo config exited with status ${result.status}`); + } + const config = JSON.parse(result.stdout); + const plugins = config.plugins || []; + const sentryPlugin = plugins.find(plugin => { + if (!Array.isArray(plugin) || plugin.length < 2) { + return false; + } + const [pluginName] = plugin; + return pluginName === '@sentry/react-native/expo'; + }); + + if (sentryPlugin) { + const [, pluginConfig] = sentryPlugin; + return pluginConfig; + } + + // When withSentry is used programmatically in app.config.ts, the plugin + // doesn't appear in the plugins array. Check config._internal where the + // plugin stashes build-time properties as a fallback. + if (config._internal?.sentryBuildProperties) { + return config._internal.sentryBuildProperties; + } + + return null; + } catch (error) { + console.error('Error fetching expo config:', error); + return null; + } +} + +function getSentryPropertiesFromFile() { + const candidates = [ + path.join(projectRoot, 'android', 'sentry.properties'), + path.join(projectRoot, 'ios', 'sentry.properties'), + ]; + for (const candidate of candidates) { + if (!fs.existsSync(candidate)) { + continue; + } + try { + const content = fs.readFileSync(candidate, 'utf8'); + const props = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const eqIndex = trimmed.indexOf('='); + if (eqIndex === -1) { + continue; + } + const key = trimmed.substring(0, eqIndex).trim(); + const value = trimmed.substring(eqIndex + 1).trim(); + if (key === 'defaults.org') { + props.organization = value; + } else if (key === 'defaults.project') { + props.project = value; + } else if (key === 'defaults.url') { + props.url = value; + } + } + if (props.organization || props.project) { + console.log(`Found sentry properties in ${candidate}`); + return props; + } + } catch (_e) { + // continue to next candidate + } + } + return null; +} + +function readAndPrintJSONFile(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error(`The file "${filePath}" does not exist.`); + } + try { + const data = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(data); + } catch (err) { + console.error('Error reading or parsing JSON file:', err); + throw err; + } +} + +function writeJSONFile(filePath, object) { + const updatedJsonString = JSON.stringify(object, null, 2); + fs.writeFileSync(filePath, updatedJsonString, 'utf8'); +} + +function isAsset(filename) { + return filename.endsWith('.map') || filename.endsWith('.js') || filename.endsWith('.hbc'); +} + +function getAssetPathsSync(directory) { + const files = []; + const items = fs.readdirSync(directory, { withFileTypes: true }); + + for (const item of items) { + const fullPath = path.join(directory, item.name); + if (item.isDirectory()) { + files.push(...getAssetPathsSync(fullPath)); + } else if (item.isFile() && isAsset(item.name)) { + files.push(fullPath); + } + } + return files; +} + +function groupAssets(assetPaths) { + const groups = {}; + for (const assetPath of assetPaths) { + const parsedPath = path.parse(assetPath); + const extname = parsedPath.ext; + const assetGroupName = extname === '.map' ? path.join(parsedPath.dir, parsedPath.name) : path.format(parsedPath); + if (!groups[assetGroupName]) { + groups[assetGroupName] = [assetPath]; + } else { + groups[assetGroupName].push(assetPath); + } + } + return groups; +} + +function loadDotenv(dotenvPath) { + try { + const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); + // NOTE: Do not use the dotenv.config API directly to read the dotenv file! For some ungodly reason, it falls back to reading `${process.cwd()}/.env` which is absolutely not what we want. + // dotenv is dependency of @expo/env, so we can just require it here + const dotenvResult = require('dotenv').parse(dotenvFile); + + Object.assign(process.env, dotenvResult); + } catch (error) { + if (error.code === 'ENOENT') { + // noop if file does not exist + } else { + console.warn('⚠️ Failed to load environment variables using dotenv.'); + console.warn(error); + } + } +} + +process.env.NODE_ENV = process.env.NODE_ENV || 'development'; // Ensures precedence .env.development > .env (the same as @expo/cli) +const projectRoot = '.'; // Assume script is run from the project root +try { + require('@expo/env').load(projectRoot); +} catch (error) { + console.warn('⚠️ Failed to load environment variables using @expo/env.'); + console.warn(error); +} + +const sentryBuildPluginPath = path.join(projectRoot, '.env.sentry-build-plugin'); +if (fs.existsSync(sentryBuildPluginPath)) { + loadDotenv(sentryBuildPluginPath); +} + +let sentryOrg = getEnvVar(SENTRY_ORG); +let sentryUrl = getEnvVar(SENTRY_URL); +let sentryProject = getEnvVar(SENTRY_PROJECT); +let authToken = getEnvVar(SENTRY_AUTH_TOKEN); +const sentryCliBin = getEnvVar(SENTRY_CLI_EXECUTABLE) || require.resolve('@sentry/cli/bin/sentry-cli'); + +if (!sentryOrg || !sentryProject || !sentryUrl) { + console.log('🐕 Fetching from expo config...'); + let pluginConfig = getSentryPluginPropertiesFromExpoConfig(); + if (!pluginConfig) { + console.log('Could not fetch from expo config, trying sentry.properties files...'); + pluginConfig = getSentryPropertiesFromFile(); + } + if (!pluginConfig) { + console.error( + "Could not resolve Sentry configuration. Set SENTRY_ORG, SENTRY_PROJECT, and SENTRY_URL environment variables, " + + "or ensure '@sentry/react-native/expo' is in your plugins array in app.json/app.config.ts." + ); + process.exit(1); + } + + if (!sentryOrg) { + if (!pluginConfig.organization) { + console.error( + `Could not resolve sentry org, set it in the environment variable ${SENTRY_ORG} or in the '@sentry/react-native' plugin properties in your expo config.`, + ); + process.exit(1); + } + + sentryOrg = pluginConfig.organization; + console.log(`${SENTRY_ORG} resolved to ${sentryOrg} from expo config.`); + } + + if (!sentryProject) { + if (!pluginConfig.project) { + console.error( + `Could not resolve sentry project, set it in the environment variable ${SENTRY_PROJECT} or in the '@sentry/react-native' plugin properties in your expo config.`, + ); + process.exit(1); + } + + sentryProject = pluginConfig.project; + console.log(`${SENTRY_PROJECT} resolved to ${sentryProject} from expo config.`); + } + if (!sentryUrl) { + if (pluginConfig.url) { + sentryUrl = pluginConfig.url; + console.log(`${SENTRY_URL} resolved to ${sentryUrl} from expo config.`); + } + else { + sentryUrl = 'https://sentry.io/'; + console.log( + `Since it wasn't specified in the Expo config or environment variable, ${SENTRY_URL} now points to ${sentryUrl}.` + ); + } + } +} + +if (!authToken) { + console.error(`${SENTRY_AUTH_TOKEN} environment variable must be set.`); + process.exit(1); +} + +const outputDir = process.argv[2]; +if (!outputDir) { + console.error('Provide the directory with your bundles and sourcemaps as the first argument.'); + console.error('Example: npx @sentry/expo-upload-sourcemaps dist'); + process.exit(1); +} + +const files = getAssetPathsSync(outputDir); +const groupedAssets = groupAssets(files); + +const totalAssets = Object.keys(groupedAssets).length; +let numAssetsUploaded = 0; +for (const [assetGroupName, assets] of Object.entries(groupedAssets)) { + const sourceMapPath = assets.find(asset => asset.endsWith('.map')); + if (sourceMapPath) { + const sourceMap = readAndPrintJSONFile(sourceMapPath); + if (sourceMap.debugId) { + sourceMap.debug_id = sourceMap.debugId; + } + writeJSONFile(sourceMapPath, sourceMap); + console.log(`⬆️ Uploading ${assetGroupName} bundle and sourcemap...`); + } else { + console.log(`❓ Sourcemap for ${assetGroupName} not found, skipping...`); + continue; + } + + const isHermes = assets.find(asset => asset.endsWith('.hbc')); + + // Build arguments array for spawnSync (no shell interpretation needed) + const args = ['sourcemaps', 'upload']; + if (isHermes) { + args.push('--debug-id-reference'); + } + args.push(...assets); + + const result = spawnSync(sentryCliBin, args, { + env: { + ...process.env, + [SENTRY_PROJECT]: sentryProject, + [SENTRY_ORG]: sentryOrg, + [SENTRY_URL]: sentryUrl + }, + stdio: 'inherit', + }); + + if (result.error) { + console.error('Failed to upload sourcemaps:', result.error); + process.exit(1); + } + if (result.status !== 0) { + if (result.signal) { + console.error(`sentry-cli was terminated by signal ${result.signal}`); + } else { + console.error(`sentry-cli exited with status ${result.status}`); + } + process.exit(result.status || 1); + } + numAssetsUploaded++; +} + +if (numAssetsUploaded === totalAssets) { + console.log('✅ Uploaded bundles and sourcemaps to Sentry successfully.'); +} else { + console.warn( + `⚠️ Uploaded ${numAssetsUploaded} of ${totalAssets} bundles and sourcemaps. ${numAssetsUploaded === 0 ? 'Ensure you are running `expo export` with the `--source-maps` flag.' : '' + }`, + ); +} diff --git a/packages/expo-upload-sourcemaps/package.json b/packages/expo-upload-sourcemaps/package.json new file mode 100644 index 0000000000..40cfbd78b3 --- /dev/null +++ b/packages/expo-upload-sourcemaps/package.json @@ -0,0 +1,50 @@ +{ + "name": "@sentry/expo-upload-sourcemaps", + "version": "8.8.0", + "description": "CLI to upload bundles and source maps from Expo builds to Sentry.", + "homepage": "https://github.com/getsentry/sentry-react-native/tree/main/packages/expo-upload-sourcemaps", + "repository": { + "type": "git", + "url": "https://github.com/getsentry/sentry-react-native", + "directory": "packages/expo-upload-sourcemaps" + }, + "license": "MIT", + "author": "Sentry", + "keywords": [ + "sentry", + "expo", + "react-native", + "sourcemaps", + "cli" + ], + "bin": "./cli.js", + "scripts": { + "build:tarball": "bash scripts/build-tarball.sh" + }, + "files": [ + "cli.js", + "LICENSE.md", + "README.md" + ], + "dependencies": { + "@sentry/cli": "3.4.0" + }, + "peerDependencies": { + "@expo/env": "*", + "dotenv": "*" + }, + "peerDependenciesMeta": { + "@expo/env": { + "optional": true + }, + "dotenv": { + "optional": true + } + }, + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/expo-upload-sourcemaps/scripts/build-tarball.sh b/packages/expo-upload-sourcemaps/scripts/build-tarball.sh new file mode 100755 index 0000000000..8f882cd2ff --- /dev/null +++ b/packages/expo-upload-sourcemaps/scripts/build-tarball.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +rm -f $(dirname "$0")/../*.tgz + +npm pack diff --git a/yarn.lock b/yarn.lock index 81bcea7a8d..7152bd12e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11129,6 +11129,24 @@ __metadata: languageName: node linkType: hard +"@sentry/expo-upload-sourcemaps@workspace:*, @sentry/expo-upload-sourcemaps@workspace:packages/expo-upload-sourcemaps": + version: 0.0.0-use.local + resolution: "@sentry/expo-upload-sourcemaps@workspace:packages/expo-upload-sourcemaps" + dependencies: + "@sentry/cli": 3.4.0 + peerDependencies: + "@expo/env": "*" + dotenv: "*" + peerDependenciesMeta: + "@expo/env": + optional: true + dotenv: + optional: true + bin: + expo-upload-sourcemaps: ./cli.js + languageName: unknown + linkType: soft + "@sentry/node-core@npm:10.31.0": version: 10.31.0 resolution: "@sentry/node-core@npm:10.31.0" @@ -11221,6 +11239,7 @@ __metadata: "@sentry/browser": 10.49.0 "@sentry/cli": 3.4.0 "@sentry/core": 10.49.0 + "@sentry/expo-upload-sourcemaps": "workspace:*" "@sentry/react": 10.49.0 "@sentry/types": 10.49.0 "@sentry/wizard": 6.12.0