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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
<!-- prettier-ignore-end -->

## Unreleased

### Features

- Add the `applicationKey` and `moduleMetadata` options to `withSentryConfig` and `getSentryExpoConfig`, and export `thirdPartyErrorFilterIntegration` ([#6863](https://github.com/getsentry/sentry-react-native/pull/6863))
- Set the same key in `applicationKey` and in the `filterKeys` option of `thirdPartyErrorFilterIntegration` to filter errors from third-party code, for example browser extensions on Expo web.

## 8.30.0

### Features
Expand Down
3 changes: 3 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import { startSpanManual } from '@sentry/core';
import type { StartSpanOptions } from '@sentry/core';
import { suppressTracing } from '@sentry/core';
import type { TextStyle } from 'react-native';
import { thirdPartyErrorFilterIntegration } from '@sentry/react';
Comment thread
sentry-warden[bot] marked this conversation as resolved.
import { Thread } from '@sentry/core';
import { TransactionEvent } from '@sentry/core';
import type { TransportMakeRequestResponse } from '@sentry/core';
Expand Down Expand Up @@ -1175,6 +1176,8 @@ export type SupabaseReactNativeIntegrationOptions = {

export { suppressTracing }

export { thirdPartyErrorFilterIntegration }

export { Thread }

// @public (undocumented)
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/js/integrations/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,5 @@ export {
linkedErrorsIntegration as browserLinkedErrorsIntegration,
rewriteFramesIntegration,
extraErrorDataIntegration,
thirdPartyErrorFilterIntegration,
} from '@sentry/react';
36 changes: 30 additions & 6 deletions packages/core/src/js/tools/metroconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,14 @@ import {
setSentryDefaultBabelTransformerPathEnv,
} from './sentryBabelTransformerUtils';
import { checkSentryExpoNativeProject } from './sentryExpoNativeCheck';
import { createSentryMetroSerializer, unstableBeforeAssetSerializationDebugIdPlugin } from './sentryMetroSerializer';
import {
createSentryMetroSerializer,
unstableBeforeAssetSerializationDebugIdPlugin,
unstableBeforeAssetSerializationModuleMetadataPlugin,
} from './sentryMetroSerializer';
import { withSentryOptionsFromFile } from './sentryOptionsSerializer';
import { unstableReleaseConstantsPlugin } from './sentryReleaseInjector';
import { buildModuleMetadata } from './utils';

export * from './sentryMetroSerializer';

Expand Down Expand Up @@ -88,6 +93,21 @@ export interface SentryMetroConfigOptions {
* @default false
*/
autoWrapExpoRouterErrorBoundary?: boolean;
/**
* A key that identifies the code of your application.
* Use the same key in the `filterKeys` option of `thirdPartyErrorFilterIntegration`
* to filter errors that come from third-party code, for example browser extensions.
*
* This is the same option as `applicationKey` of the Sentry bundler plugins.
*/
applicationKey?: string;
/**
* Metadata to attach to the stack frames of your application code.
* The SDK adds the metadata to the `module_metadata` field of each stack frame from your bundle.
*
* This is the same option as the object form of `moduleMetadata` of the Sentry bundler plugins.
*/
moduleMetadata?: Record<string, unknown>;
}

export interface SentryExpoConfigOptions {
Expand Down Expand Up @@ -119,13 +139,15 @@ export function withSentryConfig(
enableSourceContextInDevelopment = true,
optionsFile = true,
autoWrapExpoRouterErrorBoundary = false,
applicationKey,
moduleMetadata,
}: SentryMetroConfigOptions = {},
): MetroConfig {
setSentryMetroDevServerEnvFlag();

let newConfig = config;

newConfig = withSentryDebugId(newConfig);
newConfig = withSentryDebugId(newConfig, buildModuleMetadata({ applicationKey, moduleMetadata }));
newConfig = withSentryFramesCollapsed(newConfig);
if (annotateReactComponents || autoWrapExpoRouterErrorBoundary) {
newConfig = withSentryBabelTransformer(newConfig, annotateReactComponents, autoWrapExpoRouterErrorBoundary);
Expand Down Expand Up @@ -159,11 +181,13 @@ export function getSentryExpoConfig(
checkSentryExpoNativeProject(projectRoot);

const getDefaultConfig = options.getDefaultConfig || loadExpoMetroConfigModule().getDefaultConfig;
const moduleMetadata = buildModuleMetadata(options);
const config = getDefaultConfig(projectRoot, {
...options,
unstable_beforeAssetSerializationPlugins: [
...(options.unstable_beforeAssetSerializationPlugins || []),
...((options.injectReleaseForWeb ?? true) ? [unstableReleaseConstantsPlugin(projectRoot)] : []),
...(moduleMetadata ? [unstableBeforeAssetSerializationModuleMetadataPlugin(moduleMetadata)] : []),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expo skips development metadata injection

Medium Severity

getSentryExpoConfig only registers unstableBeforeAssetSerializationModuleMetadataPlugin and never prepends the metadata module onto serializer preModules. Expo applies that plugin when exporting chunks, while expo start uses baseJSBundle with the incoming premodules and does not run those plugins. Development Expo bundles therefore never define _sentryModuleMetadata, so thirdPartyErrorFilterIntegration still treats every frame as third-party โ€” the failure this change is meant to fix, including Expo web where browser-extension filtering is the documented use case. withSentryConfig already injects through createSentryMetroSerializer before the hot/dev early return; Expo has no equivalent wrapper.

Additional Locations (2)
Fix in Cursorย Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 4e05634. Configure here.

unstableBeforeAssetSerializationDebugIdPlugin,
],
});
Expand Down Expand Up @@ -258,10 +282,10 @@ export function withSentryBabelTransformer(
};
}

function withSentryDebugId(config: MetroConfig): MetroConfig {
const customSerializer = createSentryMetroSerializer(
config.serializer?.customSerializer || undefined,
) as MetroCustomSerializer;
function withSentryDebugId(config: MetroConfig, moduleMetadata?: Record<string, unknown>): MetroConfig {
const customSerializer = createSentryMetroSerializer(config.serializer?.customSerializer || undefined, {
moduleMetadata,
}) as MetroCustomSerializer;
// MetroConfig types customSerializers as async only, but sync returns are also supported
// The default serializer is sync

Expand Down
38 changes: 36 additions & 2 deletions packages/core/src/js/tools/sentryMetroSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import {
createDebugIdSnippet,
createModuleMetadataSnippet,
createVirtualJSModule,
determineDebugIdFromBundleSource,
prependModule,
Expand All @@ -17,6 +18,7 @@

const DEBUG_ID_PLACE_HOLDER = '__debug_id_place_holder__';
const DEBUG_ID_MODULE_PATH = '__debugid__';
const MODULE_METADATA_MODULE_PATH = '__sentry_module_metadata__';

const SOURCE_MAP_COMMENT = '//# sourceMappingURL=';
const DEBUG_ID_COMMENT = '//# debugId=';
Expand Down Expand Up @@ -47,15 +49,34 @@
return prependModule(premodules, debugIdModule);
}

/**
* Creates a plugin that adds the Sentry module metadata module to each bundle chunk.
* `thirdPartyErrorFilterIntegration` uses this metadata to find the frames of the application.
*/
export function unstableBeforeAssetSerializationModuleMetadataPlugin(
moduleMetadata: Record<string, unknown>,
): (serializationInput: { graph: ReadOnlyGraph<MixedOutput>; premodules: Module[]; debugId?: string }) => Module[] {
return ({ premodules }) => addModuleMetadataModule(premodules, moduleMetadata);
}

/**
* Creates a Metro serializer that adds Debug ID module to the plain bundle.
* The Debug ID module is a virtual module that provides a debug ID in runtime.
*
* If `moduleMetadata` is set, it also adds the Sentry module metadata module, also to development bundles.
*
* RAM Bundles do not support custom serializers.
*/
export const createSentryMetroSerializer = (customSerializer?: MetroSerializer): MetroSerializer => {
export const createSentryMetroSerializer = (
customSerializer?: MetroSerializer,
{ moduleMetadata }: { moduleMetadata?: Record<string, unknown> } = {},
): MetroSerializer => {
const serializer = customSerializer || createDefaultMetroSerializer();
return async function (entryPoint, preModules, graph, options) {
return async function (entryPoint, originalPreModules, graph, options) {
const preModules = moduleMetadata
? addModuleMetadataModule(originalPreModules, moduleMetadata)
: originalPreModules;

Check failure on line 78 in packages/core/src/js/tools/sentryMetroSerializer.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

[VRR-A9L] thirdPartyErrorFilterIntegration cannot see Metro-injected app metadata (additional location)

Metro only writes `_sentryModuleMetadata` on the global; React Native never runs `moduleMetadataIntegration` to copy it onto `frame.module_metadata`, so drop behaviours still treat app frames as third-party. Export it, add it to default integrations (before the filter), and include it in this test client.

if ('hot' in graph.transformOptions ? graph.transformOptions.hot : graph.transformOptions.dev) {
return serializer(entryPoint, preModules, graph, options);
}
Expand Down Expand Up @@ -169,6 +190,19 @@
return null;
}

function addModuleMetadataModule(
premodules: readonly Module<MixedOutput>[],
moduleMetadata: Record<string, unknown>,
): Module<MixedOutput>[] {
if (premodules.some(module => module.path === MODULE_METADATA_MODULE_PATH)) {
return [...premodules];
}
return prependModule(
premodules,
createVirtualJSModule(MODULE_METADATA_MODULE_PATH, createModuleMetadataSnippet(moduleMetadata)),
);
}

function createDebugIdModule(debugId: string): Module<VirtualJSOutput> & { setSource: (code: string) => void } {
return createVirtualJSModule(DEBUG_ID_MODULE_PATH, createDebugIdSnippet(debugId));
}
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/js/tools/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,52 @@ export function createDebugIdSnippet(debugId: string): string {
return `var _sentryDebugIds,_sentryDebugIdIdentifier;void 0===_sentryDebugIds&&(_sentryDebugIds={});try{var stack=(new Error).stack;stack&&(_sentryDebugIds[stack]="${debugId}",_sentryDebugIdIdentifier="sentry-dbid-${debugId}")}catch(e){}`;
}

/**
* Returns the module metadata code snippet of `@sentry/bundler-plugins`.
*
* https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/packages/bundler-plugin-core/src/utils.ts
*/
export function createModuleMetadataSnippet(metadata: Record<string, unknown>): string {
return `!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{};e._sentryModuleMetadata=e._sentryModuleMetadata||{},e._sentryModuleMetadata[(new e.Error).stack]=function(e){for(var n=1;n<arguments.length;n++){var a=arguments[n];if(null!=a)for(var t in a)a.hasOwnProperty(t)&&(e[t]=a[t])}return e}({},e._sentryModuleMetadata[(new e.Error).stack],${escapeUnsafeChars(
JSON.stringify(metadata),
)});}catch(e){}}();`;
}

const UNSAFE_CHARS: Record<string, string> = {
'<': '\\u003C',
'>': '\\u003E',
'\b': '\\b',
'\f': '\\f',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
'\0': '\\0',
'\u2028': '\\u2028',
'\u2029': '\\u2029',
};

function escapeUnsafeChars(json: string): string {
return json.replace(/[<>\b\f\n\r\t\0\u2028\u2029]/g, char => UNSAFE_CHARS[char] ?? char);
}

/**
* Builds the module metadata in the same way as `@sentry/bundler-plugins`.
* Returns `undefined` if there is no metadata to inject.
*/
export function buildModuleMetadata({
applicationKey,
moduleMetadata,
}: {
applicationKey?: string;
moduleMetadata?: Record<string, unknown>;
}): Record<string, unknown> | undefined {
const metadata: Record<string, unknown> = {
...(applicationKey ? { [`_sentryBundlerPluginAppKey:${applicationKey}`]: true } : {}),
...moduleMetadata,
};
return Object.keys(metadata).length > 0 ? metadata : undefined;
}

/**
* Deterministically hashes a string and turns the hash into a uuid.
*
Expand Down
56 changes: 56 additions & 0 deletions packages/core/test/integrations/thirdPartyErrorFilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Event } from '@sentry/core';

import { defaultStackParser } from '@sentry/browser';
import { GLOBAL_OBJ, Scope } from '@sentry/core';

import { ReactNativeClient } from '../../src/js/client';
import { thirdPartyErrorFilterIntegration } from '../../src/js/index';

jest.mock('../../src/js/wrapper', () => jest.requireActual('../mockWrapper'));

describe('thirdPartyErrorFilterIntegration', () => {
beforeEach(() => {
(GLOBAL_OBJ as { _sentryModuleMetadata?: Record<string, unknown> })._sentryModuleMetadata = {
[new Error().stack as string]: { '_sentryBundlerPluginAppKey:my-app': true },
};
});

afterEach(() => {
delete (GLOBAL_OBJ as { _sentryModuleMetadata?: unknown })._sentryModuleMetadata;
});

it('keeps errors from application code with the injected module metadata', async () => {
const beforeSend = await captureError(['my-app']);

expect(beforeSend).toHaveBeenCalledTimes(1);
});

it('drops errors without frames from application code', async () => {
const beforeSend = await captureError(['other-app']);

expect(beforeSend).not.toHaveBeenCalled();
});
});

async function captureError(filterKeys: string[]): Promise<jest.Mock> {
const beforeSend = jest.fn((event: Event) => event);
const client = new ReactNativeClient({
dsn: 'https://public@sentry.example.com/1',
enableNative: false,
stackParser: defaultStackParser,
integrations: [
thirdPartyErrorFilterIntegration({
filterKeys,
behaviour: 'drop-error-if-exclusively-contains-third-party-frames',
}),
],

Check failure on line 46 in packages/core/test/integrations/thirdPartyErrorFilter.test.ts

View check run for this annotation

@sentry/warden / warden: code-review

React Native does not apply injected module metadata to stack frames

The Metro serializer injects `_sentryModuleMetadata`, but React Native's default integrations do not include `moduleMetadataIntegration` to copy that metadata to `frame.module_metadata`. As a result, using the newly exported `thirdPartyErrorFilterIntegration` with a drop behavior can classify application frames as third-party and drop application errors. Add the metadata integration to React Native's defaults and exports, or otherwise ensure frame metadata is applied before filtering.

Check failure on line 46 in packages/core/test/integrations/thirdPartyErrorFilter.test.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

thirdPartyErrorFilterIntegration cannot see Metro-injected app metadata

Metro only writes `_sentryModuleMetadata` on the global; React Native never runs `moduleMetadataIntegration` to copy it onto `frame.module_metadata`, so drop behaviours still treat app frames as third-party. Export it, add it to default integrations (before the filter), and include it in this test client.
transport: () => ({ send: jest.fn().mockResolvedValue({}), flush: jest.fn().mockResolvedValue(true) }),
beforeSend,
});
client.init();

client.captureException(new Error('test'), undefined, new Scope());
await client.flush();

return beforeSend;
}
46 changes: 46 additions & 0 deletions packages/core/test/tools/metroconfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
withSentryExcludeServerOnlyResolver,
withSentryFeedbackResolver,
withSentryFramesCollapsed,
withSentryConfig,
withSentryResolver,
} from '../../src/js/tools/metroconfig';
import {
Expand Down Expand Up @@ -76,6 +77,51 @@ describe('metroconfig', () => {
});
});

describe('module metadata', () => {
const getPlugins = (options: Parameters<typeof getSentryExpoConfig>[1]) => {
const getDefaultConfig = jest.fn().mockReturnValue({});
getSentryExpoConfig('/project/root', { ...options, injectReleaseForWeb: false, getDefaultConfig });
return getDefaultConfig.mock.calls[0][1].unstable_beforeAssetSerializationPlugins as ((input: {
graph: unknown;
premodules: Array<{ path: string; getSource: () => Buffer }>;
}) => Array<{ path: string; getSource: () => Buffer }>)[];
};

test('getSentryExpoConfig adds module metadata plugin with application key', () => {
const plugins = getPlugins({ applicationKey: 'my-app', moduleMetadata: { team: 'mobile' } });

expect(plugins).toHaveLength(2);
const premodules = plugins[0]?.({ graph: {}, premodules: [] }) ?? [];
expect(premodules.map(module => module.getSource().toString())).toEqual([
expect.stringContaining('{"_sentryBundlerPluginAppKey:my-app":true,"team":"mobile"}'),
]);
});

test('getSentryExpoConfig does not add module metadata plugin without application key and metadata', () => {
expect(getPlugins({})).toHaveLength(1);
});

test('withSentryConfig adds module metadata module to the serializer premodules', async () => {
const customSerializer = jest.fn().mockReturnValue('');
const config = withSentryConfig(
{ serializer: { customSerializer } },
{ applicationKey: 'my-app', enableSourceContextInDevelopment: false, optionsFile: false },
);

await config.serializer?.customSerializer?.(
'index.js',
[],
{ transformOptions: { hot: true } } as never,
{} as never,
);

const premodules = customSerializer.mock.calls[0][1] as Array<{ getSource: () => Buffer }>;
expect(premodules.map(module => module.getSource().toString())).toEqual([
expect.stringContaining('{"_sentryBundlerPluginAppKey:my-app":true}'),
]);
});
});

describe('withSentryFramesCollapsed', () => {
test('adds customizeFrames if undefined ', () => {
const config = withSentryFramesCollapsed({});
Expand Down
Loading
Loading