diff --git a/CHANGELOG.md b/CHANGELOG.md index 592d58402e..b130722ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## 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 diff --git a/packages/core/src/js/integrations/debugsymbolicatorutils.ts b/packages/core/src/js/integrations/debugsymbolicatorutils.ts index 3ce166d124..849056abaf 100644 --- a/packages/core/src/js/integrations/debugsymbolicatorutils.ts +++ b/packages/core/src/js/integrations/debugsymbolicatorutils.ts @@ -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'; @@ -69,7 +70,7 @@ function getSentryMetroSourceContextUrl(): string | undefined { return undefined; } - return `${devServer.url}__sentry/context`; + return `${devServer.url}${SENTRY_CONTEXT_REQUEST_PATH}`; } /** @@ -135,16 +136,76 @@ export function parseErrorStack(errorStack: string): Array, extraData?: Record, ): Promise { - 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, + extraData?: Record, +): Promise { + 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); + } + }); } /** diff --git a/packages/core/src/js/metro/constants.ts b/packages/core/src/js/metro/constants.ts index 0971099477..9bb1ad903d 100644 --- a/packages/core/src/js/metro/constants.ts +++ b/packages/core/src/js/metro/constants.ts @@ -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'; diff --git a/packages/core/src/js/tools/metroMiddleware.ts b/packages/core/src/js/tools/metroMiddleware.ts index 14c9ca3422..a6b0e4ef0f 100644 --- a/packages/core/src/js/tools/metroMiddleware.ts +++ b/packages/core/src/js/tools/metroMiddleware.ts @@ -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'; @@ -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. */ @@ -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, diff --git a/packages/core/test/integrations/debugsymbolicatorutils.test.ts b/packages/core/test/integrations/debugsymbolicatorutils.test.ts index 25f9ef02ac..417aa73334 100644 --- a/packages/core/test/integrations/debugsymbolicatorutils.test.ts +++ b/packages/core/test/integrations/debugsymbolicatorutils.test.ts @@ -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', () => { @@ -66,3 +71,151 @@ describe('parseErrorStack', () => { expect(Array.isArray(frames)).toBe(true); }); }); + +describe('symbolicateStackTrace', () => { + const stack: Array = [ + { 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 { + return new Promise(resolve => setTimeout(resolve, 0)); +} diff --git a/packages/core/test/tools/metroMiddleware.test.ts b/packages/core/test/tools/metroMiddleware.test.ts index ab93ce8171..896cfc01e3 100644 --- a/packages/core/test/tools/metroMiddleware.test.ts +++ b/packages/core/test/tools/metroMiddleware.test.ts @@ -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]);