diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts index 09359b122450..d085175e0293 100644 --- a/packages/browser/src/helpers.ts +++ b/packages/browser/src/helpers.ts @@ -106,6 +106,9 @@ export function wrap( // Wrap the function itself // It is important that `sentryWrapped` is not an arrow function to preserve the context of `this` const sentryWrapped = function (this: unknown, ...args: unknown[]): unknown { + // Track depth on GLOBAL_OBJ so the thirdPartyErrorFilterIntegration (in @sentry/core) can detect + // that processEvent is running inside a sentryWrapped call, even with minified/bundled code. + GLOBAL_OBJ._sentryWrappedDepth = (GLOBAL_OBJ._sentryWrappedDepth || 0) + 1; try { // Also wrap arguments that are themselves functions const wrappedArguments = args.map(arg => wrap(arg, options)); @@ -138,6 +141,8 @@ export function wrap( }); throw ex; + } finally { + GLOBAL_OBJ._sentryWrappedDepth = (GLOBAL_OBJ._sentryWrappedDepth || 0) - 1; } } as unknown as WrappedFunction; diff --git a/packages/core/src/integrations/third-party-errors-filter.ts b/packages/core/src/integrations/third-party-errors-filter.ts index f5d4c087eeab..8ea64c1a2da4 100644 --- a/packages/core/src/integrations/third-party-errors-filter.ts +++ b/packages/core/src/integrations/third-party-errors-filter.ts @@ -5,6 +5,7 @@ import type { Event } from '../types-hoist/event'; import type { StackFrame } from '../types-hoist/stackframe'; import { forEachEnvelopeItem } from '../utils/envelope'; import { getFramesFromEvent } from '../utils/stacktrace'; +import { GLOBAL_OBJ } from '../utils/worldwide'; interface Options { /** @@ -74,8 +75,27 @@ export const thirdPartyErrorFilterIntegration = defineIntegration((options: Opti }); }, + preprocessEvent(event) { + // Snapshot the depth counter onto the event before any event processors run. + // This is necessary because async event processors could cause the finally block + // in sentryWrapped to decrement the counter before processEvent reads it. + if (options.ignoreSentryInternalFrames && (GLOBAL_OBJ._sentryWrappedDepth ?? 0) > 0) { + event.sdkProcessingMetadata = { + ...event.sdkProcessingMetadata, + insideSentryWrapped: true, + }; + } + }, + processEvent(event) { - const frameKeys = getBundleKeysForAllFramesWithFilenames(event, options.ignoreSentryInternalFrames); + const insideSentryWrapped = options.ignoreSentryInternalFrames + ? event.sdkProcessingMetadata?.insideSentryWrapped === true + : false; + const frameKeys = getBundleKeysForAllFramesWithFilenames( + event, + options.ignoreSentryInternalFrames, + insideSentryWrapped, + ); if (frameKeys) { const arrayMethod = @@ -106,20 +126,30 @@ export const thirdPartyErrorFilterIntegration = defineIntegration((options: Opti }; }); -/** - * 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 { +/** Checks if a frame is Sentry-internal via runtime depth, function name, or source patterns. */ +function isSentryInternalFrame(frame: StackFrame, frameIndex: number, insideSentryWrapped: boolean): boolean { // Only match the last frame (index 0 in reversed stack) - if (frameIndex !== 0 || !frame.context_line || !frame.filename) { + if (frameIndex !== 0) { + return false; + } + + // When processEvent runs inside a sentryWrapped call, the outermost frame is always + // the sentryWrapped function. This works regardless of minification/bundling because + // it's a runtime check, not a source pattern match. + if (insideSentryWrapped) { + return true; + } + + // Match by function name (works when function names survive bundling but source patterns don't) + if (frame.function === 'sentryWrapped') { + return true; + } + + if (!frame.context_line || !frame.filename) { return false; } + // Match by source code patterns (works in development / unbundled builds) if ( !frame.filename.includes('sentry') || !frame.filename.includes('helpers') || // Filename would look something like this: 'node_modules/@sentry/browser/build/npm/esm/helpers.js' @@ -144,6 +174,7 @@ function isSentryInternalFrame(frame: StackFrame, frameIndex: number): boolean { function getBundleKeysForAllFramesWithFilenames( event: Event, ignoreSentryInternalFrames?: boolean, + insideSentryWrapped?: boolean, ): string[][] | undefined { const frames = getFramesFromEvent(event); @@ -163,7 +194,7 @@ function getBundleKeysForAllFramesWithFilenames( return false; } // Optionally ignore Sentry internal frames - return !ignoreSentryInternalFrames || !isSentryInternalFrame(frame, index); + return !ignoreSentryInternalFrames || !isSentryInternalFrame(frame, index, !!insideSentryWrapped); }) .map(frame => { if (!frame.module_metadata) { diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 1955014f1345..10bc551b6170 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -54,6 +54,7 @@ export type InternalGlobal = { */ _sentryModuleMetadata?: Record; _sentryEsmLoaderHookRegistered?: boolean; + _sentryWrappedDepth?: number; } & Carrier; /** Get's the global object for the current JavaScript runtime */ 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 dd93847baf63..73a9921721be 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 @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { Client } from '../../../src/client'; import { thirdPartyErrorFilterIntegration } from '../../../src/integrations/third-party-errors-filter'; import { addMetadataToStackFrames } from '../../../src/metadata'; @@ -626,7 +626,7 @@ describe('ThirdPartyErrorFilter', () => { expect(result).toBeDefined(); }); - it('does not match when filename does not contain both helpers and sentry', async () => { + it('does not match when filename does not contain both helpers and sentry and function name is not sentryWrapped', async () => { const eventWithWrongFilename: Event = { exception: { values: [ @@ -636,7 +636,7 @@ describe('ThirdPartyErrorFilter', () => { { colno: 2, filename: 'some-helpers.js', - function: 'sentryWrapped', + function: 'someFunction', lineno: 117, context_line: ' return fn.apply(this, wrappedArguments);', pre_context: [ @@ -667,7 +667,180 @@ describe('ThirdPartyErrorFilter', () => { const event = clone(eventWithWrongFilename); const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); - // Should not drop because filename doesn't contain "sentry" + // Should not drop because filename doesn't contain "sentry" and function name is not "sentryWrapped" + expect(result).toBeDefined(); + }); + }); + + describe('minified/bundled code detection', () => { + afterEach(() => { + GLOBAL_OBJ._sentryWrappedDepth = 0; + }); + + it('detects Sentry internal frame when preprocessEvent snapshots depth inside a sentryWrapped call', async () => { + const eventWithMinifiedSentryFrame: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 12345, + filename: 'https://example.com/assets/app-abc123.js', + function: 'a', + lineno: 1, + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Third party error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + // Simulate being inside a sentryWrapped call + GLOBAL_OBJ._sentryWrappedDepth = 1; + const event = clone(eventWithMinifiedSentryFrame); + // preprocessEvent snapshots the depth onto the event + integration.preprocessEvent?.(event, {}, MOCK_CLIENT); + // Even if depth resets before processEvent (async processor scenario), the snapshot survives + GLOBAL_OBJ._sentryWrappedDepth = 0; + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('detects Sentry internal frame by function name sentryWrapped even without source patterns', async () => { + const eventWithFunctionName: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 12345, + filename: 'https://example.com/assets/app-abc123.js', + function: 'sentryWrapped', + lineno: 1, + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Third party error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithFunctionName); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('does not detect minified frame as Sentry internal when not inside sentryWrapped and function name is mangled', async () => { + const eventWithMinifiedFrame: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 12345, + filename: 'https://example.com/assets/app-abc123.js', + function: 'a', + lineno: 1, + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Third party error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + GLOBAL_OBJ._sentryWrappedDepth = 0; + const event = clone(eventWithMinifiedFrame); + integration.preprocessEvent?.(event, {}, MOCK_CLIENT); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBeDefined(); + }); + + it('does not use sentryWrappedDepth when ignoreSentryInternalFrames is false', async () => { + const eventWithMinifiedSentryFrame: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 12345, + filename: 'https://example.com/assets/app-abc123.js', + function: 'a', + lineno: 1, + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Third party error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: false, + }); + + GLOBAL_OBJ._sentryWrappedDepth = 1; + const event = clone(eventWithMinifiedSentryFrame); + integration.preprocessEvent?.(event, {}, MOCK_CLIENT); + GLOBAL_OBJ._sentryWrappedDepth = 0; + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); expect(result).toBeDefined(); }); });