diff --git a/CHANGELOG.md b/CHANGELOG.md index 47145a41d4..42420eae55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) - Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow. See [Network Details](https://docs.sentry.io/platforms/react-native/session-replay/#network-details) for details. ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) +- Use the optional `react-native-quick-base64` peer dependency for envelope base64 encoding when installed, to improve `captureEnvelope` performance. Falls back to the bundled JS encoder if the package is absent. ([#6314](https://github.com/getsentry/sentry-react-native/pull/6314)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) ### Fixes diff --git a/packages/core/package.json b/packages/core/package.json index 34626fe6b4..4ad5f9bfac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -70,7 +70,8 @@ "peerDependencies": { "expo": ">=49.0.0", "react": ">=17.0.0", - "react-native": ">=0.65.0" + "react-native": ">=0.65.0", + "react-native-quick-base64": ">=3.0.0" }, "dependencies": { "@sentry/babel-plugin-component-annotate": "5.3.0", @@ -130,6 +131,9 @@ "peerDependenciesMeta": { "expo": { "optional": true + }, + "react-native-quick-base64": { + "optional": true } } } diff --git a/packages/core/src/js/utils/base64.ts b/packages/core/src/js/utils/base64.ts new file mode 100644 index 0000000000..e4c55456bf --- /dev/null +++ b/packages/core/src/js/utils/base64.ts @@ -0,0 +1,56 @@ +import { debug } from '@sentry/core'; + +import { base64StringFromByteArray } from '../vendor'; + +type FromByteArray = (bytes: Uint8Array, urlSafe?: boolean) => string; + +let cachedEncoder: FromByteArray | null = null; +let resolved = false; + +/** + * Resolves the base64 encoder once. If the optional peer dependency + * `react-native-quick-base64` is installed, its native JSI encoder is used + * (~10x faster than the pure-JS fallback). Otherwise the bundled JS encoder + * from `vendor/base64-js` is used. + * + * The resolution is cached so the require cost is paid at most once. + */ +function resolveEncoder(): FromByteArray { + if (resolved) { + return cachedEncoder ?? base64StringFromByteArray; + } + resolved = true; + + try { + // Optional peer dependency — only loaded if the consumer installed it. + // eslint-disable-next-line @typescript-eslint/no-var-requires, import/no-extraneous-dependencies + const quickBase64 = require('react-native-quick-base64') as { fromByteArray?: FromByteArray }; + if (quickBase64 && typeof quickBase64.fromByteArray === 'function') { + cachedEncoder = quickBase64.fromByteArray; + debug.log('Using react-native-quick-base64 for envelope encoding.'); + return cachedEncoder; + } + } catch (_e) { + // Not installed — fall through to JS encoder. + } + + cachedEncoder = base64StringFromByteArray; + return cachedEncoder; +} + +/** + * Encode a byte array to a base64 string. Prefers the native + * `react-native-quick-base64` encoder when available, otherwise uses the + * bundled JS implementation. + */ +export function encodeToBase64(input: Uint8Array): string { + return resolveEncoder()(input); +} + +/** + * @internal Test helper. Resets the cached encoder so the next call re-resolves. + */ +export function _resetBase64EncoderForTesting(): void { + cachedEncoder = null; + resolved = false; +} diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index d4225740d6..f53cfa5726 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -30,11 +30,11 @@ import type { MobileReplayOptions } from './replay/mobilereplay'; import type { RequiredKeysUser } from './user'; import { isHardCrash } from './misc'; +import { encodeToBase64 } from './utils/base64'; import { encodeUTF8 } from './utils/encode'; import { isTurboModuleEnabled } from './utils/environment'; import { convertToNormalizedObject } from './utils/normalize'; import { ReactNativeLibraries } from './utils/rnlibraries'; -import { base64StringFromByteArray } from './vendor'; import { SDK_VERSION } from './version'; /** @@ -231,7 +231,7 @@ export const NATIVE: SentryNativeWrapper = { envelopeBytes = newBytes; } - await RNSentry.captureEnvelope(base64StringFromByteArray(envelopeBytes), { hardCrashed }); + await RNSentry.captureEnvelope(encodeToBase64(envelopeBytes), { hardCrashed }); }, /** diff --git a/packages/core/test/utils/base64.test.ts b/packages/core/test/utils/base64.test.ts new file mode 100644 index 0000000000..9305f546aa --- /dev/null +++ b/packages/core/test/utils/base64.test.ts @@ -0,0 +1,43 @@ +import { _resetBase64EncoderForTesting, encodeToBase64 } from '../../src/js/utils/base64'; + +jest.mock( + 'react-native-quick-base64', + () => ({ + __esModule: true, + fromByteArray: jest.fn((bytes: Uint8Array) => `quick:${bytes.length}`), + }), + { virtual: true }, +); + +describe('encodeToBase64', () => { + beforeEach(() => { + _resetBase64EncoderForTesting(); + jest.resetModules(); + }); + + test('uses react-native-quick-base64 when available', () => { + // The module mock above provides a fake `fromByteArray` returning a sentinel. + const result = encodeToBase64(new Uint8Array([0x73, 0x65, 0x6e, 0x74, 0x72, 0x79])); + expect(result).toBe('quick:6'); + }); + + test('falls back to the JS encoder when react-native-quick-base64 is not installed', () => { + jest.isolateModules(() => { + jest.doMock( + 'react-native-quick-base64', + () => { + throw new Error('Cannot find module'); + }, + { virtual: true }, + ); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { + encodeToBase64: isolatedEncode, + _resetBase64EncoderForTesting: isolatedReset, + } = require('../../src/js/utils/base64'); + isolatedReset(); + // "sentry" => "c2VudHJ5" + expect(isolatedEncode(new Uint8Array([0x73, 0x65, 0x6e, 0x74, 0x72, 0x79]))).toBe('c2VudHJ5'); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index a5beec674d..9d57f24b17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10931,9 +10931,12 @@ __metadata: expo: ">=49.0.0" react: ">=17.0.0" react-native: ">=0.65.0" + react-native-quick-base64: ">=3.0.0" peerDependenciesMeta: expo: optional: true + react-native-quick-base64: + optional: true bin: sentry-eas-build-on-complete: scripts/eas-build-hook.js sentry-eas-build-on-error: scripts/eas-build-hook.js