Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
<!-- prettier-ignore-end -->

## Unreleased

### Internal

- Fall back to the Sentry Metro middleware (`/__sentry/symbolicate`) for local debug symbolication when React Native's internal `symbolicateStackTrace` is not available ([#7031](https://github.com/getsentry/sentry-react-native/pull/7031))

## 8.35.0

### Features
Expand Down
73 changes: 67 additions & 6 deletions packages/core/src/js/integrations/debugsymbolicatorutils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { defaultStackParser } from '@sentry/react';

import type * as ReactNative from '../vendor/react-native';

import { SENTRY_CONTEXT_REQUEST_PATH, SENTRY_SYMBOLICATE_REQUEST_PATH } from '../metro/constants';
import { ReactNativeLibraries } from '../utils/rnlibraries';
import { createStealthXhr, XHR_READYSTATE_DONE } from '../utils/xhr';

Expand Down Expand Up @@ -69,7 +70,7 @@ function getSentryMetroSourceContextUrl(): string | undefined {
return undefined;
}

return `${devServer.url}__sentry/context`;
return `${devServer.url}${SENTRY_CONTEXT_REQUEST_PATH}`;
}

/**
Expand Down Expand Up @@ -135,16 +136,76 @@ export function parseErrorStack(errorStack: string): Array<ReactNative.StackFram
}

/**
* Loads and calls RN Core Devtools symbolicateStackTrace function.
* Symbolicates the stack using the local dev server.
*
* Uses RN Devtools symbolicateStackTrace by default for compatibility.
* Falls back to the Sentry Metro middleware (/__sentry/symbolicate) if Devtools
* is not available or fails, so we don't depend on RN internals that are
* scheduled for removal from the RN public API.
*/
export function symbolicateStackTrace(
export async function symbolicateStackTrace(
stack: Array<ReactNative.StackFrame>,
extraData?: Record<string, unknown>,
): Promise<ReactNative.SymbolicatedStackTrace> {
if (!ReactNativeLibraries.Devtools) {
throw new Error('React Native Devtools not available.');
if (ReactNativeLibraries.Devtools?.symbolicateStackTrace) {
try {
return await ReactNativeLibraries.Devtools.symbolicateStackTrace(stack, extraData);
} catch (error) {
debug.warn('RN Devtools symbolicateStackTrace failed, falling back to Sentry Metro middleware.');
}
}
return ReactNativeLibraries.Devtools.symbolicateStackTrace(stack, extraData);

return symbolicateStackTraceWithSentryMiddleware(stack, extraData);
}

/**
* Symbolicates the stack using the Sentry Metro middleware (/__sentry/symbolicate),
* which forwards the request to Metro's own `/symbolicate` endpoint.
*
* Rejects if the dev server is not available or the request fails.
*/
function symbolicateStackTraceWithSentryMiddleware(
stack: Array<ReactNative.StackFrame>,
extraData?: Record<string, unknown>,
): Promise<ReactNative.SymbolicatedStackTrace> {
return new Promise((resolve, reject) => {
try {
const devServer = getDevServer();
if (!devServer?.bundleLoadedFromServer) {
reject(new Error('Bundle was not loaded from Metro.'));
return;
}

const xhr = createStealthXhr();
if (!xhr) {
reject(new Error('XMLHttpRequest is not available.'));
return;
}

xhr.open('POST', `${devServer.url}${SENTRY_SYMBOLICATE_REQUEST_PATH}`, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = (): void => {
if (xhr.readyState !== XHR_READYSTATE_DONE) {
return;
}
if (xhr.status !== 200) {
reject(new Error(`Symbolication request failed with status ${xhr.status}.`));
return;
}
try {
resolve(JSON.parse(xhr.responseText));
} catch (error) {
reject(new Error('Could not parse symbolication response.'));
}
};
xhr.onerror = (): void => {
reject(new Error('Symbolication request failed.'));
};
xhr.send(JSON.stringify({ stack, extraData }));
} catch (error) {
reject(error);
}
});
}

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/js/metro/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
const SENTRY_MIDDLEWARE_PATH = '__sentry';
export const SENTRY_CONTEXT_REQUEST_PATH = `${SENTRY_MIDDLEWARE_PATH}/context`;
export const SENTRY_OPEN_URL_REQUEST_PATH = `${SENTRY_MIDDLEWARE_PATH}/open-url`;
export const SENTRY_SYMBOLICATE_REQUEST_PATH = `${SENTRY_MIDDLEWARE_PATH}/symbolicate`;

/** Metro's own symbolication endpoint, which `SENTRY_SYMBOLICATE_REQUEST_PATH` forwards to. */
export const METRO_SYMBOLICATE_REQUEST_PATH = 'symbolicate';
19 changes: 18 additions & 1 deletion packages/core/src/js/tools/metroMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { readFile, realpath, realpathSync } from 'fs';
import * as path from 'path';
import { promisify } from 'util';

import { SENTRY_CONTEXT_REQUEST_PATH, SENTRY_OPEN_URL_REQUEST_PATH } from '../metro/constants';
import {
METRO_SYMBOLICATE_REQUEST_PATH,
SENTRY_CONTEXT_REQUEST_PATH,
SENTRY_OPEN_URL_REQUEST_PATH,
SENTRY_SYMBOLICATE_REQUEST_PATH,
} from '../metro/constants';
import { getRawBody } from '../metro/getRawBody';
import { openURLMiddleware } from '../metro/openUrlMiddleware';

Expand Down Expand Up @@ -112,6 +117,13 @@ function badRequest(response: ServerResponse, message: string): void {
response.end(message);
}

/**
* Rewrites `/__sentry/symbolicate[?query]` to Metro's `/symbolicate[?query]`.
*/
function rewriteSymbolicateUrl(url: string): string {
return `/${METRO_SYMBOLICATE_REQUEST_PATH}${url.slice(SENTRY_SYMBOLICATE_REQUEST_PATH.length + 1)}`;
}

/**
* Creates a middleware that adds source context to the Sentry formatted stack frames.
*/
Expand All @@ -126,6 +138,11 @@ export const createSentryMetroMiddleware = (middleware: Middleware, allowedRoots
return stackFramesContextMiddleware(request, response, next);
} else if (request.url?.startsWith(`/${SENTRY_OPEN_URL_REQUEST_PATH}`)) {
return openURLMiddleware(request, response);
} else if (request.url?.startsWith(`/${SENTRY_SYMBOLICATE_REQUEST_PATH}`)) {
// Forward to Metro's own `/symbolicate` handler, so the SDK doesn't depend on
// RN's internal `symbolicateStackTrace` (scheduled for removal from RN's deep imports).
// The request body is not consumed here, Metro reads it as usual.
request.url = rewriteSymbolicateUrl(request.url);
}
return (middleware as (req: IncomingMessage, res: ServerResponse, next: () => void) => void)(
request,
Expand Down
155 changes: 154 additions & 1 deletion packages/core/test/integrations/debugsymbolicatorutils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { parseErrorStack } from '../../src/js/integrations/debugsymbolicatorutils';
import type * as ReactNative from '../../src/js/vendor/react-native';

import { parseErrorStack, symbolicateStackTrace } from '../../src/js/integrations/debugsymbolicatorutils';
import { ReactNativeLibraries } from '../../src/js/utils/rnlibraries';
import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide';
import { XHR_READYSTATE_DONE } from '../../src/js/utils/xhr';

describe('parseErrorStack', () => {
it('should parse Chrome-style stack trace', () => {
Expand Down Expand Up @@ -66,3 +71,151 @@ describe('parseErrorStack', () => {
expect(Array.isArray(frames)).toBe(true);
});
});

describe('symbolicateStackTrace', () => {
const stack: Array<ReactNative.StackFrame> = [
{ methodName: 'foo', file: 'http://localhost:8081/index.bundle', lineNumber: 1, column: 1 },
];
const symbolicated: ReactNative.SymbolicatedStackTrace = {
stack: [{ methodName: 'foo', file: '/app/src/foo.ts', lineNumber: 10, column: 5 }],
};

let originalDevtools: typeof ReactNativeLibraries.Devtools;
let originalXMLHttpRequest: typeof XMLHttpRequest | undefined;
let mockXhr: MockXhr;

beforeEach(() => {
originalDevtools = ReactNativeLibraries.Devtools;
originalXMLHttpRequest = RN_GLOBAL_OBJ.XMLHttpRequest;

mockXhr = createMockXhr();
RN_GLOBAL_OBJ.XMLHttpRequest = jest.fn(() => mockXhr) as unknown as typeof XMLHttpRequest;
});

afterEach(() => {
ReactNativeLibraries.Devtools = originalDevtools;
RN_GLOBAL_OBJ.XMLHttpRequest = originalXMLHttpRequest;
});

it('uses RN Devtools symbolicateStackTrace when available', async () => {
const devtoolsSymbolicate = jest.fn(() => Promise.resolve(symbolicated));
ReactNativeLibraries.Devtools = mockDevtools({ symbolicateStackTrace: devtoolsSymbolicate });

const result = await symbolicateStackTrace(stack, { extra: 'data' });

expect(result).toEqual(symbolicated);
expect(devtoolsSymbolicate).toHaveBeenCalledWith(stack, { extra: 'data' });
expect(mockXhr.open).not.toHaveBeenCalled();
});

it('falls back to the Sentry middleware when RN Devtools symbolicateStackTrace throws', async () => {
ReactNativeLibraries.Devtools = mockDevtools({
symbolicateStackTrace: jest.fn(() => {
throw new Error("Cannot find module 'react-native/Libraries/Core/Devtools/symbolicateStackTrace'");
}),
});

const result = symbolicateStackTrace(stack, { extra: 'data' });
mockXhr.respond(200, JSON.stringify(symbolicated));

await expect(result).resolves.toEqual(symbolicated);
expect(mockXhr.open).toHaveBeenCalledWith('POST', 'http://localhost:8081/__sentry/symbolicate', true);
expect(mockXhr.send).toHaveBeenCalledWith(JSON.stringify({ stack, extraData: { extra: 'data' } }));
});

it('falls back to the Sentry middleware when RN Devtools symbolicateStackTrace rejects', async () => {
ReactNativeLibraries.Devtools = mockDevtools({
symbolicateStackTrace: jest.fn(() => Promise.reject(new Error('Network error'))),
});

const result = symbolicateStackTrace(stack);
await flushPromises();
mockXhr.respond(200, JSON.stringify(symbolicated));

await expect(result).resolves.toEqual(symbolicated);
expect(mockXhr.open).toHaveBeenCalledWith('POST', 'http://localhost:8081/__sentry/symbolicate', true);
});

it('rejects when the Sentry middleware responds with non 200 status', async () => {
ReactNativeLibraries.Devtools = mockDevtools({ symbolicateStackTrace: undefined });

const result = symbolicateStackTrace(stack);
mockXhr.respond(404, 'Not Found');

await expect(result).rejects.toThrow('Symbolication request failed with status 404.');
});

it('rejects when the Sentry middleware responds with invalid JSON', async () => {
ReactNativeLibraries.Devtools = mockDevtools({ symbolicateStackTrace: undefined });

const result = symbolicateStackTrace(stack);
mockXhr.respond(200, 'not json');

await expect(result).rejects.toThrow('Could not parse symbolication response.');
});

it('rejects when the Sentry middleware request errors', async () => {
ReactNativeLibraries.Devtools = mockDevtools({ symbolicateStackTrace: undefined });

const result = symbolicateStackTrace(stack);
(mockXhr.onerror as unknown as () => void)();

await expect(result).rejects.toThrow('Symbolication request failed.');
});

it('rejects when the bundle was not loaded from Metro', async () => {
ReactNativeLibraries.Devtools = mockDevtools({
symbolicateStackTrace: undefined,
bundleLoadedFromServer: false,
});

await expect(symbolicateStackTrace(stack)).rejects.toThrow('Bundle was not loaded from Metro.');
expect(mockXhr.open).not.toHaveBeenCalled();
});

it('rejects when RN Devtools are not available', async () => {
ReactNativeLibraries.Devtools = undefined;

await expect(symbolicateStackTrace(stack)).rejects.toThrow('Bundle was not loaded from Metro.');
expect(mockXhr.open).not.toHaveBeenCalled();
});
});

type MockXhr = XMLHttpRequest & { respond: (status: number, responseText: string) => void };

function createMockXhr(): MockXhr {
const xhr = {
readyState: 0,
status: 0,
responseText: '',
open: jest.fn(),
send: jest.fn(),
setRequestHeader: jest.fn(),
onreadystatechange: null,
onerror: null,
respond(status: number, responseText: string): void {
xhr.readyState = XHR_READYSTATE_DONE;
xhr.status = status;
xhr.responseText = responseText;
(xhr.onreadystatechange as unknown as () => void)?.();
},
};
return xhr as unknown as MockXhr;
}

function mockDevtools({
symbolicateStackTrace,
bundleLoadedFromServer = true,
}: {
symbolicateStackTrace: jest.Mock | undefined;
bundleLoadedFromServer?: boolean;
}): typeof ReactNativeLibraries.Devtools {
return {
symbolicateStackTrace,
getDevServer: () => ({ url: 'http://localhost:8081/', bundleLoadedFromServer }),
} as typeof ReactNativeLibraries.Devtools;
}

function flushPromises(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0));
}
24 changes: 24 additions & 0 deletions packages/core/test/tools/metroMiddleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,30 @@ describe('metroMiddleware', () => {
spiedOpenURLMiddleware.mockRestore();
});

it('should forward sentry symbolicate requests to metro symbolicate', () => {
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware, [TEST_PROJECT_ROOT]);

const symbolicateRequest = {
url: '/__sentry/symbolicate',
} as any;
testedMiddleware(symbolicateRequest, response, next);
expect(symbolicateRequest.url).toBe('/symbolicate');
expect(defaultMiddleware).toHaveBeenCalledWith(symbolicateRequest, response, next);
expect(defaultMiddleware).toHaveBeenCalledTimes(1);
expect(mockedStackFramesContextMiddleware).not.toHaveBeenCalled();
});

it('should preserve query when forwarding sentry symbolicate requests', () => {
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware, [TEST_PROJECT_ROOT]);

const symbolicateRequest = {
url: '/__sentry/symbolicate?foo=bar',
} as any;
testedMiddleware(symbolicateRequest, response, next);
expect(symbolicateRequest.url).toBe('/symbolicate?foo=bar');
expect(defaultMiddleware).toHaveBeenCalledWith(symbolicateRequest, response, next);
});

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

Expand Down
Loading