Skip to content

Commit 0572a71

Browse files
antonisclaude
andauthored
fix(core): Fix sourcemap upload when withSentry is used programmatically (#6006)
* fix(core): Fix sourcemap upload when withSentry is used programmatically Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(core): Handle missing plugins array in expo config The early return when `config.plugins` was undefined prevented the `_internal.sentryBuildProperties` fallback from being reached. Default to an empty array instead so the fallback chain continues. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: Add changelog entry for sourcemap upload fix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 93704e1 commit 0572a71

4 files changed

Lines changed: 332 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
### Fixes
2020

21+
- Fix sourcemap upload script failing when `withSentry` is used programmatically in `app.config.ts` ([#6006](https://github.com/getsentry/sentry-react-native/pull/6006))
2122
- Retry native module resolution to prevent silent event drops in production Hermes builds ([#5981](https://github.com/getsentry/sentry-react-native/pull/5981))
2223
- Lazy-load Metro internal modules to prevent Expo 55 import errors ([#5958](https://github.com/getsentry/sentry-react-native/pull/5958))
2324
- Fix app start transaction profile offset by using the actual profiling start timestamp instead of the adjusted app start time ([#5962](https://github.com/getsentry/sentry-react-native/issues/5962))

packages/core/plugin/src/withSentry.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,29 @@ interface PluginProps {
2222
experimental_android?: SentryAndroidGradlePluginOptions;
2323
}
2424

25+
/**
26+
* Store build-time properties in config._internal so they're discoverable
27+
* by the sourcemap upload script via `expo config --json`, even when
28+
* withSentry is used programmatically in app.config.ts.
29+
*
30+
* We use _internal instead of extra because _internal is stripped from the
31+
* public config (app manifest) and not shipped in the production app, while
32+
* extra would leak org/project metadata into the app binary.
33+
*/
34+
function storeBuildPropertiesInConfig(config: ExpoConfig, props: PluginProps | void): void {
35+
if (props?.organization || props?.project) {
36+
// ExpoConfig types don't include _internal, but it's a standard Expo field
37+
// used by config plugins infrastructure (e.g. pluginHistory).
38+
const configWithInternal = config as ExpoConfig & { _internal?: Record<string, unknown> };
39+
configWithInternal._internal = configWithInternal._internal || {};
40+
configWithInternal._internal.sentryBuildProperties = {
41+
organization: props?.organization,
42+
project: props?.project,
43+
url: props?.url || 'https://sentry.io/',
44+
};
45+
}
46+
}
47+
2548
const withSentryPlugin: ConfigPlugin<PluginProps | void> = (config, props) => {
2649
const sentryProperties = getSentryProperties(props);
2750

@@ -30,6 +53,8 @@ const withSentryPlugin: ConfigPlugin<PluginProps | void> = (config, props) => {
3053
delete props.authToken;
3154
}
3255

56+
storeBuildPropertiesInConfig(config, props);
57+
3358
let cfg = config;
3459
const pluginOptions = props?.options ? { ...props.options } : {};
3560
// oxlint-disable-next-line typescript-eslint(no-unsafe-member-access)

packages/core/scripts/expo-upload-sourcemaps.js

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,7 @@ function getSentryPluginPropertiesFromExpoConfig() {
2121
throw result.error || new Error(`expo config exited with status ${result.status}`);
2222
}
2323
const config = JSON.parse(result.stdout);
24-
const plugins = config.plugins;
25-
if (!plugins) {
26-
return null;
27-
}
28-
24+
const plugins = config.plugins || [];
2925
const sentryPlugin = plugins.find(plugin => {
3026
if (!Array.isArray(plugin) || plugin.length < 2) {
3127
return false;
@@ -34,17 +30,67 @@ function getSentryPluginPropertiesFromExpoConfig() {
3430
return pluginName === '@sentry/react-native/expo';
3531
});
3632

37-
if (!sentryPlugin) {
38-
return null;
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;
3943
}
40-
const [, pluginConfig] = sentryPlugin;
41-
return pluginConfig;
44+
45+
return null;
4246
} catch (error) {
4347
console.error('Error fetching expo config:', error);
4448
return null;
4549
}
4650
}
4751

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+
4894
function readAndPrintJSONFile(filePath) {
4995
if (!fs.existsSync(filePath)) {
5096
throw new Error(`The file "${filePath}" does not exist.`);
@@ -144,9 +190,16 @@ const sentryCliBin = getEnvVar(SENTRY_CLI_EXECUTABLE) || require.resolve('@sentr
144190

145191
if (!sentryOrg || !sentryProject || !sentryUrl) {
146192
console.log('🐕 Fetching from expo config...');
147-
const pluginConfig = getSentryPluginPropertiesFromExpoConfig();
193+
let pluginConfig = getSentryPluginPropertiesFromExpoConfig();
194+
if (!pluginConfig) {
195+
console.log('Could not fetch from expo config, trying sentry.properties files...');
196+
pluginConfig = getSentryPropertiesFromFile();
197+
}
148198
if (!pluginConfig) {
149-
console.error("Could not fetch '@sentry/react-native' plugin properties from expo config.");
199+
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."
202+
);
150203
process.exit(1);
151204
}
152205

packages/core/test/scripts/expo-upload-sourcemaps.test.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,248 @@ process.exit(exitCode);
294294
});
295295
});
296296

297+
describe('sentry.properties fallback', () => {
298+
let mockNpxScript: string;
299+
let mockBinDir: string;
300+
301+
beforeEach(() => {
302+
// Create a mock npx that makes `expo config --json` fail fast
303+
// so the script falls through to the sentry.properties fallback
304+
mockBinDir = path.join(tempDir, 'mock-bin');
305+
fs.mkdirSync(mockBinDir, { recursive: true });
306+
mockNpxScript = path.join(mockBinDir, 'npx');
307+
fs.writeFileSync(mockNpxScript, '#!/usr/bin/env node\nprocess.exit(1);\n');
308+
fs.chmodSync(mockNpxScript, '755');
309+
});
310+
311+
const createSentryProperties = (dir: string, content: string) => {
312+
fs.mkdirSync(dir, { recursive: true });
313+
fs.writeFileSync(path.join(dir, 'sentry.properties'), content);
314+
};
315+
316+
const runScriptWithCwd = (
317+
cwd: string,
318+
env: Record<string, string | undefined> = {},
319+
): { stdout: string; stderr: string; exitCode: number } => {
320+
const defaultEnv = {
321+
SENTRY_AUTH_TOKEN: 'test-token',
322+
SENTRY_CLI_EXECUTABLE: mockSentryCliScript,
323+
// Put mock npx first in PATH so expo config fails fast
324+
PATH: `${mockBinDir}:${process.env.PATH}`,
325+
};
326+
327+
const result = spawnSync(process.execPath, [EXPO_UPLOAD_SCRIPT, outputDir], {
328+
cwd,
329+
env: { ...process.env, ...defaultEnv, ...env },
330+
encoding: 'utf8',
331+
timeout: 10000,
332+
});
333+
334+
return {
335+
stdout: result.stdout || '',
336+
stderr: result.stderr || '',
337+
exitCode: result.status || 0,
338+
};
339+
};
340+
341+
it('reads config from android/sentry.properties when expo config is not available', () => {
342+
createAssets(['bundle.js', 'bundle.js.map']);
343+
createSentryProperties(
344+
path.join(tempDir, 'android'),
345+
'defaults.url=https://sentry.io/\ndefaults.org=props-org\ndefaults.project=props-project\n',
346+
);
347+
348+
const result = runScriptWithCwd(tempDir, {
349+
SENTRY_ORG: undefined,
350+
SENTRY_PROJECT: undefined,
351+
SENTRY_URL: undefined,
352+
MOCK_CLI_EXIT_CODE: '0',
353+
});
354+
355+
expect(result.exitCode).toBe(0);
356+
expect(result.stdout).toContain('Found sentry properties in');
357+
expect(result.stdout).toContain('android');
358+
});
359+
360+
it('reads config from ios/sentry.properties when android is not available', () => {
361+
createAssets(['bundle.js', 'bundle.js.map']);
362+
createSentryProperties(
363+
path.join(tempDir, 'ios'),
364+
'defaults.url=https://sentry.io/\ndefaults.org=ios-org\ndefaults.project=ios-project\n',
365+
);
366+
367+
const result = runScriptWithCwd(tempDir, {
368+
SENTRY_ORG: undefined,
369+
SENTRY_PROJECT: undefined,
370+
SENTRY_URL: undefined,
371+
MOCK_CLI_EXIT_CODE: '0',
372+
});
373+
374+
expect(result.exitCode).toBe(0);
375+
expect(result.stdout).toContain('Found sentry properties in');
376+
expect(result.stdout).toContain('ios');
377+
});
378+
379+
it('skips comment lines and empty lines in sentry.properties', () => {
380+
createAssets(['bundle.js', 'bundle.js.map']);
381+
createSentryProperties(
382+
path.join(tempDir, 'android'),
383+
'# This is a comment\ndefaults.url=https://sentry.io/\n\ndefaults.org=comment-org\n# Another comment\ndefaults.project=comment-project\n',
384+
);
385+
386+
const result = runScriptWithCwd(tempDir, {
387+
SENTRY_ORG: undefined,
388+
SENTRY_PROJECT: undefined,
389+
SENTRY_URL: undefined,
390+
MOCK_CLI_EXIT_CODE: '0',
391+
});
392+
393+
expect(result.exitCode).toBe(0);
394+
expect(result.stdout).toContain('Found sentry properties in');
395+
});
396+
397+
it('fails with helpful message when no config source is available', () => {
398+
createAssets(['bundle.js', 'bundle.js.map']);
399+
400+
const result = runScriptWithCwd(tempDir, {
401+
SENTRY_ORG: undefined,
402+
SENTRY_PROJECT: undefined,
403+
SENTRY_URL: undefined,
404+
});
405+
406+
expect(result.exitCode).toBe(1);
407+
const output = result.stdout + result.stderr;
408+
expect(output).toContain('SENTRY_ORG');
409+
expect(output).toContain('SENTRY_PROJECT');
410+
});
411+
});
412+
413+
describe('config._internal.sentryBuildProperties fallback (withSentry programmatic usage)', () => {
414+
const runScriptWithMockExpoConfig = (
415+
expoConfig: Record<string, unknown>,
416+
env: Record<string, string | undefined> = {},
417+
): { stdout: string; stderr: string; exitCode: number } => {
418+
// Create a mock npx that outputs the given expo config as JSON
419+
const mockBinDir = path.join(tempDir, 'mock-bin-expo');
420+
fs.mkdirSync(mockBinDir, { recursive: true });
421+
const mockNpxScript = path.join(mockBinDir, 'npx');
422+
// The mock npx script outputs the config JSON when called with 'expo config --json'
423+
fs.writeFileSync(
424+
mockNpxScript,
425+
`#!/usr/bin/env node
426+
const args = process.argv.slice(2);
427+
if (args.includes('expo') && args.includes('config') && args.includes('--json')) {
428+
process.stdout.write(${JSON.stringify(JSON.stringify(expoConfig))});
429+
process.exit(0);
430+
}
431+
process.exit(1);
432+
`,
433+
);
434+
fs.chmodSync(mockNpxScript, '755');
435+
436+
const defaultEnv = {
437+
SENTRY_AUTH_TOKEN: 'test-token',
438+
SENTRY_CLI_EXECUTABLE: mockSentryCliScript,
439+
PATH: `${mockBinDir}:${process.env.PATH}`,
440+
};
441+
442+
const result = spawnSync(process.execPath, [EXPO_UPLOAD_SCRIPT, outputDir], {
443+
cwd: tempDir,
444+
env: { ...process.env, ...defaultEnv, ...env },
445+
encoding: 'utf8',
446+
timeout: 10000,
447+
});
448+
449+
return {
450+
stdout: result.stdout || '',
451+
stderr: result.stderr || '',
452+
exitCode: result.status || 0,
453+
};
454+
};
455+
456+
it('reads config from _internal.sentryBuildProperties when plugin is not in plugins array', () => {
457+
createAssets(['bundle.js', 'bundle.js.map']);
458+
459+
const result = runScriptWithMockExpoConfig(
460+
{
461+
plugins: [['some-other-plugin', {}]],
462+
_internal: {
463+
sentryBuildProperties: {
464+
organization: 'internal-org',
465+
project: 'internal-project',
466+
url: 'https://sentry.io/',
467+
},
468+
},
469+
},
470+
{
471+
SENTRY_ORG: undefined,
472+
SENTRY_PROJECT: undefined,
473+
SENTRY_URL: undefined,
474+
MOCK_CLI_EXIT_CODE: '0',
475+
},
476+
);
477+
478+
expect(result.exitCode).toBe(0);
479+
expect(result.stdout).toContain('Uploaded bundles and sourcemaps to Sentry successfully');
480+
});
481+
482+
it('prefers plugins array over _internal.sentryBuildProperties', () => {
483+
createAssets(['bundle.js', 'bundle.js.map']);
484+
485+
const result = runScriptWithMockExpoConfig(
486+
{
487+
plugins: [
488+
[
489+
'@sentry/react-native/expo',
490+
{ organization: 'plugin-org', project: 'plugin-project', url: 'https://sentry.io/' },
491+
],
492+
],
493+
_internal: {
494+
sentryBuildProperties: {
495+
organization: 'internal-org',
496+
project: 'internal-project',
497+
url: 'https://sentry.io/',
498+
},
499+
},
500+
},
501+
{
502+
SENTRY_ORG: undefined,
503+
SENTRY_PROJECT: undefined,
504+
SENTRY_URL: undefined,
505+
MOCK_CLI_EXIT_CODE: '0',
506+
},
507+
);
508+
509+
expect(result.exitCode).toBe(0);
510+
expect(result.stdout).toContain('SENTRY_ORG resolved to plugin-org');
511+
});
512+
513+
it('reads _internal.sentryBuildProperties even when plugins array is missing', () => {
514+
createAssets(['bundle.js', 'bundle.js.map']);
515+
516+
const result = runScriptWithMockExpoConfig(
517+
{
518+
_internal: {
519+
sentryBuildProperties: {
520+
organization: 'no-plugins-org',
521+
project: 'no-plugins-project',
522+
url: 'https://sentry.io/',
523+
},
524+
},
525+
},
526+
{
527+
SENTRY_ORG: undefined,
528+
SENTRY_PROJECT: undefined,
529+
SENTRY_URL: undefined,
530+
MOCK_CLI_EXIT_CODE: '0',
531+
},
532+
);
533+
534+
expect(result.exitCode).toBe(0);
535+
expect(result.stdout).toContain('Uploaded bundles and sourcemaps to Sentry successfully');
536+
});
537+
});
538+
297539
describe('sourcemap processing', () => {
298540
it('converts debugId to debug_id in sourcemaps', () => {
299541
createAssets(['bundle.js', 'bundle.js.map']);

0 commit comments

Comments
 (0)