Skip to content

Commit

Permalink
feat: Add exclude Web Replay option to Metro plugin (#4006)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Update src/js/tools/metroconfig.ts

Co-authored-by: Krystof Woldrich <[email protected]>

* 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 <[email protected]>
  • Loading branch information
lucas-zimerman and krystofwoldrich committed Sep 10, 2024
1 parent c2a4e9b commit 37edf26
Show file tree
Hide file tree
Showing 4 changed files with 285 additions and 3 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ wheelhouse
.yalc/
yalc.lock

# Yarn
.yarn

# E2E tests
test/react-native/versions
node_modules.bak
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
76 changes: 74 additions & 2 deletions src/js/tools/metroconfig.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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';

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();
Expand All @@ -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 {
Expand All @@ -35,7 +40,7 @@ export interface SentryExpoConfigOptions {
*/
export function withSentryConfig(
config: MetroConfig,
{ annotateReactComponents = false }: SentryMetroConfigOptions = {},
{ annotateReactComponents = false, includeWebReplay = true }: SentryMetroConfigOptions = {},
): MetroConfig {
setSentryMetroDevServerEnvFlag();

Expand All @@ -46,6 +51,9 @@ export function withSentryConfig(
if (annotateReactComponents) {
newConfig = withSentryBabelTransformer(newConfig);
}
if (includeWebReplay === false) {
newConfig = withSentryResolver(newConfig, includeWebReplay);
}

return newConfig;
}
Expand Down Expand Up @@ -73,6 +81,10 @@ export function getSentryExpoConfig(
newConfig = withSentryBabelTransformer(newConfig);
}

if (options.includeWebReplay === false) {
newConfig = withSentryResolver(newConfig, options.includeWebReplay);
}

return newConfig;
}

Expand Down Expand Up @@ -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<Required<Required<MetroConfig>['symbolicator']>['customizeFrame']>[0];
type MetroCustomizeFrame = { readonly collapse?: boolean };
type MetroCustomizeFrameReturnValue =
Expand Down
191 changes: 190 additions & 1 deletion test/tools/metroconfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Required<Required<MetroConfig>['symbolicator']>['customizeFrame']>[0];

Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 37edf26

Please sign in to comment.