Skip to content

Commit 1cc72a3

Browse files
feat(metro): Add withSentryConfig metro configuration helper (#3478)
1 parent fbf3daa commit 1cc72a3

File tree

10 files changed

+209
-50
lines changed

10 files changed

+209
-50
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@
44

55
### Features
66

7+
- New Sentry Metro configuration function `withSentryConfig` ([#3478](https://github.com/getsentry/sentry-react-native/pull/3478))
8+
- Ensures all Sentry configuration is added to your Metro config
9+
- Includes `createSentryMetroSerializer`
10+
- Collapses Sentry internal frames from the stack trace view in LogBox
11+
12+
```javascript
13+
const { getDefaultConfig } = require('@react-native/metro-config');
14+
const { withSentryConfig } = require('@sentry/react-native/metro');
15+
16+
const config = getDefaultConfig(__dirname);
17+
module.exports = withSentryConfig(config);
18+
```
19+
720
- Add experimental visionOS support ([#3467](https://github.com/getsentry/sentry-react-native/pull/3467))
821
- To set up [`react-native-visionos`](https://github.com/callstack/react-native-visionos) with the Sentry React Native SDK follow [the standard `iOS` guides](https://docs.sentry.io/platforms/react-native/manual-setup/manual-setup/#ios).
922
- Xcode project is located in `visionos` folder instead of `ios`.

metro.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export * from './dist/js/tools/sentryMetroSerializer';
1+
export * from './dist/js/tools/metroconfig';

metro.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
module.exports = require('./dist/js/tools/sentryMetroSerializer');
1+
module.exports = require('./dist/js/tools/metroconfig');

samples/expo/app/(tabs)/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ export default function TabOneScreen() {
109109
setScopeProperties();
110110
}}
111111
/>
112+
<Button
113+
title="console.warn()"
114+
onPress={() => {
115+
console.warn('This is a warning.');
116+
}}
117+
/>
112118
<Button
113119
title="Flush"
114120
onPress={async () => {

samples/react-native/metro.config.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
22
const path = require('path');
33
const blacklist = require('metro-config/src/defaults/exclusionList');
44

5-
const { createSentryMetroSerializer } = require('../../metro');
5+
const { withSentryConfig } = require('../../metro');
66
const parentDir = path.resolve(__dirname, '../..');
77

88
/**
@@ -42,10 +42,7 @@ const config = {
4242
},
4343
),
4444
},
45-
serializer: {
46-
customSerializer: createSentryMetroSerializer(),
47-
},
4845
};
4946

5047
const m = mergeConfig(getDefaultConfig(__dirname), config);
51-
module.exports = m;
48+
module.exports = withSentryConfig(m);

samples/react-native/src/Screens/HomeScreen.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ const HomeScreen = (props: Props) => {
113113
console.log('Sentry.close() completed.');
114114
}}
115115
/>
116+
<Button
117+
title="console.warn()"
118+
onPress={() => {
119+
console.warn('This is a warning.');
120+
}}
121+
/>
116122
<Button
117123
title="Crash in Cpp"
118124
onPress={() => {

src/js/tools/metroconfig.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type { MetroConfig, MixedOutput, Module, ReadOnlyGraph } from 'metro';
2+
3+
import { createSentryMetroSerializer, unstable_beforeAssetSerializationPlugin } from './sentryMetroSerializer';
4+
import type { DefaultConfigOptions } from './vendor/expo/expoconfig';
5+
6+
export * from './sentryMetroSerializer';
7+
8+
/**
9+
* Adds Sentry to the Metro config.
10+
*
11+
* Adds Debug ID to the output bundle and source maps.
12+
* Collapses Sentry frames from the stack trace view in LogBox.
13+
*/
14+
export function withSentryConfig(config: MetroConfig): MetroConfig {
15+
let newConfig = config;
16+
17+
newConfig = withSentryDebugId(newConfig);
18+
newConfig = withSentryFramesCollapsed(newConfig);
19+
20+
return newConfig;
21+
}
22+
23+
/**
24+
* This function returns Default Expo configuration with Sentry plugins.
25+
*/
26+
export function getSentryExpoConfig(projectRoot: string, options: DefaultConfigOptions = {}): MetroConfig {
27+
const { getDefaultConfig } = loadExpoMetroConfigModule();
28+
const config = getDefaultConfig(projectRoot, {
29+
...options,
30+
unstable_beforeAssetSerializationPlugins: [
31+
...(options.unstable_beforeAssetSerializationPlugins || []),
32+
unstable_beforeAssetSerializationPlugin,
33+
],
34+
});
35+
36+
return withSentryFramesCollapsed(config);
37+
}
38+
39+
function loadExpoMetroConfigModule(): {
40+
getDefaultConfig: (
41+
projectRoot: string,
42+
options: {
43+
unstable_beforeAssetSerializationPlugins?: ((serializationInput: {
44+
graph: ReadOnlyGraph<MixedOutput>;
45+
premodules: Module[];
46+
debugId?: string;
47+
}) => Module[])[];
48+
},
49+
) => MetroConfig;
50+
} {
51+
try {
52+
// eslint-disable-next-line @typescript-eslint/no-var-requires
53+
return require('expo/metro-config');
54+
} catch (e) {
55+
throw new Error('Unable to load `expo/metro-config`. Make sure you have Expo installed.');
56+
}
57+
}
58+
59+
type MetroCustomSerializer = Required<Required<MetroConfig>['serializer']>['customSerializer'] | undefined;
60+
61+
function withSentryDebugId(config: MetroConfig): MetroConfig {
62+
const customSerializer = createSentryMetroSerializer(
63+
config.serializer?.customSerializer || undefined,
64+
) as MetroCustomSerializer;
65+
// MetroConfig types customSerializers as async only, but sync returns are also supported
66+
// The default serializer is sync
67+
68+
return {
69+
...config,
70+
serializer: {
71+
...config.serializer,
72+
customSerializer,
73+
},
74+
};
75+
}
76+
77+
type MetroFrame = Parameters<Required<Required<MetroConfig>['symbolicator']>['customizeFrame']>[0];
78+
type MetroCustomizeFrame = { readonly collapse?: boolean };
79+
type MetroCustomizeFrameReturnValue =
80+
| ReturnType<Required<Required<MetroConfig>['symbolicator']>['customizeFrame']>
81+
| undefined;
82+
83+
/**
84+
* Collapses Sentry internal frames from the stack trace view in LogBox.
85+
*/
86+
export function withSentryFramesCollapsed(config: MetroConfig): MetroConfig {
87+
const originalCustomizeFrame = config.symbolicator?.customizeFrame;
88+
const collapseSentryInternalFrames = (frame: MetroFrame): boolean =>
89+
typeof frame.file === 'string' &&
90+
(frame.file.includes('node_modules/@sentry/utils/cjs/instrument.js') ||
91+
frame.file.includes('node_modules/@sentry/utils/cjs/logger.js'));
92+
93+
const customizeFrame = (frame: MetroFrame): MetroCustomizeFrameReturnValue => {
94+
const originalOrSentryCustomizeFrame = (
95+
originalCustomization: MetroCustomizeFrame | undefined,
96+
): MetroCustomizeFrame => ({
97+
...originalCustomization,
98+
collapse: (originalCustomization && originalCustomization.collapse) || collapseSentryInternalFrames(frame),
99+
});
100+
101+
const maybePromiseCustomization = (originalCustomizeFrame && originalCustomizeFrame(frame)) || undefined;
102+
103+
if (maybePromiseCustomization !== undefined && 'then' in maybePromiseCustomization) {
104+
return maybePromiseCustomization.then<MetroCustomizeFrame>(originalCustomization =>
105+
originalOrSentryCustomizeFrame(originalCustomization),
106+
);
107+
}
108+
109+
return originalOrSentryCustomizeFrame(maybePromiseCustomization);
110+
};
111+
112+
return {
113+
...config,
114+
symbolicator: {
115+
...config.symbolicator,
116+
customizeFrame,
117+
},
118+
};
119+
}

src/js/tools/sentryMetroSerializer.ts

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import * as crypto from 'crypto';
2-
import type { MetroConfig, MixedOutput, Module, ReadOnlyGraph } from 'metro';
2+
import type { MixedOutput, Module, ReadOnlyGraph } from 'metro';
33
import * as countLines from 'metro/src/lib/countLines';
44

55
import type { Bundle, MetroSerializer, MetroSerializerOutput, SerializedBundle, VirtualJSOutput } from './utils';
66
import { createDebugIdSnippet, createSet, determineDebugIdFromBundleSource, stringToUUID } from './utils';
7-
import type { DefaultConfigOptions } from './vendor/expo/expoconfig';
87
import { createDefaultMetroSerializer } from './vendor/metro/utils';
98

109
type SourceMap = Record<string, unknown>;
@@ -16,20 +15,9 @@ const SOURCE_MAP_COMMENT = '//# sourceMappingURL=';
1615
const DEBUG_ID_COMMENT = '//# debugId=';
1716

1817
/**
19-
* This function returns Default Expo configuration with Sentry plugins.
18+
* Adds Sentry Debug ID polyfill module to the bundle.
2019
*/
21-
export function getSentryExpoConfig(projectRoot: string, options: DefaultConfigOptions = {}): MetroConfig {
22-
const { getDefaultConfig } = loadExpoMetroConfigModule();
23-
return getDefaultConfig(projectRoot, {
24-
...options,
25-
unstable_beforeAssetSerializationPlugins: [
26-
...(options.unstable_beforeAssetSerializationPlugins || []),
27-
unstable_beforeAssetSerializationPlugin,
28-
],
29-
});
30-
}
31-
32-
function unstable_beforeAssetSerializationPlugin({
20+
export function unstable_beforeAssetSerializationPlugin({
3321
premodules,
3422
debugId,
3523
}: {
@@ -52,26 +40,6 @@ function unstable_beforeAssetSerializationPlugin({
5240
return [...addDebugIdModule(premodules, debugIdModule)];
5341
}
5442

55-
function loadExpoMetroConfigModule(): {
56-
getDefaultConfig: (
57-
projectRoot: string,
58-
options: {
59-
unstable_beforeAssetSerializationPlugins?: ((serializationInput: {
60-
graph: ReadOnlyGraph<MixedOutput>;
61-
premodules: Module[];
62-
debugId?: string;
63-
}) => Module[])[];
64-
},
65-
) => MetroConfig;
66-
} {
67-
try {
68-
// eslint-disable-next-line @typescript-eslint/no-var-requires
69-
return require('expo/metro-config');
70-
} catch (e) {
71-
throw new Error('Unable to load `expo/metro-config`. Make sure you have Expo installed.');
72-
}
73-
}
74-
7543
/**
7644
* Creates a Metro serializer that adds Debug ID module to the plain bundle.
7745
* The Debug ID module is a virtual module that provides a debug ID in runtime.

test/react-native/rn.patch.metro.config.js

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,32 @@ logger.info('Patching Metro config: ', args.path);
1616

1717
const configFilePath = args.path;
1818

19-
const importSerializer = "const {createSentryMetroSerializer} = require('@sentry/react-native/metro');";
20-
const serializerValue = 'serializer: { customSerializer: createSentryMetroSerializer(), },';
21-
const enterSerializerBefore = '};';
19+
const importSerializer = "const { withSentryConfig } = require('@sentry/react-native/metro');";
2220

2321
let config = fs.readFileSync(configFilePath, 'utf8').split('\n');
2422

25-
const isPatched = config.includes(line => line.includes(importSerializer));
23+
const isPatched = config.includes(importSerializer);
2624
if (!isPatched) {
2725
config = [importSerializer, ...config];
28-
const lineIndex = config.findIndex(line => line.includes(enterSerializerBefore));
29-
const lineParsed = config[lineIndex].split(enterSerializerBefore);
30-
lineParsed.push(serializerValue, enterSerializerBefore);
31-
config[lineIndex] = lineParsed.join('');
26+
const moduleExportsLineIndex = config.findIndex(line => line.includes('module.exports ='));
27+
const endOfModuleExportsIndex = config.findIndex(line => line === '};');
28+
29+
const lineParsed = config[moduleExportsLineIndex].split('=');
30+
if (lineParsed.length !== 2) {
31+
throw new Error('Failed to parse module.exports line');
32+
}
33+
const endsWithSemicolon = lineParsed[1].endsWith(';');
34+
if (endsWithSemicolon) {
35+
lineParsed[1] = lineParsed[1].slice(0, -1);
36+
}
37+
38+
lineParsed[1] = `= withSentryConfig(${lineParsed[1]}${endsWithSemicolon ? ');' : ''}`;
39+
config[moduleExportsLineIndex] = lineParsed.join('');
40+
41+
if (endOfModuleExportsIndex !== -1) {
42+
config[endOfModuleExportsIndex] = '});';
43+
}
44+
3245
fs.writeFileSync(configFilePath, config.join('\n'), 'utf8');
3346
logger.info('Patched Metro config successfully!');
3447
} else {

test/tools/metroconfig.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { MetroConfig } from 'metro';
2+
3+
import { withSentryFramesCollapsed } from '../../src/js/tools/metroconfig';
4+
5+
type MetroFrame = Parameters<Required<Required<MetroConfig>['symbolicator']>['customizeFrame']>[0];
6+
7+
describe('withSentryFramesCollapsed', () => {
8+
test('adds customizeFrames if undefined ', () => {
9+
const config = withSentryFramesCollapsed({});
10+
expect(config.symbolicator?.customizeFrame).toBeDefined();
11+
});
12+
13+
test('wraps existing customizeFrames', async () => {
14+
const originalCustomizeFrame = jest.fn();
15+
const config = withSentryFramesCollapsed({ symbolicator: { customizeFrame: originalCustomizeFrame } });
16+
17+
const customizeFrame = config.symbolicator?.customizeFrame;
18+
await customizeFrame?.(createMockSentryInstrumentMetroFrame());
19+
20+
expect(config.symbolicator?.customizeFrame).not.toBe(originalCustomizeFrame);
21+
expect(originalCustomizeFrame).toHaveBeenCalledTimes(1);
22+
});
23+
24+
test('collapses sentry instrument frames', async () => {
25+
const config = withSentryFramesCollapsed({});
26+
27+
const customizeFrame = config.symbolicator?.customizeFrame;
28+
const customizedFrame = await customizeFrame?.(createMockSentryInstrumentMetroFrame());
29+
30+
expect(customizedFrame?.collapse).toBe(true);
31+
});
32+
});
33+
34+
// function create mock metro frame
35+
function createMockSentryInstrumentMetroFrame(): MetroFrame {
36+
return { file: 'node_modules/@sentry/utils/cjs/instrument.js' };
37+
}

0 commit comments

Comments
 (0)