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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<button
id="first-party-error-btn"
onClick={() => {
try {
throwFirstPartyError();
} catch (e) {
Sentry.captureException(e);
}
}}
>
Throw First Party Error
</button>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ export default withSentryConfig(nextConfig, {
silent: true,
_experimental: {
vercelCronsMonitoring: true,
turbopackApplicationKey: 'nextjs-16-e2e',
},
});
Original file line number Diff line number Diff line change
@@ -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();
}
});
1 change: 1 addition & 0 deletions packages/nextjs/src/config/loaders/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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<ModuleMetadataInjectionLoaderOptions>,
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;
});
}
2 changes: 1 addition & 1 deletion packages/nextjs/src/config/loaders/valueInjectionLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])*(?:"[^"]*";?|\'[^\']*\';?)?');

Expand Down
25 changes: 24 additions & 1 deletion packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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;
}

Expand Down
11 changes: 11 additions & 0 deletions packages/nextjs/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}>;

/**
Expand Down
21 changes: 21 additions & 0 deletions packages/nextjs/test/config/getBuildPluginOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
129 changes: 129 additions & 0 deletions packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts
Original file line number Diff line number Diff line change
@@ -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<ModuleMetadataInjectionLoaderOptions> {
const base = {
addDependency: () => undefined,
async: () => undefined,
cacheable: () => undefined,
callback: () => undefined,
resourcePath: './app/page.tsx',
};

if (useGetOptions) {
return { ...base, getOptions: () => ({ applicationKey }) } as LoaderThis<ModuleMetadataInjectionLoaderOptions>;
}

return { ...base, query: { applicationKey } } as LoaderThis<ModuleMetadataInjectionLoaderOptions>;
}

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<ModuleMetadataInjectionLoaderOptions>;

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');
});
});
Loading
Loading