Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,37 @@ function throwFirstPartyError(): void {

export default function Page() {
return (
<button
id="first-party-error-btn"
onClick={() => {
try {
throwFirstPartyError();
} catch (e) {
Sentry.captureException(e);
}
}}
>
Throw First Party Error
</button>
<>
<button
id="first-party-error-btn"
onClick={() => {
try {
throwFirstPartyError();
} catch (e) {
Sentry.captureException(e);
}
}}
>
Throw First Party Error
</button>
<button
id="dependency-error-btn"
onClick={async () => {
const { createDependencyError } = await import('sentry-metadata-test-dependency');
Sentry.captureException(createDependencyError());
}}
>
Capture Dependency Error
</button>
<button
id="dependency-only-error-btn"
onClick={async () => {
const { createAsyncDependencyError } = await import('sentry-metadata-test-dependency');
Sentry.captureException(await createAsyncDependencyError());
}}
>
Capture Dependency-Only Error
</button>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export function createDependencyError(): Error;
export function createAsyncDependencyError(): Promise<Error>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function createDependencyError() {
return new Error('application-key-dependency-error');
}

export function createAsyncDependencyError() {
return new Promise(resolve => {
// Wait until the application's call stack has unwound so the error stack contains only a dependency frame.
queueMicrotask(() => resolve(new Error('application-key-dependency-only-error')));
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "sentry-metadata-test-dependency",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "index.js"
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ Sentry.init({
integrations: [
Sentry.thirdPartyErrorFilterIntegration({
filterKeys: ['nextjs-16-e2e'],
behaviour: 'apply-tag-if-contains-third-party-frames',
behaviour:
process.env.NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES === '1'
? 'apply-tag-if-exclusively-contains-third-party-frames'
: 'apply-tag-if-contains-third-party-frames',
}),
],
// Verify Log type is available
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const nextConfig: NextConfig = {
export default withSentryConfig(nextConfig, {
silent: true,
applicationKey: 'nextjs-16-e2e',
applicationKeyExcludeDependencies: process.env.NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES === '1',
reactComponentAnnotation: {
enabled: true,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"require-in-the-middle": "^8",
"sentry-metadata-test-dependency": "file:../../test-applications/nextjs-16/fixtures/metadata-dependency",
"zod": "^3.22.4"
},
"devDependencies": {
Expand All @@ -66,6 +67,11 @@
"sentryTest": {
"runner": "namespace-profile-sentry-javascript",
"variants": [
{
"build-command": "NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES=1 pnpm test:build",
"label": "nextjs-16 (exclude application key dependencies)",
"assert-command": "NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES=1 pnpm test:assert"
},
{
"build-command": "pnpm test:build-webpack",
"label": "nextjs-16 (webpack)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ import { isVinext } from './isVinext';

test.skip(isVinext, 'Checks the build output of Next.js');

test('excludes dependency metadata while preserving application metadata', () => {
test.skip(isDevMode || process.env.NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES !== '1');

const staticDir = path.join(process.cwd(), '.next', 'static');
const chunks = fs
.readdirSync(staticDir, { recursive: true })
.filter(file => String(file).endsWith('.js'))
.map(file => fs.readFileSync(path.join(staticDir, String(file)), 'utf8'));
const dependencyChunks = chunks.filter(chunk => chunk.includes('application-key-dependency-error'));
const applicationChunks = chunks.filter(chunk => chunk.includes('first-party-error'));

expect(dependencyChunks.length).toBeGreaterThan(0);
expect(applicationChunks.length).toBeGreaterThan(0);
for (const chunk of dependencyChunks) {
expect(chunk).not.toContain('_sentryBundlerPluginAppKey:nextjs-16-e2e');
}
for (const chunk of applicationChunks) {
expect(chunk).toContain('_sentryBundlerPluginAppKey:nextjs-16-e2e');
}
});

test('emits no absolute-path imports into the server output', () => {
const leaks = findAbsolutePathImports({ outputDir: path.join(process.cwd(), '.next', 'server') });

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import test, { expect } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';
import { isDevMode } from './isDevMode';
import { isVinext } from './isVinext';

const isWebpackDev = process.env.TEST_ENV === 'development-webpack';
Expand All @@ -13,6 +14,7 @@ test('First-party error with React frames should not be tagged as third-party co
});

await page.goto('/third-party-filter');
await page.waitForFunction(() => '__SENTRY__' in window);
await page.locator('#first-party-error-btn').click();

const errorEvent = await errorPromise;
Expand All @@ -22,11 +24,54 @@ test('First-party error with React frames should not be tagged as third-party co
// In production, TEST_ENV=production is shared by both turbopack and webpack variants.
// Only assert when the build is actually turbopack.
if (errorEvent.tags?.turbopack) {
// The integration uses `apply-tag-if-contains-third-party-frames` which tags errors
// By default, the integration uses `apply-tag-if-contains-third-party-frames` which tags errors
// if ANY frame is third-party. This error is thrown inside a React onClick handler,
// so the stack trace contains React frames from node_modules. These must NOT be
// treated as third-party — the module metadata injection must cover node_modules too
// (matching the webpack plugin's behavior).
expect(errorEvent.tags?.third_party_code).toBeUndefined();
}
});

test('retains application errors with dependency frames when dependency metadata is excluded', async ({ page }) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noting that this is just a regression test because the stack will always contain the onClick frame. Maybe would be worth adding a second test to check the case with library frames only.

test.skip(process.env.NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES !== '1');

const errorPromise = waitForError('nextjs-16', event => {
return event.exception?.values?.some(value => value.value === 'application-key-dependency-error') ?? false;
});

await page.goto('/third-party-filter');
await page.waitForFunction(() => '__SENTRY__' in window);
await page.locator('#dependency-error-btn').click();

const event = await errorPromise;
const exception = event.exception?.values?.[0];
expect(exception?.value).toBe('application-key-dependency-error');
expect(exception?.mechanism).toEqual({ handled: true, type: 'generic' });
expect(event.tags?.third_party_code).toBeUndefined();
expect(event.tags?.turbopack).toBe(true);
expect(new Set(exception?.stacktrace?.frames?.map(frame => frame.filename)).size).toBeGreaterThan(1);
});

test('classifies dependency-only errors according to dependency metadata inclusion', async ({ page }) => {
test.skip(isVinext || isDevMode, 'Checks dependency-only chunks in production Next.js builds');

const errorPromise = waitForError('nextjs-16', event => {
return event.exception?.values?.some(value => value.value === 'application-key-dependency-only-error') ?? false;
});

await page.goto('/third-party-filter');
await page.waitForFunction(() => '__SENTRY__' in window);
await page.locator('#dependency-only-error-btn').click();

const event = await errorPromise;
const frames = event.exception?.values?.[0]?.stacktrace?.frames;
expect(frames).toHaveLength(1);

const filename = frames![0]!.filename;
const chunkResponse = await page.request.get(filename!.replace('app://', ''));
expect(await chunkResponse.text()).toContain('application-key-dependency-only-error');
expect(event.tags?.third_party_code).toBe(
process.env.NEXT_PUBLIC_EXCLUDE_APPLICATION_KEY_DEPENDENCIES === '1' ? true : undefined,
);
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
});
23 changes: 13 additions & 10 deletions packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,22 +65,25 @@ export function constructTurbopackConfig({
newConfig.rules = safelyAddTurbopackRule(newConfig.rules, { matcher, rule });
}

// Add module metadata injection loader for thirdPartyErrorFilterIntegration support.
// This loader tags modules with `_sentryModuleMetadata` so the integration can tell
// first-party code from third-party code. We intentionally do NOT use
// `condition: { not: 'foreign' }` so that node_modules are also tagged as first-party,
// matching the webpack plugin's BannerPlugin behavior (which injects into all chunks
// without excluding node_modules). The injected code is wrapped in a try-catch IIFE
// so it is safe even for node_modules with strict initialization order.
// We only exclude Next.js build polyfills which contain non-standard syntax that causes
// parse errors when any code is prepended (Turbopack re-parses the loader output).
// Tag modules so thirdPartyErrorFilterIntegration can identify first-party code. Dependencies
// are tagged by default to match webpack's BannerPlugin, which injects metadata into all chunks.
// Opting out with `applicationKeyExcludeDependencies` can leave dependency-only chunks untagged.
// The injected code is wrapped in a try-catch IIFE, so it is safe even for dependencies with
// strict initialization order. Next.js build polyfills are excluded because they contain
// non-standard syntax that causes parse errors when Turbopack re-parses the loader output.
// Server and edge modules are skipped because only the browser reads these tags.
const applicationKey = userSentryOptions?.applicationKey;
if (applicationKey && nextJsVersion && supportsTurbopackRuleCondition(nextJsVersion)) {
newConfig.rules = safelyAddTurbopackRule(newConfig.rules, {
matcher: '*.{ts,tsx,js,jsx,mjs,cjs}',
rule: {
condition: { all: ['browser', { not: { path: /next\/dist\/build\/polyfills/ } }] },
condition: {
all: [
'browser',
{ not: { path: /next\/dist\/build\/polyfills/ } },
...(userSentryOptions?.applicationKeyExcludeDependencies ? [{ not: 'foreign' as const }] : []),
],
Comment thread
msonnb marked this conversation as resolved.
Resolved
},
loaders: [
{
loader: path.resolve(__dirname, '..', 'loaders', 'moduleMetadataInjectionLoader.js'),
Expand Down
15 changes: 15 additions & 0 deletions packages/nextjs/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,21 @@ export type SentryBuildOptions = Omit<
*/
applicationKey?: string;

/**
* Skip application key metadata injection for dependencies in Turbopack builds with Next.js 16+.
* Uses Turbopack's `foreign` condition, which includes `node_modules` and some Next.js internals.
* Requires `applicationKey`. Has no effect on webpack builds.
*
* This reduces per-module injection overhead, but chunks containing only dependencies may no
* longer be tagged as first-party. Only use with `*-exclusively-contains-third-party-frames`
* behaviours of `thirdPartyErrorFilterIntegration`, and accept that errors whose stacks are
* entirely in untagged chunks can be dropped or tagged as third-party. With `*-contains-*`
* behaviours, even one frame from an untagged chunk can cause this misclassification.
*
* @default false
*/
applicationKeyExcludeDependencies?: boolean;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could print a build time warning if this option is used in webpack or without applicationKey


/**
* Metadata that should be associated with the built application.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { getBuildLogger } from '../buildLogger';
import type { NextConfigObject, SentryBuildOptions } from '../types';
import { getNextjsVersion } from '../util';
import { setUpBuildTimeVariables } from './buildTime';
import type { BundlerInfo } from './getFinalConfigObjectBundlerUtils';
import {
getBundlerInfo,
getServerExternalPackagesPatch,
Expand Down Expand Up @@ -61,6 +63,7 @@ export function getFinalConfigObject(
const bundlerInfo = getBundlerInfo(nextJsVersion);
maybeWarnAboutUnsupportedTurbopack(nextJsVersion, bundlerInfo, userSentryOptions.silent);
maybeWarnAboutTurbopackModuleMetadata(userSentryOptions, bundlerInfo);
maybeWarnAboutApplicationKeyExcludeDependencies(userSentryOptions, bundlerInfo);
maybeWarnAboutUnsupportedRunAfterProductionCompileHook(nextJsVersion, userSentryOptions, bundlerInfo);

const turboPackConfig = maybeConstructTurbopackConfig(
Expand Down Expand Up @@ -108,3 +111,23 @@ export function getFinalConfigObject(
...getTurbopackPatch(bundlerInfo, turboPackConfig),
};
}

function maybeWarnAboutApplicationKeyExcludeDependencies(
userSentryOptions: SentryBuildOptions,
bundlerInfo: BundlerInfo,
): void {
if (!userSentryOptions.applicationKeyExcludeDependencies) {
return;
}

const logger = getBuildLogger(userSentryOptions.silent);
if (!userSentryOptions.applicationKey) {
logger.warn(
'[@sentry/nextjs] `applicationKeyExcludeDependencies` requires `applicationKey` and has no effect without it.',
);
} else if (bundlerInfo.isWebpack) {
logger.warn(
'[@sentry/nextjs] `applicationKeyExcludeDependencies` only applies to Turbopack builds and has no effect on webpack builds.',
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -990,20 +990,40 @@ describe('moduleMetadataInjection with applicationKey', () => {
expect(rule.loaders[0]!.options.applicationKey).toBe('my-top-level-key');
});

it('should only tag browser modules and exclude Next.js polyfills, not all foreign modules', () => {
const userNextConfig: NextConfigObject = {};
it.each([undefined, false])(
'includes dependencies when applicationKeyExcludeDependencies is %s',
applicationKeyExcludeDependencies => {
const userNextConfig: NextConfigObject = {};

const result = constructTurbopackConfig({
userNextConfig,
userSentryOptions: { applicationKey: 'my-app', applicationKeyExcludeDependencies },
nextJsVersion: '16.0.0',
});

const rule = result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}'] as { condition?: unknown; loaders: unknown[] };
expect(rule.condition).toEqual({ all: ['browser', { not: { path: /next\/dist\/build\/polyfills/ } }] });
},
);

it('excludes dependencies from browser metadata injection when opted in', () => {
const result = constructTurbopackConfig({
userNextConfig,
userSentryOptions: { applicationKey: 'my-app' },
userNextConfig: {},
userSentryOptions: { applicationKey: 'my-app', applicationKeyExcludeDependencies: true },
nextJsVersion: '16.0.0',
});

const rule = result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}'] as { condition?: unknown; loaders: unknown[] };
// Unlike component annotation (which uses { not: 'foreign' }), metadata injection
// must cover node_modules to match the webpack plugin's BannerPlugin behavior.
// Only Next.js build polyfills are excluded because they have non-standard syntax.
expect(rule.condition).toEqual({ all: ['browser', { not: { path: /next\/dist\/build\/polyfills/ } }] });
expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toEqual({
condition: {
all: ['browser', { not: { path: /next\/dist\/build\/polyfills/ } }, { not: 'foreign' }],
},
loaders: [
{
loader: '/mocked/path/to/moduleMetadataInjectionLoader.js',
options: { applicationKey: 'my-app' },
},
],
});
});

it('should NOT add metadata loader rule when Next.js < 16', () => {
Expand All @@ -1018,17 +1038,20 @@ describe('moduleMetadataInjection with applicationKey', () => {
expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toBeUndefined();
});

it('should NOT add metadata loader rule when applicationKey is not set', () => {
const userNextConfig: NextConfigObject = {};
it.each([undefined, true])(
'does not inject metadata without applicationKey when exclusion is %s',
applicationKeyExcludeDependencies => {
const userNextConfig: NextConfigObject = {};

const result = constructTurbopackConfig({
userNextConfig,
userSentryOptions: {},
nextJsVersion: '16.0.0',
});
const result = constructTurbopackConfig({
userNextConfig,
userSentryOptions: { applicationKeyExcludeDependencies },
nextJsVersion: '16.0.0',
});

expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toBeUndefined();
});
expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toBeUndefined();
},
);

it('should NOT add metadata loader rule when nextJsVersion is undefined', () => {
const userNextConfig: NextConfigObject = {};
Expand Down
Loading
Loading