diff --git a/CHANGELOG.md b/CHANGELOG.md index 89b03fb3e015..17e340c1f207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **feat(nextjs): Add Turbopack support for `thirdPartyErrorFilterIntegration` ([#19542](https://github.com/getsentry/sentry-javascript/pull/19542))** + + We added experimental support for the `thirdPartyErrorFilterIntegration` with Turbopack builds. + + This feature requires Next.js 16+ and is currently behind an experimental flag: + + ```js + // next.config.ts + import { withSentryConfig } from '@sentry/nextjs'; + + export default withSentryConfig(nextConfig, { + _experimental: { + turbopackApplicationKey: 'my-app-key', + }, + }); + ``` + + Then configure the integration in your client instrumentation file with a matching key: + + ```js + // instrumentation-client.ts + import * as Sentry from '@sentry/nextjs'; + + Sentry.init({ + integrations: [ + Sentry.thirdPartyErrorFilterIntegration({ + filterKeys: ['my-app-key'], + behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', + }), + ], + }); + ``` + Work in this release was contributed by @YevheniiKotyrlo. Thank you for your contribution! ## 10.40.0 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/third-party-filter/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/third-party-filter/page.tsx new file mode 100644 index 000000000000..b6b4bea80def --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/third-party-filter/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +function throwFirstPartyError(): void { + throw new Error('first-party-error'); +} + +export default function Page() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts index ae4e3195a2a1..934a50fb786d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts @@ -7,6 +7,12 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, sendDefaultPii: true, + integrations: [ + Sentry.thirdPartyErrorFilterIntegration({ + filterKeys: ['nextjs-16-e2e'], + behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', + }), + ], // Verify Log type is available beforeSendLog(log: Log) { return log; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts index 5e72a02200d8..342ba13b1206 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts @@ -10,5 +10,6 @@ export default withSentryConfig(nextConfig, { silent: true, _experimental: { vercelCronsMonitoring: true, + turbopackApplicationKey: 'nextjs-16-e2e', }, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/third-party-filter.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/third-party-filter.test.ts new file mode 100644 index 000000000000..277a53fd394b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/third-party-filter.test.ts @@ -0,0 +1,25 @@ +import test, { expect } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const isWebpackDev = process.env.TEST_ENV === 'development-webpack'; + +test('First-party error should not be tagged as third-party code', async ({ page }) => { + test.skip(isWebpackDev, 'Only relevant for Turbopack builds'); + + const errorPromise = waitForError('nextjs-16', errorEvent => { + return errorEvent?.exception?.values?.some(value => value.value === 'first-party-error') ?? false; + }); + + await page.goto('/third-party-filter'); + await page.locator('#first-party-error-btn').click(); + + const errorEvent = await errorPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('first-party-error'); + + // 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) { + expect(errorEvent.tags?.third_party_code).toBeUndefined(); + } +}); diff --git a/packages/nextjs/src/config/loaders/index.ts b/packages/nextjs/src/config/loaders/index.ts index 322567c1495b..359d72d7def6 100644 --- a/packages/nextjs/src/config/loaders/index.ts +++ b/packages/nextjs/src/config/loaders/index.ts @@ -1,3 +1,4 @@ export { default as valueInjectionLoader } from './valueInjectionLoader'; export { default as prefixLoader } from './prefixLoader'; export { default as wrappingLoader } from './wrappingLoader'; +export { default as moduleMetadataInjectionLoader } from './moduleMetadataInjectionLoader'; diff --git a/packages/nextjs/src/config/loaders/moduleMetadataInjectionLoader.ts b/packages/nextjs/src/config/loaders/moduleMetadataInjectionLoader.ts new file mode 100644 index 000000000000..3bfe974b59fb --- /dev/null +++ b/packages/nextjs/src/config/loaders/moduleMetadataInjectionLoader.ts @@ -0,0 +1,41 @@ +import type { LoaderThis } from './types'; +import { SKIP_COMMENT_AND_DIRECTIVE_REGEX } from './valueInjectionLoader'; + +export type ModuleMetadataInjectionLoaderOptions = { + applicationKey: string; +}; + +/** + * Inject `_sentryModuleMetadata` into every module so that the + * `thirdPartyErrorFilterIntegration` can tell first-party code from + * third-party code. + * + * This is the Turbopack equivalent of what `@sentry/webpack-plugin` does + * via its `moduleMetadata` option. + * + * Options: + * - `applicationKey`: The application key used to tag first-party modules. + */ +export default function moduleMetadataInjectionLoader( + this: LoaderThis, + userCode: string, +): string { + const { applicationKey } = 'getOptions' in this ? this.getOptions() : this.query; + + // We do not want to cache injected values across builds + this.cacheable(false); + + // The snippet mirrors what @sentry/webpack-plugin injects for moduleMetadata. + // We access _sentryModuleMetadata via globalThis (not as a bare variable) to avoid + // ReferenceError in strict mode. Each module is keyed by its Error stack trace so that + // the SDK can map filenames to metadata at runtime. + // Not putting any newlines in the generated code will decrease the likelihood of sourcemaps breaking. + const metadata = JSON.stringify({ [`_sentryBundlerPluginAppKey:${applicationKey}`]: true }); + const injectedCode = + ';globalThis._sentryModuleMetadata = globalThis._sentryModuleMetadata || {};' + + `globalThis._sentryModuleMetadata[(new Error).stack] = Object.assign({}, globalThis._sentryModuleMetadata[(new Error).stack], ${metadata});`; + + return userCode.replace(SKIP_COMMENT_AND_DIRECTIVE_REGEX, match => { + return match + injectedCode; + }); +} diff --git a/packages/nextjs/src/config/loaders/valueInjectionLoader.ts b/packages/nextjs/src/config/loaders/valueInjectionLoader.ts index c15413cd1444..3fe15a8e5872 100644 --- a/packages/nextjs/src/config/loaders/valueInjectionLoader.ts +++ b/packages/nextjs/src/config/loaders/valueInjectionLoader.ts @@ -10,7 +10,7 @@ export type ValueInjectionLoaderOptions = { // We need to be careful not to inject anything before any `"use strict";`s or "use client"s or really any other directive. // As an additional complication directives may come after any number of comments. // This regex is shamelessly stolen from: https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/7f984482c73e4284e8b12a08dfedf23b5a82f0af/packages/bundler-plugin-core/src/index.ts#L535-L539 -const SKIP_COMMENT_AND_DIRECTIVE_REGEX = +export const SKIP_COMMENT_AND_DIRECTIVE_REGEX = // Note: CodeQL complains that this regex potentially has n^2 runtime. This likely won't affect realistic files. new RegExp('^(?:\\s*|/\\*(?:.|\\r|\\n)*?\\*/|//.*[\\n\\r])*(?:"[^"]*";?|\'[^\']*\';?)?'); diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 3484a169d018..d8f70efbacf1 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,8 +1,9 @@ import { debug } from '@sentry/core'; +import * as path from 'path'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; import type { NextConfigObject, SentryBuildOptions, TurbopackMatcherWithRule, TurbopackOptions } from '../types'; -import { supportsNativeDebugIds } from '../util'; +import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util'; import { generateValueInjectionRules } from './generateValueInjectionRules'; /** @@ -56,6 +57,28 @@ export function constructTurbopackConfig({ newConfig.rules = safelyAddTurbopackRule(newConfig.rules, { matcher, rule }); } + // Add module metadata injection loader for thirdPartyErrorFilterIntegration support. + // This is only added when turbopackApplicationKey is set AND the Next.js version supports the + // `condition` field in Turbopack rules (Next.js 16+). Without `condition: { not: 'foreign' }`, + // the loader would tag node_modules as first-party, defeating the purpose. + const applicationKey = userSentryOptions?._experimental?.turbopackApplicationKey; + if (applicationKey && nextJsVersion && supportsTurbopackRuleCondition(nextJsVersion)) { + newConfig.rules = safelyAddTurbopackRule(newConfig.rules, { + matcher: '*.{ts,tsx,js,jsx,mjs,cjs}', + rule: { + condition: { not: 'foreign' }, + loaders: [ + { + loader: path.resolve(__dirname, '..', 'loaders', 'moduleMetadataInjectionLoader.js'), + options: { + applicationKey, + }, + }, + ], + }, + }); + } + return newConfig; } diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index bc2b5463d2ca..233860fb1388 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -713,6 +713,17 @@ export type SentryBuildOptions = { * Requires cron jobs to be configured in `vercel.json`. */ vercelCronsMonitoring?: boolean; + /** + * Application key used by `thirdPartyErrorFilterIntegration` to distinguish + * first-party code from third-party code in Turbopack builds. + * + * When set, a Turbopack loader injects `_sentryModuleMetadata` into every + * first-party module, mirroring what `@sentry/webpack-plugin` does for + * webpack builds via its `moduleMetadata` / `applicationKey` option. + * + * Requires Next.js 16+ + */ + turbopackApplicationKey?: string; }>; /** diff --git a/packages/nextjs/test/config/getBuildPluginOptions.test.ts b/packages/nextjs/test/config/getBuildPluginOptions.test.ts index 329ca59eedc1..6eecd83905b8 100644 --- a/packages/nextjs/test/config/getBuildPluginOptions.test.ts +++ b/packages/nextjs/test/config/getBuildPluginOptions.test.ts @@ -902,6 +902,27 @@ describe('getBuildPluginOptions', () => { }); }); + describe('applicationKey is not forwarded to webpack plugin', () => { + it('does not include turbopackApplicationKey in webpack plugin options', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + _experimental: { turbopackApplicationKey: 'my-app' }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', + }); + + // turbopackApplicationKey should only be used by the Turbopack loader, + // not forwarded to the webpack plugin + expect(result.applicationKey).toBeUndefined(); + }); + }); + describe('edge cases', () => { it('handles undefined release name gracefully', () => { const sentryBuildOptions: SentryBuildOptions = { diff --git a/packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts b/packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts new file mode 100644 index 000000000000..35e7ba4b692a --- /dev/null +++ b/packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import type { ModuleMetadataInjectionLoaderOptions } from '../../src/config/loaders/moduleMetadataInjectionLoader'; +import moduleMetadataInjectionLoader from '../../src/config/loaders/moduleMetadataInjectionLoader'; +import type { LoaderThis } from '../../src/config/loaders/types'; + +function createLoaderThis( + applicationKey: string, + useGetOptions = true, +): LoaderThis { + const base = { + addDependency: () => undefined, + async: () => undefined, + cacheable: () => undefined, + callback: () => undefined, + resourcePath: './app/page.tsx', + }; + + if (useGetOptions) { + return { ...base, getOptions: () => ({ applicationKey }) } as LoaderThis; + } + + return { ...base, query: { applicationKey } } as LoaderThis; +} + +describe('moduleMetadataInjectionLoader', () => { + it('should inject metadata snippet into simple code', () => { + const loaderThis = createLoaderThis('my-app'); + const userCode = "import * as Sentry from '@sentry/nextjs';\nSentry.init();"; + + const result = moduleMetadataInjectionLoader.call(loaderThis, userCode); + + expect(result).toContain('_sentryModuleMetadata'); + expect(result).toContain('_sentryBundlerPluginAppKey:my-app'); + expect(result).toContain('Object.assign'); + }); + + it('should inject after "use strict" directive', () => { + const loaderThis = createLoaderThis('my-app'); + const userCode = '"use strict";\nconsole.log("hello");'; + + const result = moduleMetadataInjectionLoader.call(loaderThis, userCode); + + const metadataIndex = result.indexOf('_sentryModuleMetadata'); + const directiveIndex = result.indexOf('"use strict"'); + expect(metadataIndex).toBeGreaterThan(directiveIndex); + }); + + it('should inject after "use client" directive', () => { + const loaderThis = createLoaderThis('my-app'); + const userCode = '"use client";\nimport React from \'react\';'; + + const result = moduleMetadataInjectionLoader.call(loaderThis, userCode); + + const metadataIndex = result.indexOf('_sentryModuleMetadata'); + const directiveIndex = result.indexOf('"use client"'); + expect(metadataIndex).toBeGreaterThan(directiveIndex); + }); + + it('should handle code with leading comments before directives', () => { + const loaderThis = createLoaderThis('my-app'); + const userCode = '// some comment\n"use client";\nimport React from \'react\';'; + + const result = moduleMetadataInjectionLoader.call(loaderThis, userCode); + + expect(result).toContain('_sentryBundlerPluginAppKey:my-app'); + const metadataIndex = result.indexOf('_sentryModuleMetadata'); + const directiveIndex = result.indexOf('"use client"'); + expect(metadataIndex).toBeGreaterThan(directiveIndex); + }); + + it('should handle code with block comments before directives', () => { + const loaderThis = createLoaderThis('my-app'); + const userCode = '/* block comment */\n"use client";\nimport React from \'react\';'; + + const result = moduleMetadataInjectionLoader.call(loaderThis, userCode); + + expect(result).toContain('_sentryBundlerPluginAppKey:my-app'); + }); + + it('should set cacheable to false', () => { + let cacheableValue: boolean | undefined; + const loaderThis = { + addDependency: () => undefined, + async: () => undefined, + cacheable: (flag: boolean) => { + cacheableValue = flag; + }, + callback: () => undefined, + resourcePath: './app/page.tsx', + getOptions: () => ({ applicationKey: 'my-app' }), + } as LoaderThis; + + moduleMetadataInjectionLoader.call(loaderThis, 'const x = 1;'); + + expect(cacheableValue).toBe(false); + }); + + it('should work with webpack 4 query API', () => { + const loaderThis = createLoaderThis('my-app', false); + const userCode = 'const x = 1;'; + + const result = moduleMetadataInjectionLoader.call(loaderThis, userCode); + + expect(result).toContain('_sentryBundlerPluginAppKey:my-app'); + }); + + it('should use globalThis and Object.assign merge pattern keyed by stack trace', () => { + const loaderThis = createLoaderThis('my-app'); + const userCode = 'const x = 1;'; + + const result = moduleMetadataInjectionLoader.call(loaderThis, userCode); + + // Should use globalThis to avoid ReferenceError in strict mode + expect(result).toContain('globalThis._sentryModuleMetadata = globalThis._sentryModuleMetadata || {}'); + // Should key by stack trace like the webpack plugin does + expect(result).toContain('globalThis._sentryModuleMetadata[(new Error).stack]'); + // Should use Object.assign to merge metadata + expect(result).toContain('Object.assign({}'); + }); + + it('should contain the correct app key format in output', () => { + const loaderThis = createLoaderThis('test-key-123'); + const userCode = 'export default function Page() {}'; + + const result = moduleMetadataInjectionLoader.call(loaderThis, userCode); + + expect(result).toContain('"_sentryBundlerPluginAppKey:test-key-123":true'); + }); +}); diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index 00bf3ece5935..d1bf313d16f2 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -12,7 +12,13 @@ vi.mock('path', async () => { const actual = await vi.importActual('path'); return { ...actual, - resolve: vi.fn().mockReturnValue('/mocked/path/to/valueInjectionLoader.js'), + resolve: vi.fn().mockImplementation((...args: string[]) => { + const lastArg = args[args.length - 1]; + if (lastArg === 'moduleMetadataInjectionLoader.js') { + return '/mocked/path/to/moduleMetadataInjectionLoader.js'; + } + return '/mocked/path/to/valueInjectionLoader.js'; + }), }; }); @@ -936,6 +942,144 @@ describe('condition field version gating', () => { }); }); +describe('moduleMetadataInjection with applicationKey', () => { + it('should add metadata loader rule when applicationKey is set and Next.js >= 16', () => { + const pathResolveSpy = vi.spyOn(path, 'resolve'); + pathResolveSpy.mockImplementation((...args: string[]) => { + const lastArg = args[args.length - 1]; + if (lastArg === 'moduleMetadataInjectionLoader.js') { + return '/mocked/path/to/moduleMetadataInjectionLoader.js'; + } + return '/mocked/path/to/valueInjectionLoader.js'; + }); + + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: { _experimental: { turbopackApplicationKey: 'my-app' } }, + nextJsVersion: '16.0.0', + }); + + expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toEqual({ + condition: { not: 'foreign' }, + loaders: [ + { + loader: '/mocked/path/to/moduleMetadataInjectionLoader.js', + options: { + applicationKey: 'my-app', + }, + }, + ], + }); + }); + + it('should NOT add metadata loader rule when Next.js < 16', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: { _experimental: { turbopackApplicationKey: 'my-app' } }, + nextJsVersion: '15.4.1', + }); + + 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 = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: {}, + nextJsVersion: '16.0.0', + }); + + expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toBeUndefined(); + }); + + it('should NOT add metadata loader rule when nextJsVersion is undefined', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: { _experimental: { turbopackApplicationKey: 'my-app' } }, + nextJsVersion: undefined, + }); + + expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toBeUndefined(); + }); + + it('should pass applicationKey through to loader options correctly', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: { _experimental: { turbopackApplicationKey: 'custom-key-123' } }, + nextJsVersion: '16.0.0', + }); + + const rule = result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}'] as { + condition: unknown; + loaders: Array<{ loader: string; options: { applicationKey: string } }>; + }; + expect(rule.loaders[0]!.options.applicationKey).toBe('custom-key-123'); + }); + + it('should coexist with existing value injection rules', () => { + const userNextConfig: NextConfigObject = {}; + const mockRouteManifest: RouteManifest = { + dynamicRoutes: [], + staticRoutes: [{ path: '/', regex: '/' }], + isrRoutes: [], + }; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: { _experimental: { turbopackApplicationKey: 'my-app' } }, + routeManifest: mockRouteManifest, + nextJsVersion: '16.0.0', + }); + + // Value injection rules should still be present + expect(result.rules!['**/instrumentation-client.*']).toBeDefined(); + expect(result.rules!['**/instrumentation.*']).toBeDefined(); + // Metadata loader rule should also be present + expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toBeDefined(); + }); + + it('should add metadata loader rule for Next.js 17+', () => { + const pathResolveSpy = vi.spyOn(path, 'resolve'); + pathResolveSpy.mockImplementation((...args: string[]) => { + const lastArg = args[args.length - 1]; + if (lastArg === 'moduleMetadataInjectionLoader.js') { + return '/mocked/path/to/moduleMetadataInjectionLoader.js'; + } + return '/mocked/path/to/valueInjectionLoader.js'; + }); + + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: { _experimental: { turbopackApplicationKey: 'my-app' } }, + nextJsVersion: '17.0.0', + }); + + expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toEqual({ + condition: { not: 'foreign' }, + loaders: [ + { + loader: '/mocked/path/to/moduleMetadataInjectionLoader.js', + options: { + applicationKey: 'my-app', + }, + }, + ], + }); + }); +}); + describe('safelyAddTurbopackRule', () => { const mockRule = { loaders: [