Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

## 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.

### Dependencies

- Bump JavaScript SDK from v10.76.0 to v10.76.2 ([#6866](https://github.com/getsentry/sentry-react-native/pull/6866))
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)] : []),
Comment thread
cursor[bot] marked this conversation as resolved.
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 type { Bundle, MetroSerializer, SerializedBundle, VirtualJSOutput } from

import {
createDebugIdSnippet,
createModuleMetadataSnippet,
createVirtualJSModule,
determineDebugIdFromBundleSource,
prependModule,
Expand All @@ -17,6 +18,7 @@ type SourceMap = Record<string, unknown>;

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 @@ export function unstableBeforeAssetSerializationDebugIdPlugin({
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;

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 @@ function extractSerializerResult(serializerResult: unknown): SerializedBundle |
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',
}),
],
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