From 37edf26fa350735f854632cd844d2c8ce979d4a7 Mon Sep 17 00:00:00 2001 From: LucasZF Date: Tue, 10 Sep 2024 12:04:14 -0300 Subject: [PATCH] feat: Add exclude Web Replay option to Metro plugin (#4006) * soft rollback later * add sentry exclude for replay * changelog * clear tests * clearup PT2 * mergeConfig not compatible with old RN 0.65.3 * add changelog snippet, also remove it on expo * Update CHANGELOG.md Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> * Update src/js/tools/metroconfig.ts Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> * add tests and requested changes * changelog * fix * bad merge * Update CHANGELOG.md change to feature * requested changes * lint fix * test disable code * test compatibility with old metro * add tests for old metro * unified tests and replay check * add test for null platform * test resolveRequest as null * document why null * nit * test refactor * add error * force error on all versions. * test console error. * console error ok, logger logs? * logger doesnt work, should we brek the process? * final tests * yran fix and set default to true * Apply suggestions from code review * Update src/js/tools/metroconfig.ts --------- Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> --- .gitignore | 3 + CHANGELOG.md | 18 ++++ src/js/tools/metroconfig.ts | 76 ++++++++++++- test/tools/metroconfig.test.ts | 191 ++++++++++++++++++++++++++++++++- 4 files changed, 285 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 0fd72ba7b..e10f2c3b6 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,9 @@ wheelhouse .yalc/ yalc.lock +# Yarn +.yarn + # E2E tests test/react-native/versions node_modules.bak diff --git a/CHANGELOG.md b/CHANGELOG.md index 39f3ec22c..168c83891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## Unreleased + +### Features + +- Exclude Sentry Web Replay by default, reducing the code in 130KB. ([#4006](https://github.com/getsentry/sentry-react-native/pull/4006)) + - You can keep Sentry Web Replay by setting `includeWebReplay` to `true` in your metro config as shown in the snippet: + + ```js + // For Expo + const { getSentryExpoConfig } = require("@sentry/react-native/metro"); + const config = getSentryExpoConfig(__dirname, { includeWebReplay: true }); + + // For RN + const { getDefaultConfig } = require('@react-native/metro-config'); + const { withSentryConfig } = require('@sentry/react-native/metro'); + module.exports = withSentryConfig(getDefaultConfig(__dirname), { includeWebReplay: true }); + ``` + ## 5.31.1 ### Fixes diff --git a/src/js/tools/metroconfig.ts b/src/js/tools/metroconfig.ts index 6e5854475..4235f38e4 100644 --- a/src/js/tools/metroconfig.ts +++ b/src/js/tools/metroconfig.ts @@ -1,5 +1,6 @@ import { logger } from '@sentry/utils'; import type { MetroConfig, MixedOutput, Module, ReadOnlyGraph } from 'metro'; +import type { CustomResolutionContext, CustomResolver, Resolution } from 'metro-resolver'; import * as process from 'process'; import { env } from 'process'; @@ -7,7 +8,6 @@ import { enableLogger } from './enableLogger'; import { cleanDefaultBabelTransformerPath, saveDefaultBabelTransformerPath } from './sentryBabelTransformerUtils'; import { createSentryMetroSerializer, unstable_beforeAssetSerializationPlugin } from './sentryMetroSerializer'; import type { DefaultConfigOptions } from './vendor/expo/expoconfig'; - export * from './sentryMetroSerializer'; enableLogger(); @@ -18,6 +18,11 @@ export interface SentryMetroConfigOptions { * @default false */ annotateReactComponents?: boolean; + /** + * Adds the Sentry replay package for web. + * @default true + */ + includeWebReplay?: boolean; } export interface SentryExpoConfigOptions { @@ -35,7 +40,7 @@ export interface SentryExpoConfigOptions { */ export function withSentryConfig( config: MetroConfig, - { annotateReactComponents = false }: SentryMetroConfigOptions = {}, + { annotateReactComponents = false, includeWebReplay = true }: SentryMetroConfigOptions = {}, ): MetroConfig { setSentryMetroDevServerEnvFlag(); @@ -46,6 +51,9 @@ export function withSentryConfig( if (annotateReactComponents) { newConfig = withSentryBabelTransformer(newConfig); } + if (includeWebReplay === false) { + newConfig = withSentryResolver(newConfig, includeWebReplay); + } return newConfig; } @@ -73,6 +81,10 @@ export function getSentryExpoConfig( newConfig = withSentryBabelTransformer(newConfig); } + if (options.includeWebReplay === false) { + newConfig = withSentryResolver(newConfig, options.includeWebReplay); + } + return newConfig; } @@ -146,6 +158,66 @@ function withSentryDebugId(config: MetroConfig): MetroConfig { }; } +// Based on: https://github.com/facebook/metro/blob/c21daba415ea26511e157f794689caab9abe8236/packages/metro-resolver/src/resolve.js#L86-L91 +type CustomResolverBeforeMetro068 = ( + context: CustomResolutionContext, + realModuleName: string, + platform: string | null, + moduleName?: string, +) => Resolution; + +/** + * Includes `@sentry/replay` packages based on the `includeWebReplay` flag and current bundle `platform`. + */ +export function withSentryResolver(config: MetroConfig, includeWebReplay: boolean | undefined): MetroConfig { + const originalResolver = config.resolver?.resolveRequest as CustomResolver | CustomResolverBeforeMetro068 | undefined; + + const sentryResolverRequest: CustomResolver = ( + context: CustomResolutionContext, + moduleName: string, + platform: string | null, + oldMetroModuleName?: string, + ) => { + if ( + (includeWebReplay === false || + (includeWebReplay === undefined && (platform === 'android' || platform === 'ios'))) && + (oldMetroModuleName ?? moduleName).includes('@sentry/replay') + ) { + return { type: 'empty' } as Resolution; + } + if (originalResolver) { + return oldMetroModuleName + ? originalResolver(context, moduleName, platform, oldMetroModuleName) + : originalResolver(context, moduleName, platform); + } + + // Prior 0.68, resolve context.resolveRequest is sentryResolver itself, where on later version it is the default resolver. + if (context.resolveRequest === sentryResolverRequest) { + // eslint-disable-next-line no-console + console.error( + `Error: [@sentry/react-native/metro] Can not resolve the defaultResolver on Metro older than 0.68. +Please follow one of the following options: +- Include your resolverRequest on your metroconfig. +- Update your Metro version to 0.68 or higher. +- Set includeWebReplay as true on your metro config. +- If you are still facing issues, report the issue at http://www.github.com/getsentry/sentry-react-native/issues`, + ); + // Return required for test. + return process.exit(-1); + } + + return context.resolveRequest(context, moduleName, platform); + }; + + return { + ...config, + resolver: { + ...config.resolver, + resolveRequest: sentryResolverRequest, + }, + }; +} + type MetroFrame = Parameters['symbolicator']>['customizeFrame']>[0]; type MetroCustomizeFrame = { readonly collapse?: boolean }; type MetroCustomizeFrameReturnValue = diff --git a/test/tools/metroconfig.test.ts b/test/tools/metroconfig.test.ts index a0ee9533f..9913d6eec 100644 --- a/test/tools/metroconfig.test.ts +++ b/test/tools/metroconfig.test.ts @@ -11,7 +11,11 @@ import type { MetroConfig } from 'metro'; import * as path from 'path'; import * as process from 'process'; -import { withSentryBabelTransformer, withSentryFramesCollapsed } from '../../src/js/tools/metroconfig'; +import { + withSentryBabelTransformer, + withSentryFramesCollapsed, + withSentryResolver, +} from '../../src/js/tools/metroconfig'; type MetroFrame = Parameters['symbolicator']>['customizeFrame']>[0]; @@ -108,6 +112,191 @@ describe('metroconfig', () => { ); }); }); + + describe('withSentryResolver', () => { + let originalResolverMock: any; + + // @ts-expect-error Can't see type CustomResolutionContext + let contextMock: CustomResolutionContext; + let config: MetroConfig = {}; + + beforeEach(() => { + originalResolverMock = jest.fn(); + contextMock = { + resolveRequest: jest.fn(), + }; + + config = { + resolver: { + resolveRequest: originalResolverMock, + }, + }; + }); + + describe.each([ + ['new Metro', false, '0.70.0'], + ['old Metro', true, '0.67.0'], + ])(`on %s`, (description, oldMetro, metroVersion) => { + beforeEach(() => { + jest.resetModules(); + // Mock metro/package.json + jest.mock('metro/package.json', () => ({ + version: metroVersion, + })); + }); + + test('keep Web Replay when platform is web and includeWebReplay is true', () => { + const modifiedConfig = withSentryResolver(config, true); + resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'web'); + + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, '@sentry/replay', 'web'); + }); + + test('removes Web Replay when platform is web and includeWebReplay is false', () => { + const modifiedConfig = withSentryResolver(config, false); + const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'web'); + + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); + + test('keep Web Replay when platform is android and includeWebReplay is true', () => { + const modifiedConfig = withSentryResolver(config, true); + resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'android'); + + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, '@sentry/replay', 'android'); + }); + + test('removes Web Replay when platform is android and includeWebReplay is false', () => { + const modifiedConfig = withSentryResolver(config, false); + const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'android'); + + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); + + test('removes Web Replay when platform is android and includeWebReplay is undefined', () => { + const modifiedConfig = withSentryResolver(config, undefined); + const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'android'); + + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); + + test('keep Web Replay when platform is undefined and includeWebReplay is null', () => { + const modifiedConfig = withSentryResolver(config, undefined); + resolveRequest(modifiedConfig, contextMock, '@sentry/replay', null); + + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, '@sentry/replay', null); + }); + + test('keep Web Replay when platform is ios and includeWebReplay is true', () => { + const modifiedConfig = withSentryResolver(config, true); + resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'ios'); + + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, '@sentry/replay', 'ios'); + }); + + test('removes Web Replay when platform is ios and includeWebReplay is false', () => { + const modifiedConfig = withSentryResolver(config, false); + const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'ios'); + + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); + + test('removes Web Replay when platform is ios and includeWebReplay is undefined', () => { + const modifiedConfig = withSentryResolver(config, undefined); + const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'ios'); + + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); + + test('calls originalResolver when moduleName is not @sentry/replay', () => { + const modifiedConfig = withSentryResolver(config, true); + const moduleName = 'some/other/module'; + resolveRequest(modifiedConfig, contextMock, moduleName, 'web'); + + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, moduleName, 'web'); + }); + + test('calls originalResolver when moduleName is not @sentry/replay and includeWebReplay set to false', () => { + const modifiedConfig = withSentryResolver(config, false); + const moduleName = 'some/other/module'; + resolveRequest(modifiedConfig, contextMock, moduleName, 'web'); + + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, moduleName, 'web'); + }); + + test('calls default resolver on new metro resolver when originalResolver is not provided', () => { + if (oldMetro) { + return; + } + + const modifiedConfig = withSentryResolver({ resolver: {} }, true); + const moduleName = 'some/other/module'; + const platform = 'web'; + resolveRequest(modifiedConfig, contextMock, moduleName, platform); + + ExpectToBeCalledWithMetroParameters(contextMock.resolveRequest, contextMock, moduleName, platform); + }); + + test('throws error when running on old metro and includeWebReplay is set to false', () => { + if (!oldMetro) { + return; + } + + // @ts-expect-error mock. + const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); + const modifiedConfig = withSentryResolver({ resolver: {} }, true); + const moduleName = 'some/other/module'; + resolveRequest(modifiedConfig, contextMock, moduleName, 'web'); + + expect(mockExit).toHaveBeenCalledWith(-1); + }); + + type CustomResolverBeforeMetro067 = ( + // @ts-expect-error Can't see type CustomResolutionContext + context: CustomResolutionContext, + realModuleName: string, + platform: string | null, + moduleName?: string, + // @ts-expect-error Can't see type CustomResolutionContext + ) => Resolution; + + function resolveRequest( + metroConfig: MetroConfig, + context: any, + moduleName: string, + platform: string | null, + // @ts-expect-error Can't see type Resolution. + ): Resolution { + if (oldMetro) { + const resolver = metroConfig.resolver?.resolveRequest as CustomResolverBeforeMetro067; + // On older Metro the resolveRequest is the creater resolver. + context.resolveRequest = resolver; + return resolver(context, `real${moduleName}`, platform, moduleName); + } + return ( + metroConfig.resolver?.resolveRequest && metroConfig.resolver.resolveRequest(context, moduleName, platform) + ); + } + + function ExpectToBeCalledWithMetroParameters( + received: CustomResolverBeforeMetro067, + contextMock: CustomResolverBeforeMetro067, + moduleName: string, + platform: string | null, + ) { + if (oldMetro) { + expect(received).toBeCalledWith(contextMock, `real${moduleName}`, platform, moduleName); + } else { + expect(received).toBeCalledWith(contextMock, moduleName, platform); + } + } + }); + }); }); // function create mock metro frame