Skip to content

Commit 7cf4aa8

Browse files
antonisclaude
andcommitted
fix(core): Include watchFolders as allowed roots for source context
Monorepos and yarn workspaces set projectRoot to the app package and declare sibling packages via Metro's watchFolders. Allow reads under any configured root so source context keeps working in those setups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2cd1e7b commit 7cf4aa8

2 files changed

Lines changed: 69 additions & 18 deletions

File tree

packages/core/src/js/tools/metroMiddleware.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ const readFileAsync = promisify(readFile);
1717
* Accepts Sentry formatted stack frames and
1818
* adds source context to the in app frames.
1919
*
20-
* Filenames are resolved relative to `projectRoot` and must remain within it.
20+
* Relative filenames are resolved against the first entry in `allowedRoots`.
21+
* A resolved filename must be contained in at least one of `allowedRoots`
22+
* (project root plus any Metro `watchFolders`).
2123
*/
22-
export const createStackFramesContextMiddleware = (projectRoot: string): Middleware => {
23-
const normalizedRoot = path.resolve(projectRoot);
24+
export const createStackFramesContextMiddleware = (allowedRoots: string[]): Middleware => {
25+
const normalizedRoots = allowedRoots.map(root => path.resolve(root));
2426

2527
return async (request: IncomingMessage, response: ServerResponse, _next: () => void): Promise<void> => {
2628
debug.log('[@sentry/react-native/metro] Received request for stack frames context.');
@@ -45,15 +47,15 @@ export const createStackFramesContextMiddleware = (projectRoot: string): Middlew
4547
return;
4648
}
4749

48-
const stackWithSourceContext = await Promise.all(stack.map(frame => addSourceContext(frame, normalizedRoot)));
50+
const stackWithSourceContext = await Promise.all(stack.map(frame => addSourceContext(frame, normalizedRoots)));
4951
response.setHeader('Content-Type', 'application/json');
5052
response.statusCode = 200;
5153
response.end(JSON.stringify({ stack: stackWithSourceContext }));
5254
debug.log('[@sentry/react-native/metro] Sent stack frames context.');
5355
};
5456
};
5557

56-
async function addSourceContext(frame: StackFrame, projectRoot: string): Promise<StackFrame> {
58+
async function addSourceContext(frame: StackFrame, allowedRoots: string[]): Promise<StackFrame> {
5759
if (!frame.in_app) {
5860
return frame;
5961
}
@@ -64,10 +66,18 @@ async function addSourceContext(frame: StackFrame, projectRoot: string): Promise
6466
return frame;
6567
}
6668

67-
const resolvedPath = path.resolve(projectRoot, frame.filename);
68-
const relative = path.relative(projectRoot, resolvedPath);
69-
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
70-
debug.warn('[@sentry/react-native/metro] Skipping frame whose filename is outside the project root.');
69+
if (allowedRoots.length === 0) {
70+
debug.warn('[@sentry/react-native/metro] Skipping frame: no allowed roots configured.');
71+
return frame;
72+
}
73+
74+
const resolvedPath = path.resolve(allowedRoots[0]!, frame.filename);
75+
const isInside = allowedRoots.some(root => {
76+
const relative = path.relative(root, resolvedPath);
77+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
78+
});
79+
if (!isInside) {
80+
debug.warn('[@sentry/react-native/metro] Skipping frame whose filename is outside the allowed roots.');
7181
return frame;
7282
}
7383

@@ -88,8 +98,8 @@ function badRequest(response: ServerResponse, message: string): void {
8898
/**
8999
* Creates a middleware that adds source context to the Sentry formatted stack frames.
90100
*/
91-
export const createSentryMetroMiddleware = (middleware: Middleware, projectRoot: string): Middleware => {
92-
const stackFramesContextMiddleware = createStackFramesContextMiddleware(projectRoot) as (
101+
export const createSentryMetroMiddleware = (middleware: Middleware, allowedRoots: string[]): Middleware => {
102+
const stackFramesContextMiddleware = createStackFramesContextMiddleware(allowedRoots) as (
93103
req: IncomingMessage,
94104
res: ServerResponse,
95105
next: () => void,
@@ -117,10 +127,14 @@ export const withSentryMiddleware = (config: InputConfigT): InputConfigT => {
117127
config.server = {};
118128
}
119129

120-
const projectRoot = (config as { projectRoot?: string }).projectRoot || process.cwd();
130+
const typedConfig = config as { projectRoot?: string; watchFolders?: readonly string[] };
131+
const projectRoot = typedConfig.projectRoot || process.cwd();
132+
const watchFolders = typedConfig.watchFolders || [];
133+
const allowedRoots = [projectRoot, ...watchFolders];
134+
121135
const originalEnhanceMiddleware = config.server.enhanceMiddleware;
122136
config.server.enhanceMiddleware = (middleware, server) => {
123-
const sentryMiddleware = createSentryMetroMiddleware(middleware, projectRoot);
137+
const sentryMiddleware = createSentryMetroMiddleware(middleware, allowedRoots);
124138
return originalEnhanceMiddleware ? originalEnhanceMiddleware(sentryMiddleware, server) : sentryMiddleware;
125139
};
126140
return config;

packages/core/test/tools/metroMiddleware.test.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,14 @@ describe('metroMiddleware', () => {
7979
});
8080

8181
it('should call stackFramesContextMiddleware for sentry context requests', () => {
82-
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware, TEST_PROJECT_ROOT);
82+
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware, [TEST_PROJECT_ROOT]);
8383

8484
const sentryRequest = {
8585
url: '/__sentry/context',
8686
} as any;
8787
testedMiddleware(sentryRequest, response, next);
8888
expect(defaultMiddleware).not.toHaveBeenCalled();
89-
expect(spiedCreateStackFramesContextMiddleware).toHaveBeenCalledWith(TEST_PROJECT_ROOT);
89+
expect(spiedCreateStackFramesContextMiddleware).toHaveBeenCalledWith([TEST_PROJECT_ROOT]);
9090
expect(mockedStackFramesContextMiddleware).toHaveBeenCalledWith(sentryRequest, response, next);
9191
});
9292

@@ -95,7 +95,7 @@ describe('metroMiddleware', () => {
9595
.spyOn(openUrlMiddlewareModule, 'openURLMiddleware')
9696
.mockReturnValue(undefined as any);
9797

98-
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware, TEST_PROJECT_ROOT);
98+
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware, [TEST_PROJECT_ROOT]);
9999

100100
const openUrlRequest = {
101101
url: '/__sentry/open-url',
@@ -109,7 +109,7 @@ describe('metroMiddleware', () => {
109109
});
110110

111111
it('should call default middleware for non-sentry requests', () => {
112-
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware, TEST_PROJECT_ROOT);
112+
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware, [TEST_PROJECT_ROOT]);
113113

114114
const regularRequest = {
115115
url: '/regular/path',
@@ -125,7 +125,7 @@ describe('metroMiddleware', () => {
125125
let request: any;
126126
let response: any;
127127
const next = jest.fn();
128-
const stackFramesContextMiddleware = createStackFramesContextMiddleware(TEST_PROJECT_ROOT);
128+
const stackFramesContextMiddleware = createStackFramesContextMiddleware([TEST_PROJECT_ROOT]);
129129

130130
let testData: string = '';
131131

@@ -289,6 +289,43 @@ describe('metroMiddleware', () => {
289289
});
290290
});
291291

292+
it('should add source context for frames under additional allowed roots (watchFolders)', async () => {
293+
const watchFolder = path.resolve('/tmp/sentry-rn-test-workspace-pkg');
294+
const scopedMiddleware = createStackFramesContextMiddleware([TEST_PROJECT_ROOT, watchFolder]);
295+
const readFileSpy = jest.spyOn(fs, 'readFile');
296+
mockReadFileOnce(readFileSpy, path.join(watchFolder, 'shared.js'), 'one\ntwo\nthree\nfour\nfive');
297+
298+
testData = JSON.stringify({
299+
stack: [
300+
{
301+
in_app: true,
302+
filename: path.join(watchFolder, 'shared.js'),
303+
function: 'sharedFn',
304+
lineno: 3,
305+
colno: 1,
306+
},
307+
],
308+
} satisfies { stack: StackFrame[] });
309+
310+
await scopedMiddleware(request, response, next);
311+
312+
expect(response.statusCode).toBe(200);
313+
expect(JSON.parse(response.end.mock.calls[0][0])).toEqual({
314+
stack: [
315+
{
316+
in_app: true,
317+
filename: path.join(watchFolder, 'shared.js'),
318+
function: 'sharedFn',
319+
lineno: 3,
320+
colno: 1,
321+
pre_context: ['one', 'two'],
322+
context_line: 'three',
323+
post_context: ['four', 'five'],
324+
},
325+
],
326+
});
327+
});
328+
292329
it('should skip frames whose filename escapes the project root', async () => {
293330
const readFileSpy = jest.spyOn(fs, 'readFile');
294331
testData = JSON.stringify({

0 commit comments

Comments
 (0)