Skip to content

Commit 80219e5

Browse files
authored
fix(browser): Fix internal frame detection in minified bundles (#20802)
The `ignoreSentryInternalFrames` option in `thirdPartyErrorFilterIntegration` relied on source-code-level signals (filename, context_line, comment patterns) that are all lost after minification. This PR adds a runtime depth counter (`GLOBAL_OBJ._sentryWrappedDepth`) that tracks whether processEvent is running inside a sentryWrapped call. This works regardless of minification and also covers framework-caught errors (e.g. Vue swallowing the error before sentryWrapped's `catch` fires). closes #20687
1 parent 5881009 commit 80219e5

4 files changed

Lines changed: 270 additions & 16 deletions

File tree

packages/browser/src/helpers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ export function wrap<T extends WrappableFunction, NonFunction>(
106106
// Wrap the function itself
107107
// It is important that `sentryWrapped` is not an arrow function to preserve the context of `this`
108108
const sentryWrapped = function (this: unknown, ...args: unknown[]): unknown {
109+
// Track depth on GLOBAL_OBJ so the thirdPartyErrorFilterIntegration (in @sentry/core) can detect
110+
// that processEvent is running inside a sentryWrapped call, even with minified/bundled code.
111+
GLOBAL_OBJ._sentryWrappedDepth = (GLOBAL_OBJ._sentryWrappedDepth || 0) + 1;
109112
try {
110113
// Also wrap arguments that are themselves functions
111114
const wrappedArguments = args.map(arg => wrap(arg, options));
@@ -138,6 +141,8 @@ export function wrap<T extends WrappableFunction, NonFunction>(
138141
});
139142

140143
throw ex;
144+
} finally {
145+
GLOBAL_OBJ._sentryWrappedDepth = (GLOBAL_OBJ._sentryWrappedDepth || 0) - 1;
141146
}
142147
} as unknown as WrappedFunction<T>;
143148

packages/core/src/integrations/third-party-errors-filter.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Event } from '../types-hoist/event';
55
import type { StackFrame } from '../types-hoist/stackframe';
66
import { forEachEnvelopeItem } from '../utils/envelope';
77
import { getFramesFromEvent } from '../utils/stacktrace';
8+
import { GLOBAL_OBJ } from '../utils/worldwide';
89

910
interface Options {
1011
/**
@@ -74,8 +75,27 @@ export const thirdPartyErrorFilterIntegration = defineIntegration((options: Opti
7475
});
7576
},
7677

78+
preprocessEvent(event) {
79+
// Snapshot the depth counter onto the event before any event processors run.
80+
// This is necessary because async event processors could cause the finally block
81+
// in sentryWrapped to decrement the counter before processEvent reads it.
82+
if (options.ignoreSentryInternalFrames && (GLOBAL_OBJ._sentryWrappedDepth ?? 0) > 0) {
83+
event.sdkProcessingMetadata = {
84+
...event.sdkProcessingMetadata,
85+
insideSentryWrapped: true,
86+
};
87+
}
88+
},
89+
7790
processEvent(event) {
78-
const frameKeys = getBundleKeysForAllFramesWithFilenames(event, options.ignoreSentryInternalFrames);
91+
const insideSentryWrapped = options.ignoreSentryInternalFrames
92+
? event.sdkProcessingMetadata?.insideSentryWrapped === true && event.exception?.values?.length === 1
93+
: false;
94+
const frameKeys = getBundleKeysForAllFramesWithFilenames(
95+
event,
96+
options.ignoreSentryInternalFrames,
97+
insideSentryWrapped,
98+
);
7999

80100
if (frameKeys) {
81101
const arrayMethod =
@@ -106,20 +126,31 @@ export const thirdPartyErrorFilterIntegration = defineIntegration((options: Opti
106126
};
107127
});
108128

109-
/**
110-
* Checks if a stack frame is a Sentry internal frame by strictly matching:
111-
* 1. The frame must be the last frame in the stack
112-
* 2. The filename must indicate the internal helpers file
113-
* 3. The context_line must contain the exact pattern "fn.apply(this, wrappedArguments)"
114-
* 4. The comment pattern "Attempt to invoke user-land function" must be present in pre_context
115-
*
116-
*/
117-
function isSentryInternalFrame(frame: StackFrame, frameIndex: number): boolean {
129+
/** Checks if a frame is Sentry-internal via runtime depth, function name, or source patterns. */
130+
function isSentryInternalFrame(frame: StackFrame, frameIndex: number, insideSentryWrapped: boolean): boolean {
118131
// Only match the last frame (index 0 in reversed stack)
119-
if (frameIndex !== 0 || !frame.context_line || !frame.filename) {
132+
if (frameIndex !== 0) {
133+
return false;
134+
}
135+
136+
// When processEvent runs inside a sentryWrapped call and the frame looks minified,
137+
// the outermost frame is the sentryWrapped function with a mangled name.
138+
// We gate on minified shape to avoid false positives in non-minified builds
139+
// where stripSentryFramesAndReverse already removed the sentryWrapped frame.
140+
if (insideSentryWrapped && isLikelyMinifiedSentryWrappedFrame(frame)) {
141+
return true;
142+
}
143+
144+
// Match by function name (works when function names survive bundling but source patterns don't)
145+
if (frame.function === 'sentryWrapped') {
146+
return true;
147+
}
148+
149+
if (!frame.context_line || !frame.filename) {
120150
return false;
121151
}
122152

153+
// Match by source code patterns (works in development / unbundled builds)
123154
if (
124155
!frame.filename.includes('sentry') ||
125156
!frame.filename.includes('helpers') || // Filename would look something like this: 'node_modules/@sentry/browser/build/npm/esm/helpers.js'
@@ -144,6 +175,7 @@ function isSentryInternalFrame(frame: StackFrame, frameIndex: number): boolean {
144175
function getBundleKeysForAllFramesWithFilenames(
145176
event: Event,
146177
ignoreSentryInternalFrames?: boolean,
178+
insideSentryWrapped?: boolean,
147179
): string[][] | undefined {
148180
const frames = getFramesFromEvent(event);
149181

@@ -163,7 +195,7 @@ function getBundleKeysForAllFramesWithFilenames(
163195
return false;
164196
}
165197
// Optionally ignore Sentry internal frames
166-
return !ignoreSentryInternalFrames || !isSentryInternalFrame(frame, index);
198+
return !ignoreSentryInternalFrames || !isSentryInternalFrame(frame, index, !!insideSentryWrapped);
167199
})
168200
.map(frame => {
169201
if (!frame.module_metadata) {
@@ -175,6 +207,10 @@ function getBundleKeysForAllFramesWithFilenames(
175207
});
176208
}
177209

210+
function isLikelyMinifiedSentryWrappedFrame(frame: StackFrame): boolean {
211+
return !frame.context_line && !frame.pre_context && !!frame.function && frame.function.length <= 2;
212+
}
213+
178214
const BUNDLER_PLUGIN_APP_KEY_PREFIX = '_sentryBundlerPluginAppKey:';
179215
const SENTRY_INTERNAL_COMMENT = 'Attempt to invoke user-land function';
180216
const SENTRY_INTERNAL_FN_APPLY = 'fn.apply(this, wrappedArguments)';

packages/core/src/utils/worldwide.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export type InternalGlobal = {
5454
*/
5555
_sentryModuleMetadata?: Record<string, any>;
5656
_sentryEsmLoaderHookRegistered?: boolean;
57+
_sentryWrappedDepth?: number;
5758
} & Carrier;
5859

5960
/** Get's the global object for the current JavaScript runtime */

packages/core/test/lib/integrations/third-party-errors-filter.test.ts

Lines changed: 216 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, describe, expect, it } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22
import type { Client } from '../../../src/client';
33
import { thirdPartyErrorFilterIntegration } from '../../../src/integrations/third-party-errors-filter';
44
import { addMetadataToStackFrames } from '../../../src/metadata';
@@ -626,7 +626,7 @@ describe('ThirdPartyErrorFilter', () => {
626626
expect(result).toBeDefined();
627627
});
628628

629-
it('does not match when filename does not contain both helpers and sentry', async () => {
629+
it('does not match when filename does not contain both helpers and sentry and function name is not sentryWrapped', async () => {
630630
const eventWithWrongFilename: Event = {
631631
exception: {
632632
values: [
@@ -636,7 +636,7 @@ describe('ThirdPartyErrorFilter', () => {
636636
{
637637
colno: 2,
638638
filename: 'some-helpers.js',
639-
function: 'sentryWrapped',
639+
function: 'someFunction',
640640
lineno: 117,
641641
context_line: ' return fn.apply(this, wrappedArguments);',
642642
pre_context: [
@@ -667,7 +667,219 @@ describe('ThirdPartyErrorFilter', () => {
667667

668668
const event = clone(eventWithWrongFilename);
669669
const result = await integration.processEvent?.(event, {}, MOCK_CLIENT);
670-
// Should not drop because filename doesn't contain "sentry"
670+
// Should not drop because filename doesn't contain "sentry" and function name is not "sentryWrapped"
671+
expect(result).toBeDefined();
672+
});
673+
});
674+
675+
describe('minified/bundled code detection', () => {
676+
afterEach(() => {
677+
GLOBAL_OBJ._sentryWrappedDepth = 0;
678+
});
679+
680+
it('detects Sentry internal frame when preprocessEvent snapshots depth inside a sentryWrapped call', async () => {
681+
const eventWithMinifiedSentryFrame: Event = {
682+
exception: {
683+
values: [
684+
{
685+
stacktrace: {
686+
frames: [
687+
{
688+
colno: 12345,
689+
filename: 'https://example.com/assets/app-abc123.js',
690+
function: 'a',
691+
lineno: 1,
692+
},
693+
{
694+
colno: 1,
695+
filename: 'other-file.js',
696+
function: 'function',
697+
lineno: 1,
698+
},
699+
],
700+
},
701+
type: 'Error',
702+
value: 'Third party error',
703+
},
704+
],
705+
},
706+
};
707+
708+
const integration = thirdPartyErrorFilterIntegration({
709+
behaviour: 'drop-error-if-exclusively-contains-third-party-frames',
710+
filterKeys: ['some-key'],
711+
ignoreSentryInternalFrames: true,
712+
});
713+
714+
// Simulate being inside a sentryWrapped call
715+
GLOBAL_OBJ._sentryWrappedDepth = 1;
716+
const event = clone(eventWithMinifiedSentryFrame);
717+
// preprocessEvent snapshots the depth onto the event
718+
integration.preprocessEvent?.(event, {}, MOCK_CLIENT);
719+
// Even if depth resets before processEvent (async processor scenario), the snapshot survives
720+
GLOBAL_OBJ._sentryWrappedDepth = 0;
721+
const result = await integration.processEvent?.(event, {}, MOCK_CLIENT);
722+
expect(result).toBe(null);
723+
});
724+
725+
it('detects Sentry internal frame by function name sentryWrapped even without source patterns', async () => {
726+
const eventWithFunctionName: Event = {
727+
exception: {
728+
values: [
729+
{
730+
stacktrace: {
731+
frames: [
732+
{
733+
colno: 12345,
734+
filename: 'https://example.com/assets/app-abc123.js',
735+
function: 'sentryWrapped',
736+
lineno: 1,
737+
},
738+
{
739+
colno: 1,
740+
filename: 'other-file.js',
741+
function: 'function',
742+
lineno: 1,
743+
},
744+
],
745+
},
746+
type: 'Error',
747+
value: 'Third party error',
748+
},
749+
],
750+
},
751+
};
752+
753+
const integration = thirdPartyErrorFilterIntegration({
754+
behaviour: 'drop-error-if-exclusively-contains-third-party-frames',
755+
filterKeys: ['some-key'],
756+
ignoreSentryInternalFrames: true,
757+
});
758+
759+
const event = clone(eventWithFunctionName);
760+
const result = await integration.processEvent?.(event, {}, MOCK_CLIENT);
761+
expect(result).toBe(null);
762+
});
763+
764+
it('does not detect minified frame as Sentry internal when not inside sentryWrapped and function name is mangled', async () => {
765+
const eventWithMinifiedFrame: Event = {
766+
exception: {
767+
values: [
768+
{
769+
stacktrace: {
770+
frames: [
771+
{
772+
colno: 12345,
773+
filename: 'https://example.com/assets/app-abc123.js',
774+
function: 'a',
775+
lineno: 1,
776+
},
777+
{
778+
colno: 1,
779+
filename: 'other-file.js',
780+
function: 'function',
781+
lineno: 1,
782+
},
783+
],
784+
},
785+
type: 'Error',
786+
value: 'Third party error',
787+
},
788+
],
789+
},
790+
};
791+
792+
const integration = thirdPartyErrorFilterIntegration({
793+
behaviour: 'drop-error-if-exclusively-contains-third-party-frames',
794+
filterKeys: ['some-key'],
795+
ignoreSentryInternalFrames: true,
796+
});
797+
798+
GLOBAL_OBJ._sentryWrappedDepth = 0;
799+
const event = clone(eventWithMinifiedFrame);
800+
integration.preprocessEvent?.(event, {}, MOCK_CLIENT);
801+
const result = await integration.processEvent?.(event, {}, MOCK_CLIENT);
802+
expect(result).toBeDefined();
803+
});
804+
805+
it('does not exclude non-minified frame even when inside sentryWrapped (parser already stripped it)', async () => {
806+
const eventWithNonMinifiedFrame: Event = {
807+
exception: {
808+
values: [
809+
{
810+
stacktrace: {
811+
frames: [
812+
{
813+
colno: 10,
814+
filename: 'app.js',
815+
function: 'handleClick',
816+
lineno: 42,
817+
context_line: ' throw new Error("oops");',
818+
},
819+
],
820+
},
821+
type: 'Error',
822+
value: 'oops',
823+
},
824+
],
825+
},
826+
};
827+
828+
const integration = thirdPartyErrorFilterIntegration({
829+
behaviour: 'drop-error-if-exclusively-contains-third-party-frames',
830+
filterKeys: ['some-key'],
831+
ignoreSentryInternalFrames: true,
832+
});
833+
834+
GLOBAL_OBJ._sentryWrappedDepth = 1;
835+
const event = clone(eventWithNonMinifiedFrame);
836+
integration.preprocessEvent?.(event, {}, MOCK_CLIENT);
837+
GLOBAL_OBJ._sentryWrappedDepth = 0;
838+
const result = await integration.processEvent?.(event, {}, MOCK_CLIENT);
839+
// Should NOT be dropped — the frame has context_line, so it's not a minified sentryWrapped frame.
840+
// The real sentryWrapped frame was already stripped by the parser.
841+
expect(result).toBeDefined();
842+
});
843+
844+
it('does not use sentryWrappedDepth when ignoreSentryInternalFrames is false', async () => {
845+
const eventWithMinifiedSentryFrame: Event = {
846+
exception: {
847+
values: [
848+
{
849+
stacktrace: {
850+
frames: [
851+
{
852+
colno: 12345,
853+
filename: 'https://example.com/assets/app-abc123.js',
854+
function: 'a',
855+
lineno: 1,
856+
},
857+
{
858+
colno: 1,
859+
filename: 'other-file.js',
860+
function: 'function',
861+
lineno: 1,
862+
},
863+
],
864+
},
865+
type: 'Error',
866+
value: 'Third party error',
867+
},
868+
],
869+
},
870+
};
871+
872+
const integration = thirdPartyErrorFilterIntegration({
873+
behaviour: 'drop-error-if-exclusively-contains-third-party-frames',
874+
filterKeys: ['some-key'],
875+
ignoreSentryInternalFrames: false,
876+
});
877+
878+
GLOBAL_OBJ._sentryWrappedDepth = 1;
879+
const event = clone(eventWithMinifiedSentryFrame);
880+
integration.preprocessEvent?.(event, {}, MOCK_CLIENT);
881+
GLOBAL_OBJ._sentryWrappedDepth = 0;
882+
const result = await integration.processEvent?.(event, {}, MOCK_CLIENT);
671883
expect(result).toBeDefined();
672884
});
673885
});

0 commit comments

Comments
 (0)