Skip to content

Commit 1338a69

Browse files
antonisclaude
andcommitted
feat(core): Introduce @sentry/expo-upload-sourcemaps package
Moves the Expo sourcemap upload script into a new scoped workspace package @sentry/expo-upload-sourcemaps and replaces the original scripts/expo-upload-sourcemaps.js in @sentry/react-native with a shim that forwards to it. The existing sentry-expo-upload-sourcemaps bin in @sentry/react-native keeps working unchanged, so projects with the bin referenced in package.json scripts or invoked via npx are not affected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5fe1c6c commit 1338a69

9 files changed

Lines changed: 465 additions & 304 deletions

File tree

.craft.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ targets:
88
sdks:
99
npm:@sentry/react-native:
1010
includeNames: /^sentry-react-native-\d.*\.tgz$/
11+
npm:@sentry/expo-upload-sourcemaps:
12+
includeNames: /^sentry-expo-upload-sourcemaps-\d.*\.tgz$/

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
},
4343
"workspaces": [
4444
"packages/core",
45+
"packages/expo-upload-sourcemaps",
4546
"dev-packages/e2e-tests",
4647
"dev-packages/type-check",
4748
"dev-packages/utils",

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"@sentry/browser": "10.49.0",
7676
"@sentry/cli": "3.4.0",
7777
"@sentry/core": "10.49.0",
78+
"@sentry/expo-upload-sourcemaps": "workspace:*",
7879
"@sentry/react": "10.49.0",
7980
"@sentry/types": "10.49.0"
8081
},
Lines changed: 6 additions & 304 deletions
Original file line numberDiff line numberDiff line change
@@ -1,311 +1,13 @@
11
#!/usr/bin/env node
2-
const { spawnSync } = require('child_process');
3-
const fs = require('fs');
4-
const path = require('path');
5-
const process = require('process');
6-
7-
const SENTRY_URL = 'SENTRY_URL';
8-
const SENTRY_ORG = 'SENTRY_ORG';
9-
const SENTRY_PROJECT = 'SENTRY_PROJECT';
10-
const SENTRY_AUTH_TOKEN = 'SENTRY_AUTH_TOKEN';
11-
const SENTRY_CLI_EXECUTABLE = 'SENTRY_CLI_EXECUTABLE';
12-
13-
function getEnvVar(varname) {
14-
return process.env[varname];
15-
}
16-
17-
function getSentryPluginPropertiesFromExpoConfig() {
18-
try {
19-
const result = spawnSync('npx', ['expo', 'config', '--json'], { encoding: 'utf8' });
20-
if (result.error || result.status !== 0) {
21-
throw result.error || new Error(`expo config exited with status ${result.status}`);
22-
}
23-
const config = JSON.parse(result.stdout);
24-
const plugins = config.plugins || [];
25-
const sentryPlugin = plugins.find(plugin => {
26-
if (!Array.isArray(plugin) || plugin.length < 2) {
27-
return false;
28-
}
29-
const [pluginName] = plugin;
30-
return pluginName === '@sentry/react-native/expo';
31-
});
32-
33-
if (sentryPlugin) {
34-
const [, pluginConfig] = sentryPlugin;
35-
return pluginConfig;
36-
}
37-
38-
// When withSentry is used programmatically in app.config.ts, the plugin
39-
// doesn't appear in the plugins array. Check config._internal where the
40-
// plugin stashes build-time properties as a fallback.
41-
if (config._internal?.sentryBuildProperties) {
42-
return config._internal.sentryBuildProperties;
43-
}
44-
45-
return null;
46-
} catch (error) {
47-
console.error('Error fetching expo config:', error);
48-
return null;
49-
}
50-
}
51-
52-
function getSentryPropertiesFromFile() {
53-
const candidates = [
54-
path.join(projectRoot, 'android', 'sentry.properties'),
55-
path.join(projectRoot, 'ios', 'sentry.properties'),
56-
];
57-
for (const candidate of candidates) {
58-
if (!fs.existsSync(candidate)) {
59-
continue;
60-
}
61-
try {
62-
const content = fs.readFileSync(candidate, 'utf8');
63-
const props = {};
64-
for (const line of content.split('\n')) {
65-
const trimmed = line.trim();
66-
if (!trimmed || trimmed.startsWith('#')) {
67-
continue;
68-
}
69-
const eqIndex = trimmed.indexOf('=');
70-
if (eqIndex === -1) {
71-
continue;
72-
}
73-
const key = trimmed.substring(0, eqIndex).trim();
74-
const value = trimmed.substring(eqIndex + 1).trim();
75-
if (key === 'defaults.org') {
76-
props.organization = value;
77-
} else if (key === 'defaults.project') {
78-
props.project = value;
79-
} else if (key === 'defaults.url') {
80-
props.url = value;
81-
}
82-
}
83-
if (props.organization || props.project) {
84-
console.log(`Found sentry properties in ${candidate}`);
85-
return props;
86-
}
87-
} catch (_e) {
88-
// continue to next candidate
89-
}
90-
}
91-
return null;
92-
}
93-
94-
function readAndPrintJSONFile(filePath) {
95-
if (!fs.existsSync(filePath)) {
96-
throw new Error(`The file "${filePath}" does not exist.`);
97-
}
98-
try {
99-
const data = fs.readFileSync(filePath, 'utf8');
100-
return JSON.parse(data);
101-
} catch (err) {
102-
console.error('Error reading or parsing JSON file:', err);
103-
throw err;
104-
}
105-
}
106-
107-
function writeJSONFile(filePath, object) {
108-
// Convert the updated JavaScript object back to a JSON string
109-
const updatedJsonString = JSON.stringify(object, null, 2);
110-
fs.writeFileSync(filePath, updatedJsonString, 'utf8', writeErr => {
111-
if (writeErr) {
112-
console.error('Error writing to the file:', writeErr);
113-
} else {
114-
console.log('File updated successfully.');
115-
}
116-
});
117-
}
118-
119-
function isAsset(filename) {
120-
return filename.endsWith('.map') || filename.endsWith('.js') || filename.endsWith('.hbc');
121-
}
122-
123-
function getAssetPathsSync(directory) {
124-
const files = [];
125-
const items = fs.readdirSync(directory, { withFileTypes: true });
126-
127-
for (const item of items) {
128-
const fullPath = path.join(directory, item.name);
129-
if (item.isDirectory()) {
130-
files.push(...getAssetPathsSync(fullPath));
131-
} else if (item.isFile() && isAsset(item.name)) {
132-
files.push(fullPath);
133-
}
134-
}
135-
return files;
136-
}
137-
138-
function groupAssets(assetPaths) {
139-
const groups = {};
140-
for (const assetPath of assetPaths) {
141-
const parsedPath = path.parse(assetPath);
142-
const extname = parsedPath.ext;
143-
const assetGroupName = extname === '.map' ? path.join(parsedPath.dir, parsedPath.name) : path.format(parsedPath);
144-
if (!groups[assetGroupName]) {
145-
groups[assetGroupName] = [assetPath];
146-
} else {
147-
groups[assetGroupName].push(assetPath);
148-
}
149-
}
150-
return groups;
151-
}
152-
153-
function loadDotenv(dotenvPath) {
154-
try {
155-
const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8');
156-
// 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.
157-
// dotenv is dependency of @expo/env, so we can just require it here
158-
const dotenvResult = require('dotenv').parse(dotenvFile);
159-
160-
Object.assign(process.env, dotenvResult);
161-
} catch (error) {
162-
if (error.code === 'ENOENT') {
163-
// noop if file does not exist
164-
} else {
165-
console.warn('⚠️ Failed to load environment variables using dotenv.');
166-
console.warn(error);
167-
}
168-
}
169-
}
170-
171-
process.env.NODE_ENV = process.env.NODE_ENV || 'development'; // Ensures precedence .env.development > .env (the same as @expo/cli)
172-
const projectRoot = '.'; // Assume script is run from the project root
1732
try {
174-
require('@expo/env').load(projectRoot);
175-
} catch (error) {
176-
console.warn('⚠️ Failed to load environment variables using @expo/env.');
177-
console.warn(error);
178-
}
179-
180-
const sentryBuildPluginPath = path.join(projectRoot, '.env.sentry-build-plugin');
181-
if (fs.existsSync(sentryBuildPluginPath)) {
182-
loadDotenv(sentryBuildPluginPath);
183-
}
184-
185-
let sentryOrg = getEnvVar(SENTRY_ORG);
186-
let sentryUrl = getEnvVar(SENTRY_URL);
187-
let sentryProject = getEnvVar(SENTRY_PROJECT);
188-
let authToken = getEnvVar(SENTRY_AUTH_TOKEN);
189-
const sentryCliBin = getEnvVar(SENTRY_CLI_EXECUTABLE) || require.resolve('@sentry/cli/bin/sentry-cli');
190-
191-
if (!sentryOrg || !sentryProject || !sentryUrl) {
192-
console.log('🐕 Fetching from expo config...');
193-
let pluginConfig = getSentryPluginPropertiesFromExpoConfig();
194-
if (!pluginConfig) {
195-
console.log('Could not fetch from expo config, trying sentry.properties files...');
196-
pluginConfig = getSentryPropertiesFromFile();
197-
}
198-
if (!pluginConfig) {
3+
require.resolve('@sentry/expo-upload-sourcemaps/cli.js');
4+
} catch (e) {
5+
if (e && e.code === 'MODULE_NOT_FOUND') {
1996
console.error(
200-
"Could not resolve Sentry configuration. Set SENTRY_ORG, SENTRY_PROJECT, and SENTRY_URL environment variables, " +
201-
"or ensure '@sentry/react-native/expo' is in your plugins array in app.json/app.config.ts."
7+
"The '@sentry/expo-upload-sourcemaps' package is missing. Reinstall @sentry/react-native, or invoke `npx @sentry/expo-upload-sourcemaps dist` directly."
2028
);
2039
process.exit(1);
20410
}
205-
206-
if (!sentryOrg) {
207-
if (!pluginConfig.organization) {
208-
console.error(
209-
`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.`,
210-
);
211-
process.exit(1);
212-
}
213-
214-
sentryOrg = pluginConfig.organization;
215-
console.log(`${SENTRY_ORG} resolved to ${sentryOrg} from expo config.`);
216-
}
217-
218-
if (!sentryProject) {
219-
if (!pluginConfig.project) {
220-
console.error(
221-
`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.`,
222-
);
223-
process.exit(1);
224-
}
225-
226-
sentryProject = pluginConfig.project;
227-
console.log(`${SENTRY_PROJECT} resolved to ${sentryProject} from expo config.`);
228-
}
229-
if (!sentryUrl) {
230-
if (pluginConfig.url) {
231-
sentryUrl = pluginConfig.url;
232-
console.log(`${SENTRY_URL} resolved to ${sentryUrl} from expo config.`);
233-
}
234-
else {
235-
sentryUrl = 'https://sentry.io/';
236-
console.log(
237-
`Since it wasn't specified in the Expo config or environment variable, ${SENTRY_URL} now points to ${sentryUrl}.`
238-
);
239-
}
240-
}
241-
}
242-
243-
if (!authToken) {
244-
console.error(`${SENTRY_AUTH_TOKEN} environment variable must be set.`);
245-
process.exit(1);
246-
}
247-
248-
const outputDir = process.argv[2];
249-
if (!outputDir) {
250-
console.error('Provide the directory with your bundles and sourcemaps as the first argument.');
251-
console.error('Example: node node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps dist');
252-
process.exit(1);
253-
}
254-
255-
const files = getAssetPathsSync(outputDir);
256-
const groupedAssets = groupAssets(files);
257-
258-
const totalAssets = Object.keys(groupedAssets).length;
259-
let numAssetsUploaded = 0;
260-
for (const [assetGroupName, assets] of Object.entries(groupedAssets)) {
261-
const sourceMapPath = assets.find(asset => asset.endsWith('.map'));
262-
if (sourceMapPath) {
263-
const sourceMap = readAndPrintJSONFile(sourceMapPath);
264-
if (sourceMap.debugId) {
265-
sourceMap.debug_id = sourceMap.debugId;
266-
}
267-
writeJSONFile(sourceMapPath, sourceMap);
268-
console.log(`⬆️ Uploading ${assetGroupName} bundle and sourcemap...`);
269-
} else {
270-
console.log(`❓ Sourcemap for ${assetGroupName} not found, skipping...`);
271-
continue;
272-
}
273-
274-
const isHermes = assets.find(asset => asset.endsWith('.hbc'));
275-
276-
// Build arguments array for spawnSync (no shell interpretation needed)
277-
const args = ['sourcemaps', 'upload'];
278-
if (isHermes) {
279-
args.push('--debug-id-reference');
280-
}
281-
args.push(...assets);
282-
283-
const result = spawnSync(sentryCliBin, args, {
284-
env: {
285-
...process.env,
286-
[SENTRY_PROJECT]: sentryProject,
287-
[SENTRY_ORG]: sentryOrg,
288-
[SENTRY_URL]: sentryUrl
289-
},
290-
stdio: 'inherit',
291-
});
292-
293-
if (result.error) {
294-
console.error('Failed to upload sourcemaps:', result.error);
295-
process.exit(1);
296-
}
297-
if (result.status !== 0) {
298-
console.error(`sentry-cli exited with status ${result.status}`);
299-
process.exit(result.status);
300-
}
301-
numAssetsUploaded++;
302-
}
303-
304-
if (numAssetsUploaded === totalAssets) {
305-
console.log('✅ Uploaded bundles and sourcemaps to Sentry successfully.');
306-
} else {
307-
console.warn(
308-
`⚠️ Uploaded ${numAssetsUploaded} of ${totalAssets} bundles and sourcemaps. ${numAssetsUploaded === 0 ? 'Ensure you are running `expo export` with the `--source-maps` flag.' : ''
309-
}`,
310-
);
11+
throw e;
31112
}
13+
require('@sentry/expo-upload-sourcemaps/cli.js');
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2017-2024 Sentry
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

0 commit comments

Comments
 (0)