Skip to content

Commit cd05479

Browse files
authored
fix(nextjs): Align Turbopack module metadata injection with webpack behavior (#19645)
- Remove `condition: { not: 'foreign' }` from the Turbopack metadata injection rule so node_modules (including e.g. React) are tagged as first-party, matching webpack's BannerPlugin behavior - Wrap injected code in a try-catch IIFE (matching the webpack plugin's `CodeInjection` pattern) to safely handle node_modules with strict initialization order - Exclude only `next/dist/build/polyfills/` which contain non-standard syntax that causes Turbopack parse errors Fixes the `thirdPartyErrorFilterIntegration` being unusable in Turbopack builds — previously, React frames in stack traces were treated as third-party because node_modules lacked metadata, causing `apply-tag-if-contains-third-party-frames` to incorrectly tag every error. closes #19320 (again)
1 parent c6b6edb commit cd05479

File tree

10 files changed

+114
-19
lines changed

10 files changed

+114
-19
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use client';
2+
3+
import * as Sentry from '@sentry/nextjs';
4+
5+
function throwFirstPartyError(): void {
6+
throw new Error('first-party-error');
7+
}
8+
9+
export default function Page() {
10+
return (
11+
<button
12+
id="first-party-error-btn"
13+
onClick={() => {
14+
try {
15+
throwFirstPartyError();
16+
} catch (e) {
17+
Sentry.captureException(e);
18+
}
19+
}}
20+
>
21+
Throw First Party Error
22+
</button>
23+
);
24+
}

dev-packages/e2e-tests/test-applications/nextjs-16-bun/instrumentation-client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ Sentry.init({
66
tunnel: `http://localhost:3031/`, // proxy server
77
tracesSampleRate: 1.0,
88
sendDefaultPii: true,
9+
integrations: [
10+
Sentry.thirdPartyErrorFilterIntegration({
11+
filterKeys: ['nextjs-16-bun-e2e'],
12+
behaviour: 'apply-tag-if-exclusively-contains-third-party-frames',
13+
}),
14+
],
915
});
1016

1117
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

dev-packages/e2e-tests/test-applications/nextjs-16-bun/next.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,7 @@ const nextConfig: NextConfig = {};
55

66
export default withSentryConfig(nextConfig, {
77
silent: true,
8+
_experimental: {
9+
turbopackApplicationKey: 'nextjs-16-bun-e2e',
10+
},
811
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import test, { expect } from '@playwright/test';
2+
import { waitForError } from '@sentry-internal/test-utils';
3+
4+
const isWebpackDev = process.env.TEST_ENV === 'development-webpack';
5+
6+
test('First-party error should not be tagged as third-party with exclusively-contains mode', async ({ page }) => {
7+
test.skip(isWebpackDev, 'Only relevant for Turbopack builds');
8+
9+
const errorPromise = waitForError('nextjs-16-bun', errorEvent => {
10+
return errorEvent?.exception?.values?.some(value => value.value === 'first-party-error') ?? false;
11+
});
12+
13+
await page.goto('/third-party-filter');
14+
await page.locator('#first-party-error-btn').click();
15+
16+
const errorEvent = await errorPromise;
17+
18+
expect(errorEvent.exception?.values?.[0]?.value).toBe('first-party-error');
19+
20+
// In production, TEST_ENV=production is shared by both turbopack and webpack variants.
21+
// Only assert when the build is actually turbopack.
22+
if (errorEvent.tags?.turbopack) {
23+
// The integration uses `apply-tag-if-exclusively-contains-third-party-frames` which
24+
// only tags errors if ALL frames are third-party. A first-party error with React frames
25+
// should not be tagged because the first-party frames have metadata.
26+
expect(errorEvent.tags?.third_party_code).toBeUndefined();
27+
}
28+
});

dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Sentry.init({
1010
integrations: [
1111
Sentry.thirdPartyErrorFilterIntegration({
1212
filterKeys: ['nextjs-16-e2e'],
13-
behaviour: 'apply-tag-if-exclusively-contains-third-party-frames',
13+
behaviour: 'apply-tag-if-contains-third-party-frames',
1414
}),
1515
],
1616
// Verify Log type is available

dev-packages/e2e-tests/test-applications/nextjs-16/tests/third-party-filter.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { waitForError } from '@sentry-internal/test-utils';
33

44
const isWebpackDev = process.env.TEST_ENV === 'development-webpack';
55

6-
test('First-party error should not be tagged as third-party code', async ({ page }) => {
6+
test('First-party error with React frames should not be tagged as third-party code', async ({ page }) => {
77
test.skip(isWebpackDev, 'Only relevant for Turbopack builds');
88

99
const errorPromise = waitForError('nextjs-16', errorEvent => {
@@ -20,6 +20,11 @@ test('First-party error should not be tagged as third-party code', async ({ page
2020
// In production, TEST_ENV=production is shared by both turbopack and webpack variants.
2121
// Only assert when the build is actually turbopack.
2222
if (errorEvent.tags?.turbopack) {
23+
// The integration uses `apply-tag-if-contains-third-party-frames` which tags errors
24+
// if ANY frame is third-party. This error is thrown inside a React onClick handler,
25+
// so the stack trace contains React frames from node_modules. These must NOT be
26+
// treated as third-party — the module metadata injection must cover node_modules too
27+
// (matching the webpack plugin's behavior).
2328
expect(errorEvent.tags?.third_party_code).toBeUndefined();
2429
}
2530
});

packages/nextjs/src/config/loaders/moduleMetadataInjectionLoader.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,18 @@ export default function moduleMetadataInjectionLoader(
2626
this.cacheable(false);
2727

2828
// The snippet mirrors what @sentry/webpack-plugin injects for moduleMetadata.
29-
// We access _sentryModuleMetadata via globalThis (not as a bare variable) to avoid
30-
// ReferenceError in strict mode. Each module is keyed by its Error stack trace so that
31-
// the SDK can map filenames to metadata at runtime.
29+
// It is wrapped in a try-catch IIFE (matching the webpack plugin's CodeInjection pattern)
30+
// so that injection failures in node_modules or unusual environments never break the module.
31+
// The IIFE resolves the global object and stores metadata keyed by (new Error).stack
32+
// so the SDK can map chunk filenames to metadata at runtime.
3233
// Not putting any newlines in the generated code will decrease the likelihood of sourcemaps breaking.
3334
const metadata = JSON.stringify({ [`_sentryBundlerPluginAppKey:${applicationKey}`]: true });
3435
const injectedCode =
35-
';globalThis._sentryModuleMetadata = globalThis._sentryModuleMetadata || {};' +
36-
`globalThis._sentryModuleMetadata[(new Error).stack] = Object.assign({}, globalThis._sentryModuleMetadata[(new Error).stack], ${metadata});`;
36+
';!function(){try{' +
37+
'var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{};' +
38+
'e._sentryModuleMetadata=e._sentryModuleMetadata||{},' +
39+
`e._sentryModuleMetadata[(new e.Error).stack]=Object.assign({},e._sentryModuleMetadata[(new e.Error).stack],${metadata});` +
40+
'}catch(e){}}();';
3741

3842
return userCode.replace(SKIP_COMMENT_AND_DIRECTIVE_REGEX, match => {
3943
return match + injectedCode;

packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,20 @@ export function constructTurbopackConfig({
5858
}
5959

6060
// Add module metadata injection loader for thirdPartyErrorFilterIntegration support.
61-
// This is only added when turbopackApplicationKey is set AND the Next.js version supports the
62-
// `condition` field in Turbopack rules (Next.js 16+). Without `condition: { not: 'foreign' }`,
63-
// the loader would tag node_modules as first-party, defeating the purpose.
61+
// This loader tags modules with `_sentryModuleMetadata` so the integration can tell
62+
// first-party code from third-party code. We intentionally do NOT use
63+
// `condition: { not: 'foreign' }` so that node_modules are also tagged as first-party,
64+
// matching the webpack plugin's BannerPlugin behavior (which injects into all chunks
65+
// without excluding node_modules). The injected code is wrapped in a try-catch IIFE
66+
// so it is safe even for node_modules with strict initialization order.
67+
// We only exclude Next.js build polyfills which contain non-standard syntax that causes
68+
// parse errors when any code is prepended (Turbopack re-parses the loader output).
6469
const applicationKey = userSentryOptions?._experimental?.turbopackApplicationKey;
6570
if (applicationKey && nextJsVersion && supportsTurbopackRuleCondition(nextJsVersion)) {
6671
newConfig.rules = safelyAddTurbopackRule(newConfig.rules, {
6772
matcher: '*.{ts,tsx,js,jsx,mjs,cjs}',
6873
rule: {
69-
condition: { not: 'foreign' },
74+
condition: { not: { path: /next\/dist\/build\/polyfills/ } },
7075
loaders: [
7176
{
7277
loader: path.resolve(__dirname, '..', 'loaders', 'moduleMetadataInjectionLoader.js'),

packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ describe('moduleMetadataInjectionLoader', () => {
3131

3232
expect(result).toContain('_sentryModuleMetadata');
3333
expect(result).toContain('_sentryBundlerPluginAppKey:my-app');
34-
expect(result).toContain('Object.assign');
34+
// Wrapped in try-catch IIFE
35+
expect(result).toContain('!function(){try{');
3536
});
3637

3738
it('should inject after "use strict" directive', () => {
@@ -104,16 +105,20 @@ describe('moduleMetadataInjectionLoader', () => {
104105
expect(result).toContain('_sentryBundlerPluginAppKey:my-app');
105106
});
106107

107-
it('should use globalThis and Object.assign merge pattern keyed by stack trace', () => {
108+
it('should use try-catch IIFE pattern matching the webpack plugin', () => {
108109
const loaderThis = createLoaderThis('my-app');
109110
const userCode = 'const x = 1;';
110111

111112
const result = moduleMetadataInjectionLoader.call(loaderThis, userCode);
112113

113-
// Should use globalThis to avoid ReferenceError in strict mode
114-
expect(result).toContain('globalThis._sentryModuleMetadata = globalThis._sentryModuleMetadata || {}');
114+
// Should be wrapped in a try-catch IIFE so injection failures never break the module
115+
expect(result).toContain('!function(){try{');
116+
expect(result).toContain('}catch(e){}}();');
117+
// Should resolve the global object like the webpack plugin does
118+
expect(result).toContain('typeof window');
119+
expect(result).toContain('typeof globalThis');
115120
// Should key by stack trace like the webpack plugin does
116-
expect(result).toContain('globalThis._sentryModuleMetadata[(new Error).stack]');
121+
expect(result).toContain('e._sentryModuleMetadata[(new e.Error).stack]');
117122
// Should use Object.assign to merge metadata
118123
expect(result).toContain('Object.assign({}');
119124
});

packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,7 @@ describe('moduleMetadataInjection with applicationKey', () => {
965965
});
966966

967967
expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toEqual({
968-
condition: { not: 'foreign' },
968+
condition: { not: { path: /next\/dist\/build\/polyfills/ } },
969969
loaders: [
970970
{
971971
loader: '/mocked/path/to/moduleMetadataInjectionLoader.js',
@@ -977,6 +977,22 @@ describe('moduleMetadataInjection with applicationKey', () => {
977977
});
978978
});
979979

980+
it('should only exclude Next.js polyfills, not all foreign modules', () => {
981+
const userNextConfig: NextConfigObject = {};
982+
983+
const result = constructTurbopackConfig({
984+
userNextConfig,
985+
userSentryOptions: { _experimental: { turbopackApplicationKey: 'my-app' } },
986+
nextJsVersion: '16.0.0',
987+
});
988+
989+
const rule = result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}'] as { condition?: { not: unknown }; loaders: unknown[] };
990+
// Unlike component annotation (which uses { not: 'foreign' }), metadata injection
991+
// must cover node_modules to match the webpack plugin's BannerPlugin behavior.
992+
// Only Next.js build polyfills are excluded because they have non-standard syntax.
993+
expect(rule.condition).toEqual({ not: { path: /next\/dist\/build\/polyfills/ } });
994+
});
995+
980996
it('should NOT add metadata loader rule when Next.js < 16', () => {
981997
const userNextConfig: NextConfigObject = {};
982998

@@ -1023,7 +1039,6 @@ describe('moduleMetadataInjection with applicationKey', () => {
10231039
});
10241040

10251041
const rule = result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}'] as {
1026-
condition: unknown;
10271042
loaders: Array<{ loader: string; options: { applicationKey: string } }>;
10281043
};
10291044
expect(rule.loaders[0]!.options.applicationKey).toBe('custom-key-123');
@@ -1070,7 +1085,7 @@ describe('moduleMetadataInjection with applicationKey', () => {
10701085
});
10711086

10721087
expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toEqual({
1073-
condition: { not: 'foreign' },
1088+
condition: { not: { path: /next\/dist\/build\/polyfills/ } },
10741089
loaders: [
10751090
{
10761091
loader: '/mocked/path/to/moduleMetadataInjectionLoader.js',

0 commit comments

Comments
 (0)