diff --git a/packages/core/src/integrations/third-party-errors-filter.ts b/packages/core/src/integrations/third-party-errors-filter.ts index 53739c9efd2d..36c88105a283 100644 --- a/packages/core/src/integrations/third-party-errors-filter.ts +++ b/packages/core/src/integrations/third-party-errors-filter.ts @@ -2,6 +2,7 @@ import { defineIntegration } from '../integration'; import { addMetadataToStackFrames, stripMetadataFromStackFrames } from '../metadata'; import type { EventItem } from '../types-hoist/envelope'; import type { Event } from '../types-hoist/event'; +import type { StackFrame } from '../types-hoist/stackframe'; import { forEachEnvelopeItem } from '../utils/envelope'; import { getFramesFromEvent } from '../utils/stacktrace'; @@ -32,6 +33,13 @@ interface Options { | 'drop-error-if-exclusively-contains-third-party-frames' | 'apply-tag-if-contains-third-party-frames' | 'apply-tag-if-exclusively-contains-third-party-frames'; + + /** + * @experimental + * If set to true, the integration will ignore frames that are internal to the Sentry SDK from the third-party frame detection. + * Note that enabling this option might lead to errors being misclassified as third-party errors. + */ + ignoreSentryInternalFrames?: boolean; } /** @@ -67,7 +75,7 @@ export const thirdPartyErrorFilterIntegration = defineIntegration((options: Opti }, processEvent(event) { - const frameKeys = getBundleKeysForAllFramesWithFilenames(event); + const frameKeys = getBundleKeysForAllFramesWithFilenames(event, options.ignoreSentryInternalFrames); if (frameKeys) { const arrayMethod = @@ -98,27 +106,71 @@ export const thirdPartyErrorFilterIntegration = defineIntegration((options: Opti }; }); -function getBundleKeysForAllFramesWithFilenames(event: Event): string[][] | undefined { +/** + * Checks if a stack frame is a Sentry internal frame by strictly matching: + * 1. The frame must be the last frame in the stack + * 2. The filename must indicate the internal helpers file + * 3. The context_line must contain the exact pattern "fn.apply(this, wrappedArguments)" + * 4. The comment pattern "Attempt to invoke user-land function" must be present in pre_context + * + */ +function isSentryInternalFrame(frame: StackFrame, frameIndex: number): boolean { + // Only match the last frame (index 0 in reversed stack) + if (frameIndex !== 0 || !frame.context_line || !frame.filename) { + return false; + } + + if ( + !frame.filename.includes('sentry') || + !frame.filename.includes('helpers') || // Filename would look something like this: 'node_modules/@sentry/browser/build/npm/esm/helpers.js' + !frame.context_line.includes(SENTRY_INTERNAL_FN_APPLY) // Must have context_line with the exact fn.apply pattern + ) { + return false; + } + + // Check pre_context array for comment pattern + if (frame.pre_context) { + const len = frame.pre_context.length; + for (let i = 0; i < len; i++) { + if (frame.pre_context[i]?.includes(SENTRY_INTERNAL_COMMENT)) { + return true; + } + } + } + + return false; +} + +function getBundleKeysForAllFramesWithFilenames( + event: Event, + ignoreSentryInternalFrames?: boolean, +): string[][] | undefined { const frames = getFramesFromEvent(event); if (!frames) { return undefined; } - return ( - frames + return frames + .filter((frame, index) => { // Exclude frames without a filename or without lineno and colno, // since these are likely native code or built-ins - .filter(frame => !!frame.filename && (frame.lineno ?? frame.colno) != null) - .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)); - } + if (!frame.filename || (frame.lineno == null && frame.colno == null)) { + return false; + } + // Optionally ignore Sentry internal frames + return !ignoreSentryInternalFrames || !isSentryInternalFrame(frame, index); + }) + .map(frame => { + if (!frame.module_metadata) { return []; - }) - ); + } + 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)); + }); } const BUNDLER_PLUGIN_APP_KEY_PREFIX = '_sentryBundlerPluginAppKey:'; +const SENTRY_INTERNAL_COMMENT = 'Attempt to invoke user-land function'; +const SENTRY_INTERNAL_FN_APPLY = 'fn.apply(this, wrappedArguments)'; 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 index 2b5445a4544e..e519cfd2564c 100644 --- a/packages/core/test/lib/integrations/third-party-errors-filter.test.ts +++ b/packages/core/test/lib/integrations/third-party-errors-filter.test.ts @@ -132,6 +132,78 @@ const eventWithOnlyThirdPartyFrames: Event = { }, }; +const eventWithThirdPartyAndSentryInternalFrames: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ' // means the sentry.javascript SDK caught an error invoking your application code. This', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Third party error', + }, + ], + }, +}; + +const eventWithThirdPartySentryInternalAndFirstPartyFrames: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 1, + filename: __filename, + function: 'function', + lineno: 1, + }, + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ' // means the sentry.javascript SDK caught an error invoking your application code. This', + ], + }, + { + colno: 3, + filename: 'other-file.js', + function: 'function', + lineno: 3, + }, + ], + }, + type: 'Error', + value: 'Mixed error', + }, + ], + }, +}; + // This only needs the stackParser const MOCK_CLIENT = {} as unknown as Client; @@ -146,6 +218,8 @@ describe('ThirdPartyErrorFilter', () => { addMetadataToStackFrames(stackParser, eventWithThirdAndFirstPartyFrames); addMetadataToStackFrames(stackParser, eventWithOnlyFirstPartyFrames); addMetadataToStackFrames(stackParser, eventWithOnlyThirdPartyFrames); + addMetadataToStackFrames(stackParser, eventWithThirdPartyAndSentryInternalFrames); + addMetadataToStackFrames(stackParser, eventWithThirdPartySentryInternalAndFirstPartyFrames); }); describe('drop-error-if-contains-third-party-frames', () => { @@ -287,4 +361,315 @@ describe('ThirdPartyErrorFilter', () => { expect(result?.tags).toMatchObject({ third_party_code: true }); }); }); + + describe('experimentalExcludeSentryInternalFrames', () => { + describe('drop-error-if-exclusively-contains-third-party-frames', () => { + it('drops event with only third-party + Sentry internal frames when option is enabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithThirdPartyAndSentryInternalFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('keeps event with third-party + Sentry internal + first-party frames when option is enabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithThirdPartySentryInternalAndFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBeDefined(); + }); + + it('does not drop event with only third-party + Sentry internal frames when option is disabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: false, + }); + + const event = clone(eventWithThirdPartyAndSentryInternalFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBeDefined(); + }); + + it('defaults to false', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + // experimentalExcludeSentryInternalFrames not set, should default to false + }); + + const event = clone(eventWithThirdPartyAndSentryInternalFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should not drop because option defaults to false + expect(result).toBeDefined(); + }); + }); + + describe('drop-error-if-contains-third-party-frames', () => { + it('drops event with third-party + Sentry internal frames when option is enabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithThirdPartyAndSentryInternalFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('keeps event with third-party + Sentry internal + first-party frames when option is enabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithThirdPartySentryInternalAndFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should drop because it contains third-party frames (even with first-party frames) + expect(result).toBe(null); + }); + }); + + describe('comment pattern detection', () => { + it('detects Sentry internal frame by context_line with both patterns', async () => { + const eventWithContextLine: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithContextLine); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('detects Sentry internal frame by pre_context with both patterns', async () => { + const eventWithPreContext: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithPreContext); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('does not detect Sentry internal frame when fn.apply pattern is missing', async () => { + const eventWithoutFnApply: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 115, + context_line: ' const wrappedArguments = args.map(arg => wrap(arg, options));', + post_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithoutFnApply); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should not drop because fn.apply pattern is missing + expect(result).toBeDefined(); + }); + + it('does not match when Sentry internal frame is not the last frame', async () => { + const eventWithSentryFrameNotLast: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ], + }, + { + colno: 3, + filename: 'another-file.js', + function: 'function', + lineno: 3, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithSentryFrameNotLast); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should not drop because Sentry frame is not the last frame + expect(result).toBeDefined(); + }); + + it('does not match when filename does not contain both helpers and sentry', async () => { + const eventWithWrongFilename: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: 'some-helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithWrongFilename); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should not drop because filename doesn't contain "sentry" + expect(result).toBeDefined(); + }); + }); + }); });