diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7ab3c4008dd1..8ea4b032d44c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -95,6 +95,7 @@ export { extraErrorDataIntegration } from './integrations/extraerrordata'; export { rewriteFramesIntegration } from './integrations/rewriteframes'; export { sessionTimingIntegration } from './integrations/sessiontiming'; export { zodErrorsIntegration } from './integrations/zoderrors'; +export { thirdPartyErrorFilterIntegration } from './integrations/third-party-errors-filter'; export { metrics } from './metrics/exports'; export type { MetricData } from '@sentry/types'; export { metricsDefault } from './metrics/exports-default'; diff --git a/packages/core/src/integrations/dedupe.ts b/packages/core/src/integrations/dedupe.ts index 13d92fe3d56b..94eea57e68cb 100644 --- a/packages/core/src/integrations/dedupe.ts +++ b/packages/core/src/integrations/dedupe.ts @@ -1,5 +1,5 @@ import type { Event, Exception, IntegrationFn, StackFrame } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import { getFramesFromEvent, logger } from '@sentry/utils'; import { defineIntegration } from '../integration'; import { DEBUG_BUILD } from '../debug-build'; @@ -106,8 +106,8 @@ function _isSameExceptionEvent(currentEvent: Event, previousEvent: Event): boole } function _isSameStacktrace(currentEvent: Event, previousEvent: Event): boolean { - let currentFrames = _getFramesFromEvent(currentEvent); - let previousFrames = _getFramesFromEvent(previousEvent); + let currentFrames = getFramesFromEvent(currentEvent); + let previousFrames = getFramesFromEvent(previousEvent); // If neither event has a stacktrace, they are assumed to be the same if (!currentFrames && !previousFrames) { @@ -173,17 +173,3 @@ function _isSameFingerprint(currentEvent: Event, previousEvent: Event): boolean function _getExceptionFromEvent(event: Event): Exception | undefined { return event.exception && event.exception.values && event.exception.values[0]; } - -function _getFramesFromEvent(event: Event): StackFrame[] | undefined { - const exception = event.exception; - - if (exception) { - try { - // @ts-expect-error Object could be undefined - return exception.values[0].stacktrace.frames; - } catch (_oO) { - return undefined; - } - } - return undefined; -} diff --git a/packages/core/src/integrations/third-party-errors-filter.ts b/packages/core/src/integrations/third-party-errors-filter.ts new file mode 100644 index 000000000000..70e7317f58c3 --- /dev/null +++ b/packages/core/src/integrations/third-party-errors-filter.ts @@ -0,0 +1,114 @@ +import type { Event, EventItem } from '@sentry/types'; +import { forEachEnvelopeItem, getFramesFromEvent } from '@sentry/utils'; +import { defineIntegration } from '../integration'; +import { addMetadataToStackFrames, stripMetadataFromStackFrames } from '../metadata'; + +interface Options { + /** + * Keys that have been provided in the Sentry bundler plugin via the the `applicationKey` option, identifying your bundles. + * + * - Webpack plugin: https://www.npmjs.com/package/@sentry/webpack-plugin#applicationkey + * - Vite plugin: https://www.npmjs.com/package/@sentry/vite-plugin#applicationkey + * - Esbuild plugin: https://www.npmjs.com/package/@sentry/esbuild-plugin#applicationkey + * - Rollup plugin: https://www.npmjs.com/package/@sentry/rollup-plugin#applicationkey + */ + filterKeys: string[]; + + /** + * Defines how the integration should behave. "Third-Party Stack Frames" are stack frames that did not come from files marked with a matching bundle key. + * + * You can define the behaviour with one of 4 modes: + * - `drop-error-if-contains-third-party-frames`: Drop error events that contain at least one third-party stack frame. + * - `drop-error-if-exclusively-contains-third-party-frames`: Drop error events that exclusively contain third-party stack frames. + * - `apply-tag-if-contains-third-party-frames`: Keep all error events, but apply a `third_party_code: true` tag in case the error contains at least one third-party stack frame. + * - `apply-tag-if-exclusively-contains-third-party-frames`: Keep all error events, but apply a `third_party_code: true` tag in case the error contains exclusively third-party stack frames. + * + * If you chose the mode to only apply tags, the tags can then be used in Sentry to filter your issue stream by entering `!third_party_code:True` in the search bar. + */ + behaviour: + | 'drop-error-if-contains-third-party-frames' + | 'drop-error-if-exclusively-contains-third-party-frames' + | 'apply-tag-if-contains-third-party-frames' + | 'apply-tag-if-exclusively-contains-third-party-frames'; +} + +/** + * This integration allows you to filter out, or tag error events that do not come from user code marked with a bundle key via the Sentry bundler plugins. + */ +export const thirdPartyErrorFilterIntegration = defineIntegration((options: Options) => { + return { + name: 'ThirdPartyErrorsFilter', + setup(client) { + // We need to strip metadata from stack frames before sending them to Sentry since these are client side only. + // TODO(lforst): Move this cleanup logic into a more central place in the SDK. + client.on('beforeEnvelope', envelope => { + forEachEnvelopeItem(envelope, (item, type) => { + if (type === 'event') { + const event = Array.isArray(item) ? (item as EventItem)[1] : undefined; + + if (event) { + stripMetadataFromStackFrames(event); + item[1] = event; + } + } + }); + }); + }, + processEvent(event, _hint, client) { + const stackParser = client.getOptions().stackParser; + addMetadataToStackFrames(stackParser, event); + + const frameKeys = getBundleKeysForAllFramesWithFilenames(event); + + if (frameKeys) { + const arrayMethod = + options.behaviour === 'drop-error-if-contains-third-party-frames' || + options.behaviour === 'apply-tag-if-contains-third-party-frames' + ? 'some' + : 'every'; + + const behaviourApplies = frameKeys[arrayMethod](keys => !keys.some(key => options.filterKeys.includes(key))); + + if (behaviourApplies) { + const shouldDrop = + options.behaviour === 'drop-error-if-contains-third-party-frames' || + options.behaviour === 'drop-error-if-exclusively-contains-third-party-frames'; + if (shouldDrop) { + return null; + } else { + event.tags = { + ...event.tags, + third_party_code: true, + }; + } + } + } + + return event; + }, + }; +}); + +function getBundleKeysForAllFramesWithFilenames(event: Event): string[][] | undefined { + const frames = getFramesFromEvent(event); + + if (!frames) { + return undefined; + } + + return ( + frames + // Exclude frames without a filename since these are likely native code or built-ins + .filter(frame => !!frame.filename) + .map(frame => { + if (frame.module_metadata) { + return Object.keys(frame.module_metadata) + .filter(key => key.startsWith(BUNDLER_PLUGIN_APP_KEY_PREFIX)) + .map(key => key.slice(BUNDLER_PLUGIN_APP_KEY_PREFIX.length)); + } + return []; + }) + ); +} + +const BUNDLER_PLUGIN_APP_KEY_PREFIX = '_sentryBundlerPluginAppKey:'; diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index d1ebac6e90e5..c13a5dabce34 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -60,7 +60,7 @@ export function addMetadataToStackFrames(parser: StackParser, event: Event): voi } for (const frame of exception.stacktrace.frames || []) { - if (!frame.filename) { + if (!frame.filename || frame.module_metadata) { continue; } diff --git a/packages/core/test/lib/integrations/third-party-errors-filter.test.ts b/packages/core/test/lib/integrations/third-party-errors-filter.test.ts new file mode 100644 index 000000000000..d0fd02045080 --- /dev/null +++ b/packages/core/test/lib/integrations/third-party-errors-filter.test.ts @@ -0,0 +1,247 @@ +import type { Client, Event } from '@sentry/types'; +import { GLOBAL_OBJ, createStackParser, nodeStackLineParser } from '@sentry/utils'; +import { thirdPartyErrorFilterIntegration } from '../../../src/integrations/third-party-errors-filter'; + +function clone(data: T): T { + return JSON.parse(JSON.stringify(data)); +} + +const stack = new Error().stack || ''; + +const eventWithThirdAndFirstPartyFrames: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 1, + filename: __filename, + function: 'function', + lineno: 1, + }, + { + colno: 2, + filename: 'other-file.js', + function: 'function', + lineno: 2, + }, + ], + }, + type: 'SyntaxError', + value: 'missing ( on line 10', + }, + ], + }, +}; + +const eventWithOnlyFirstPartyFrames: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 1, + filename: __filename, + function: 'function', + lineno: 1, + }, + { + colno: 2, + filename: __filename, + function: 'function', + lineno: 2, + }, + ], + }, + type: 'SyntaxError', + value: 'missing ( on line 10', + }, + ], + }, +}; + +const eventWithOnlyThirdPartyFrames: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + { + colno: 2, + filename: 'other-file.js', + function: 'function', + lineno: 2, + }, + ], + }, + type: 'SyntaxError', + value: 'missing ( on line 10', + }, + ], + }, +}; + +// This only needs the stackParser +const MOCK_CLIENT = { + getOptions: () => ({ + stackParser: createStackParser(nodeStackLineParser()), + }), +} as unknown as Client; + +describe('ThirdPartyErrorFilter', () => { + beforeEach(() => { + GLOBAL_OBJ._sentryModuleMetadata = GLOBAL_OBJ._sentryModuleMetadata || {}; + GLOBAL_OBJ._sentryModuleMetadata[stack] = { + '_sentryBundlerPluginAppKey:some-key': true, + '_sentryBundlerPluginAppKey:some-other-key': true, + }; + }); + + describe('drop-error-if-contains-third-party-frames', () => { + it('should keep event if there are exclusively first-party frames', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-contains-third-party-frames', + filterKeys: ['some-key'], + }); + + const event = clone(eventWithOnlyFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBeDefined(); + }); + + it('should drop event if there is at least one third-party frame', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-contains-third-party-frames', + filterKeys: ['some-key'], + }); + + const event = clone(eventWithThirdAndFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('should drop event if all frames are third-party frames', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-contains-third-party-frames', + filterKeys: ['some-key'], + }); + + const event = clone(eventWithOnlyThirdPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + }); + + describe('drop-error-if-exclusively-contains-third-party-frames', () => { + it('should keep event if there are exclusively first-party frames', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + }); + + const event = clone(eventWithOnlyFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBeDefined(); + }); + + it('should keep event if there is at least one first-party frame', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + }); + + const event = clone(eventWithThirdAndFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBeDefined(); + }); + + it('should drop event if all frames are third-party frames', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + }); + + const event = clone(eventWithOnlyThirdPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + }); + + describe('apply-tag-if-contains-third-party-frames', () => { + it('should not tag event if exclusively contains first-party frames', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'apply-tag-if-contains-third-party-frames', + filterKeys: ['some-key'], + }); + + const event = clone(eventWithOnlyFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result?.tags?.third_party_code).toBeUndefined(); + }); + + it('should tag event if contains at least one third-party frame', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'apply-tag-if-contains-third-party-frames', + filterKeys: ['some-key'], + }); + + const event = clone(eventWithThirdAndFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result?.tags).toMatchObject({ third_party_code: true }); + }); + + it('should tag event if contains exclusively third-party frames', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'apply-tag-if-contains-third-party-frames', + filterKeys: ['some-key'], + }); + + const event = clone(eventWithOnlyThirdPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result?.tags).toMatchObject({ third_party_code: true }); + }); + }); + + describe('apply-tag-if-exclusively-contains-third-party-frames', () => { + it('should not tag event if exclusively contains first-party frames', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + }); + + const event = clone(eventWithOnlyFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result?.tags?.third_party_code).toBeUndefined(); + }); + + it('should not tag event if contains at least one first-party frame', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + }); + + const event = clone(eventWithThirdAndFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result?.tags?.third_party_code).toBeUndefined(); + }); + + it('should tag event if contains exclusively third-party frames', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + }); + + const event = clone(eventWithOnlyThirdPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result?.tags).toMatchObject({ third_party_code: true }); + }); + }); +}); diff --git a/packages/utils/src/stacktrace.ts b/packages/utils/src/stacktrace.ts index bc2274ef522f..dfb2a6e6269f 100644 --- a/packages/utils/src/stacktrace.ts +++ b/packages/utils/src/stacktrace.ts @@ -1,4 +1,4 @@ -import type { StackFrame, StackLineParser, StackParser } from '@sentry/types'; +import type { Event, StackFrame, StackLineParser, StackParser } from '@sentry/types'; const STACKTRACE_FRAME_LIMIT = 50; export const UNKNOWN_FUNCTION = '?'; @@ -133,3 +133,28 @@ export function getFunctionName(fn: unknown): string { return defaultFunctionName; } } + +/** + * Get's stack frames from an event without needing to check for undefined properties. + */ +export function getFramesFromEvent(event: Event): StackFrame[] | undefined { + const exception = event.exception; + + if (exception) { + const frames: StackFrame[] = []; + try { + // @ts-expect-error Object could be undefined + exception.values.forEach(value => { + // @ts-expect-error Value could be undefined + if (value.stacktrace.frames) { + // @ts-expect-error Value could be undefined + frames.push(...value.stacktrace.frames); + } + }); + return frames; + } catch (_oO) { + return undefined; + } + } + return undefined; +}