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 @@ +
+
+
+