Skip to content

Commit 08cee3d

Browse files
feat(metro): Add Sentry Middleware for source context in debug builds (#4287)
1 parent b9eeab6 commit 08cee3d

File tree

8 files changed

+554
-74
lines changed

8 files changed

+554
-74
lines changed

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,27 @@
88
99
## Unreleased
1010

11+
### Features
12+
13+
- Add Sentry Metro Server Source Context middleware ([#4287](https://github.com/getsentry/sentry-react-native/pull/4287))
14+
15+
This enables the SDK to add source context to locally symbolicated events using the Metro Development Server.
16+
The middleware can be disabled in `metro.config.js` using the `enableSourceContextInDevelopment` option.
17+
18+
```js
19+
// Expo
20+
const { getSentryExpoConfig } = require('@sentry/react-native/metro');
21+
const config = getSentryExpoConfig(__dirname, {
22+
enableSourceContextInDevelopment: false,
23+
});
24+
25+
// React Native
26+
const { withSentryConfig } = require('@sentry/react-native/metro');
27+
module.exports = withSentryConfig(config, {
28+
enableSourceContextInDevelopment: false,
29+
});
30+
```
31+
1132
### Fixes
1233

1334
- Prevents exception capture context from being overwritten by native scope sync ([#4124](https://github.com/getsentry/sentry-react-native/pull/4124))

packages/core/src/js/integrations/debugsymbolicator.ts

Lines changed: 4 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { Event, EventHint, Exception, Integration, StackFrame as SentryStackFrame } from '@sentry/types';
2-
import { addContextToFrame, logger } from '@sentry/utils';
2+
import { logger } from '@sentry/utils';
33

44
import type { ExtendedError } from '../utils/error';
55
import { getFramesToPop, isErrorLike } from '../utils/error';
66
import type * as ReactNative from '../vendor/react-native';
7-
import { fetchSourceContext, getDevServer, parseErrorStack, symbolicateStackTrace } from './debugsymbolicatorutils';
7+
import { fetchSourceContext, parseErrorStack, symbolicateStackTrace } from './debugsymbolicatorutils';
88

99
const INTEGRATION_NAME = 'DebugSymbolicator';
1010

@@ -89,7 +89,8 @@ async function symbolicate(rawStack: string, skipFirstFrames: number = 0): Promi
8989
(frame: { file?: string }) => frame.file && frame.file.match(INTERNAL_CALLSITES_REGEX) === null,
9090
);
9191

92-
return await convertReactNativeFramesToSentryFrames(stackWithoutInternalCallsites);
92+
const sentryFrames = await convertReactNativeFramesToSentryFrames(stackWithoutInternalCallsites);
93+
return await fetchSourceContext(sentryFrames);
9394
} catch (error) {
9495
if (error instanceof Error) {
9596
logger.warn(`Unable to symbolicate stack trace: ${error.message}`);
@@ -120,10 +121,6 @@ async function convertReactNativeFramesToSentryFrames(frames: ReactNative.StackF
120121
in_app: inApp,
121122
};
122123

123-
if (inApp) {
124-
await addSourceContext(newFrame);
125-
}
126-
127124
return newFrame;
128125
}),
129126
);
@@ -151,41 +148,6 @@ function replaceThreadFramesInEvent(event: Event, frames: SentryStackFrame[]): v
151148
}
152149
}
153150

154-
/**
155-
* This tries to add source context for in_app Frames
156-
*
157-
* @param frame StackFrame
158-
* @param getDevServer function from RN to get DevServer URL
159-
*/
160-
async function addSourceContext(frame: SentryStackFrame): Promise<void> {
161-
let sourceContext: string | null = null;
162-
163-
const segments = frame.filename?.split('/') ?? [];
164-
165-
const serverUrl = getDevServer()?.url;
166-
if (!serverUrl) {
167-
return;
168-
}
169-
170-
for (const idx in segments) {
171-
if (!Object.prototype.hasOwnProperty.call(segments, idx)) {
172-
continue;
173-
}
174-
175-
sourceContext = await fetchSourceContext(serverUrl, segments, -idx);
176-
if (sourceContext) {
177-
break;
178-
}
179-
}
180-
181-
if (!sourceContext) {
182-
return;
183-
}
184-
185-
const lines = sourceContext.split('\n');
186-
addContextToFrame(lines, frame);
187-
}
188-
189151
/**
190152
* Return a list containing the original exception and also the cause if found.
191153
*

packages/core/src/js/integrations/debugsymbolicatorutils.ts

Lines changed: 44 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,61 @@
1+
import type { StackFrame as SentryStackFrame } from '@sentry/types';
2+
import { logger } from '@sentry/utils';
3+
14
import { ReactNativeLibraries } from '../utils/rnlibraries';
25
import { createStealthXhr, XHR_READYSTATE_DONE } from '../utils/xhr';
36
import type * as ReactNative from '../vendor/react-native';
47

58
/**
6-
* Get source context for segment
9+
* Fetches source context for the Sentry Middleware (/__sentry/context)
10+
*
11+
* @param frame StackFrame
12+
* @param getDevServer function from RN to get DevServer URL
713
*/
8-
export async function fetchSourceContext(url: string, segments: Array<string>, start: number): Promise<string | null> {
14+
export async function fetchSourceContext(frames: SentryStackFrame[]): Promise<SentryStackFrame[]> {
915
return new Promise(resolve => {
10-
const fullUrl = `${url}${segments.slice(start).join('/')}`;
16+
try {
17+
const xhr = createStealthXhr();
18+
if (!xhr) {
19+
resolve(frames);
20+
return;
21+
}
1122

12-
const xhr = createStealthXhr();
13-
if (!xhr) {
14-
resolve(null);
15-
return;
16-
}
23+
xhr.open('POST', getSentryMetroSourceContextUrl(), true);
24+
xhr.setRequestHeader('Content-Type', 'application/json');
25+
xhr.send(JSON.stringify({ stack: frames }));
1726

18-
xhr.open('GET', fullUrl, true);
19-
xhr.send();
27+
xhr.onreadystatechange = (): void => {
28+
if (xhr.readyState === XHR_READYSTATE_DONE) {
29+
if (xhr.status !== 200) {
30+
resolve(frames);
31+
}
2032

21-
xhr.onreadystatechange = (): void => {
22-
if (xhr.readyState === XHR_READYSTATE_DONE) {
23-
if (xhr.status !== 200) {
24-
resolve(null);
33+
try {
34+
const response: { stack?: SentryStackFrame[] } = JSON.parse(xhr.responseText);
35+
if (Array.isArray(response.stack)) {
36+
resolve(response.stack);
37+
} else {
38+
resolve(frames);
39+
}
40+
} catch (error) {
41+
resolve(frames);
42+
}
2543
}
26-
const response = xhr.responseText;
27-
if (
28-
typeof response !== 'string' ||
29-
// Expo Dev Server responses with status 200 and config JSON
30-
// when web support not enabled and requested file not found
31-
response.startsWith('{')
32-
) {
33-
resolve(null);
34-
}
35-
36-
resolve(response);
37-
}
38-
};
39-
xhr.onerror = (): void => {
40-
resolve(null);
41-
};
44+
};
45+
xhr.onerror = (): void => {
46+
resolve(frames);
47+
};
48+
} catch (error) {
49+
logger.error('Could not fetch source context.', error);
50+
resolve(frames);
51+
}
4252
});
4353
}
4454

55+
function getSentryMetroSourceContextUrl(): string {
56+
return `${getDevServer().url}__sentry/context`;
57+
}
58+
4559
/**
4660
* Loads and calls RN Core Devtools parseErrorStack function.
4761
*/
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { StackFrame } from '@sentry/types';
2+
import { addContextToFrame, logger } from '@sentry/utils';
3+
import { readFile } from 'fs';
4+
import type { IncomingMessage, ServerResponse } from 'http';
5+
import type { InputConfigT, Middleware } from 'metro-config';
6+
import { promisify } from 'util';
7+
8+
const readFileAsync = promisify(readFile);
9+
10+
/**
11+
* Accepts Sentry formatted stack frames and
12+
* adds source context to the in app frames.
13+
*/
14+
export const stackFramesContextMiddleware: Middleware = async (
15+
request: IncomingMessage,
16+
response: ServerResponse,
17+
): Promise<void> => {
18+
logger.debug('[@sentry/react-native/metro] Received request for stack frames context.');
19+
request.setEncoding('utf8');
20+
const rawBody = await getRawBody(request);
21+
22+
let body: {
23+
stack?: Partial<StackFrame>[];
24+
} = {};
25+
try {
26+
body = JSON.parse(rawBody);
27+
} catch (e) {
28+
logger.debug('[@sentry/react-native/metro] Could not parse request body.', e);
29+
badRequest(response, 'Invalid request body. Expected a JSON object.');
30+
return;
31+
}
32+
33+
const stack = body.stack;
34+
if (!Array.isArray(stack)) {
35+
logger.debug('[@sentry/react-native/metro] Invalid stack frames.', stack);
36+
badRequest(response, 'Invalid stack frames. Expected an array.');
37+
return;
38+
}
39+
40+
const stackWithSourceContext = await Promise.all(stack.map(addSourceContext));
41+
response.setHeader('Content-Type', 'application/json');
42+
response.statusCode = 200;
43+
response.end(JSON.stringify({ stack: stackWithSourceContext }));
44+
logger.debug('[@sentry/react-native/metro] Sent stack frames context.');
45+
};
46+
47+
async function addSourceContext(frame: StackFrame): Promise<StackFrame> {
48+
if (!frame.in_app) {
49+
return frame;
50+
}
51+
52+
try {
53+
if (typeof frame.filename !== 'string') {
54+
logger.warn('[@sentry/react-native/metro] Could not read source context for frame without filename.');
55+
return frame;
56+
}
57+
58+
const source = await readFileAsync(frame.filename, { encoding: 'utf8' });
59+
const lines = source.split('\n');
60+
addContextToFrame(lines, frame);
61+
} catch (error) {
62+
logger.warn('[@sentry/react-native/metro] Could not read source context for frame.', error);
63+
}
64+
return frame;
65+
}
66+
67+
function badRequest(response: ServerResponse, message: string): void {
68+
response.statusCode = 400;
69+
response.end(message);
70+
}
71+
72+
function getRawBody(request: IncomingMessage): Promise<string> {
73+
return new Promise((resolve, reject) => {
74+
let data = '';
75+
request.on('data', chunk => {
76+
data += chunk;
77+
});
78+
request.on('end', () => {
79+
resolve(data);
80+
});
81+
request.on('error', reject);
82+
});
83+
}
84+
85+
const SENTRY_MIDDLEWARE_PATH = '/__sentry';
86+
const SENTRY_CONTEXT_REQUEST_PATH = `${SENTRY_MIDDLEWARE_PATH}/context`;
87+
88+
/**
89+
* Creates a middleware that adds source context to the Sentry formatted stack frames.
90+
*/
91+
export const createSentryMetroMiddleware = (middleware: Middleware): Middleware => {
92+
return (request: IncomingMessage, response: ServerResponse, next: unknown) => {
93+
if (request.url?.startsWith(SENTRY_CONTEXT_REQUEST_PATH)) {
94+
return stackFramesContextMiddleware(request, response);
95+
}
96+
return middleware(request, response, next);
97+
};
98+
};
99+
100+
/**
101+
* Adds the Sentry middleware to the Metro server config.
102+
*/
103+
export const withSentryMiddleware = (config: InputConfigT): InputConfigT => {
104+
if (!config.server) {
105+
// @ts-expect-error server is typed read only
106+
config.server = {};
107+
}
108+
109+
const originalEnhanceMiddleware = config.server.enhanceMiddleware;
110+
config.server.enhanceMiddleware = (middleware, server) => {
111+
const sentryMiddleware = createSentryMetroMiddleware(middleware);
112+
return originalEnhanceMiddleware ? originalEnhanceMiddleware(sentryMiddleware, server) : sentryMiddleware;
113+
};
114+
return config;
115+
};

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { cleanDefaultBabelTransformerPath, saveDefaultBabelTransformerPath } fro
99
import { createSentryMetroSerializer, unstable_beforeAssetSerializationPlugin } from './sentryMetroSerializer';
1010
import type { DefaultConfigOptions } from './vendor/expo/expoconfig';
1111
export * from './sentryMetroSerializer';
12+
import { withSentryMiddleware } from './metroMiddleware';
1213

1314
enableLogger();
1415

@@ -23,6 +24,12 @@ export interface SentryMetroConfigOptions {
2324
* @default true
2425
*/
2526
includeWebReplay?: boolean;
27+
/**
28+
* Add Sentry Metro Server Middleware which
29+
* enables the app to fetch stack frames source context.
30+
* @default true
31+
*/
32+
enableSourceContextInDevelopment?: boolean;
2633
}
2734

2835
export interface SentryExpoConfigOptions {
@@ -40,7 +47,11 @@ export interface SentryExpoConfigOptions {
4047
*/
4148
export function withSentryConfig(
4249
config: MetroConfig,
43-
{ annotateReactComponents = false, includeWebReplay = true }: SentryMetroConfigOptions = {},
50+
{
51+
annotateReactComponents = false,
52+
includeWebReplay = true,
53+
enableSourceContextInDevelopment = true,
54+
}: SentryMetroConfigOptions = {},
4455
): MetroConfig {
4556
setSentryMetroDevServerEnvFlag();
4657

@@ -54,6 +65,9 @@ export function withSentryConfig(
5465
if (includeWebReplay === false) {
5566
newConfig = withSentryResolver(newConfig, includeWebReplay);
5667
}
68+
if (enableSourceContextInDevelopment) {
69+
newConfig = withSentryMiddleware(newConfig);
70+
}
5771

5872
return newConfig;
5973
}
@@ -85,6 +99,10 @@ export function getSentryExpoConfig(
8599
newConfig = withSentryResolver(newConfig, options.includeWebReplay);
86100
}
87101

102+
if (options.enableSourceContextInDevelopment ?? true) {
103+
newConfig = withSentryMiddleware(newConfig);
104+
}
105+
88106
return newConfig;
89107
}
90108

packages/core/test/integrations/debugsymbolicator.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('Debug Symbolicator Integration', () => {
2626
(getDevServer as jest.Mock).mockReturnValue(<ReactNative.DevServerInfo>{
2727
url: 'http://localhost:8081',
2828
});
29-
(fetchSourceContext as jest.Mock).mockReturnValue(Promise.resolve(null));
29+
(fetchSourceContext as jest.Mock).mockImplementation((frames: StackFrame[]) => Promise.resolve(frames));
3030
});
3131

3232
describe('parse stack', () => {

0 commit comments

Comments
 (0)