From 422d546553ace0b862efbb9bfab18f1d7b74a6c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:55:43 -0500 Subject: [PATCH 01/14] chore(deps): bump next from 15.5.4 to 15.5.7 in /dev-packages/e2e-tests/test-applications/nextjs-15-intl (#18400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [next](https://github.com/vercel/next.js) from 15.5.4 to 15.5.7.
Release notes

Sourced from next's releases.

v15.5.7

Please see CVE-2025-66478 for additional details about this release.

v15.5.6

[!NOTE]
This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

Credits

Huge thanks to @​mischnic for helping!

v15.5.5

[!NOTE]
This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

Misc Changes

Credits

Huge thanks to @​devjiwonchoi, @​ztanner, and @​icyJoseph for helping!

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=next&package-manager=npm_and_yarn&previous-version=15.5.4&new-version=15.5.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../e2e-tests/test-applications/nextjs-15-intl/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json index 359b939eaf50..af7863b46e81 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json @@ -15,7 +15,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.5.4", + "next": "15.5.7", "next-intl": "^4.3.12", "react": "latest", "react-dom": "latest", From 4b7afc96718b9c525744260414938e66ec685a1d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:36:32 -0500 Subject: [PATCH 02/14] chore(deps): bump next from 16.0.0 to 16.0.7 in /dev-packages/e2e-tests/test-applications/nextjs-16 (#18399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [next](https://github.com/vercel/next.js) from 16.0.0 to 16.0.7.
Release notes

Sourced from next's releases.

v16.0.7

Please see CVE-2025-66478 for additional details about this release.

v16.0.6

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • bump the browserslist version to silence a warning in CI (#86625)

Credits

Huge thanks to @​lukesandberg for helping!

v16.0.5

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • fix(nodejs-middleware): await for body cloning to be properly finalized (#85418)

Credits

Huge thanks to @​lucasadrianof for helping!

v16.0.4

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • fix: Rename proxy.js to middleware.js in NFT file (#86214)
  • fix: prevent fetch abort errors propagating to user error boundaries (#86277)
  • Turbopack: fix passing project options from napi (#86256)

Credits

Huge thanks to @​devjiwonchoi, @​sokra and @​ztanner for helping!

v16.0.3

Core Changes

  • fix: Rspack throw error when using ForceCompleteRuntimePlugin: #85221
  • fix: build CLI output not displaying Proxy (Middleware) when nodejs runtime: #85403
  • fix: staleTimes.static should consistently enforce a 30s minimum: #85479
  • [turbopack] fix build of empty entries of pages: #84873
  • Cache the head separately from the route tree: #84724
  • Allow inspecting dev server on default port with next dev --inspect: #85037

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=next&package-manager=npm_and_yarn&previous-version=16.0.0&new-version=16.0.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/e2e-tests/test-applications/nextjs-16/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 662e1b85936a..54e3fc3eddeb 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -27,7 +27,7 @@ "@sentry/core": "latest || *", "ai": "^3.0.0", "import-in-the-middle": "^2", - "next": "16.0.0", + "next": "16.0.7", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^8", From 656e8de4bc2f0b15ac9f164b8c4eec23eb8fd180 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:37:11 +0000 Subject: [PATCH 03/14] ci(deps): bump actions/create-github-app-token from 2.1.4 to 2.2.0 (#18362) --- .github/workflows/auto-release.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index f693c62d765d..e1f22cff2f64 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ff10040dc97..4a0278ae85a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} From fdf22a31afce27e71d74ae46db7de253e53fcb6f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:39:57 +0100 Subject: [PATCH 04/14] chore(deps): bump next from 15.5.4 to 15.5.7 in /dev-packages/e2e-tests/test-applications/nextjs-15 (#18411) --- dev-packages/e2e-tests/test-applications/nextjs-15/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index 9d56bf6c3df5..2c0a4956e34a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -20,7 +20,7 @@ "@types/react": "18.0.26", "@types/react-dom": "18.0.9", "ai": "^3.0.0", - "next": "15.5.4", + "next": "15.5.7", "react": "latest", "react-dom": "latest", "typescript": "~5.0.0", From 6e539e03c827ef1ad53d2279a91943f9b0deea1a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 5 Dec 2025 17:21:01 +0200 Subject: [PATCH 05/14] feat(nextjs): Deprecate Webpack top-level options (#18343) This PR deprecates the webpack-only configurations via the `@deprecated` JSDoc annotation and introduces a new `webpack` config namespace for them. Under the hood the logic was changed to read from the new values, with a compatibility layer that sets them from the deprecated top-level options while warning for each option if used. This should set us up for a v11/v12 deletion of those options. I might have missed a few options that only affect webpack, so I appreciate a good look at this. At any case this isn't breaking so even if missed a few, users won't experience disruptions. --- .../src/config/getBuildPluginOptions.ts | 10 +- packages/nextjs/src/config/types.ts | 106 ++++++++++ packages/nextjs/src/config/webpack.ts | 12 +- .../nextjs/src/config/withSentryConfig.ts | 92 ++++++++- .../test/config/getBuildPluginOptions.test.ts | 40 ++-- .../test/config/withSentryConfig.test.ts | 183 ++++++++++++++++++ 6 files changed, 414 insertions(+), 29 deletions(-) diff --git a/packages/nextjs/src/config/getBuildPluginOptions.ts b/packages/nextjs/src/config/getBuildPluginOptions.ts index e36e88802fa5..e43061eb59a5 100644 --- a/packages/nextjs/src/config/getBuildPluginOptions.ts +++ b/packages/nextjs/src/config/getBuildPluginOptions.ts @@ -205,7 +205,7 @@ function createReleaseConfig( vcsRemote: sentryBuildOptions.release?.vcsRemote, setCommits: sentryBuildOptions.release?.setCommits, deploy: sentryBuildOptions.release?.deploy, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.release, + ...sentryBuildOptions.webpack?.unstable_sentryWebpackPluginOptions?.release, }; } @@ -272,8 +272,8 @@ export function getBuildPluginOptions({ reactComponentAnnotation: buildTool.startsWith('after-production-compile') ? undefined : { - ...sentryBuildOptions.reactComponentAnnotation, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.reactComponentAnnotation, + ...sentryBuildOptions.webpack?.reactComponentAnnotation, + ...sentryBuildOptions.webpack?.unstable_sentryWebpackPluginOptions?.reactComponentAnnotation, }, silent: sentryBuildOptions.silent, url: sentryBuildOptions.sentryUrl, @@ -283,7 +283,7 @@ export function getBuildPluginOptions({ assets: sentryBuildOptions.sourcemaps?.assets ?? sourcemapUploadAssets, ignore: sentryBuildOptions.sourcemaps?.ignore ?? sourcemapUploadIgnore, filesToDeleteAfterUpload, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.sourcemaps, + ...sentryBuildOptions.webpack?.unstable_sentryWebpackPluginOptions?.sourcemaps, }, release: createReleaseConfig(releaseName, sentryBuildOptions), bundleSizeOptimizations: { @@ -295,6 +295,6 @@ export function getBuildPluginOptions({ metaFramework: 'nextjs', }, }, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions, + ...sentryBuildOptions.webpack?.unstable_sentryWebpackPluginOptions, }; } diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 28e038b6d0f2..c7472c08fc20 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -57,6 +57,91 @@ export type NextConfigObject = { }; }; +export type SentryBuildWebpackOptions = { + /** + * Automatically instrument Next.js data fetching methods and Next.js API routes with error and performance monitoring. + * Defaults to `true`. + */ + autoInstrumentServerFunctions?: boolean; + + /** + * Automatically instrument Next.js middleware with error and performance monitoring. Defaults to `true`. + */ + autoInstrumentMiddleware?: boolean; + + /** + * Automatically instrument components in the `app` directory with error monitoring. Defaults to `true`. + */ + autoInstrumentAppDirectory?: boolean; + + /** + * Automatically create cron monitors in Sentry for your Vercel Cron Jobs if configured via `vercel.json`. + * + * Defaults to `false`. + */ + automaticVercelMonitors?: boolean; + + /** + * Exclude certain serverside API routes or pages from being instrumented with Sentry during build-time. This option + * takes an array of strings or regular expressions. This options also affects pages in the `app` directory. + * + * NOTE: Pages should be specified as routes (`/animals` or `/api/animals/[animalType]/habitat`), not filepaths + * (`pages/animals/index.js` or `.\src\pages\api\animals\[animalType]\habitat.tsx`), and strings must be be a full, + * exact match. + * + * Notice: If you build Next.js with turbopack, the Sentry SDK will no longer apply build-time instrumentation and + * purely rely on Next.js telemetry features, meaning that this option will effectively no-op. + */ + excludeServerRoutes?: Array; + + /** + * Disables automatic injection of Sentry's Webpack configuration. + * + * By default, the Sentry Next.js SDK injects its own Webpack configuration to enable features such as + * source map upload and automatic instrumentation. Set this option to `true` if you want to prevent + * the SDK from modifying your Webpack config (for example, if you want to handle Sentry integration manually + * or if you are on an older version of Next.js while using Turbopack). + */ + disableSentryConfig?: boolean; + + /** + * Tree-shaking options to help reduce the size of the Sentry SDK bundle. + */ + treeshake?: { + /** + * Removes Sentry SDK logger statements from the bundle. Note that this doesn't affect Sentry Logs. + */ + removeDebugLogging?: boolean; + }; + + /** + * Options to be passed directly to the Sentry Webpack Plugin (`@sentry/webpack-plugin`) that ships with the Sentry SDK. + * You can use this option to override any options the SDK passes to the Webpack plugin. + * + * Please note that this option is unstable and may change in a breaking way in any release. + */ + unstable_sentryWebpackPluginOptions?: SentryWebpackPluginOptions; + + /** + * Options related to react component name annotations. + * Disabled by default, unless a value is set for this option. + * When enabled, your app's DOM will automatically be annotated during build-time with their respective component names. + * This will unlock the capability to search for Replays in Sentry by component name, as well as see component names in breadcrumbs and performance monitoring. + * Please note that this feature is not currently supported by the esbuild bundler plugins, and will only annotate React components + */ + reactComponentAnnotation?: { + /** + * Whether the component name annotate plugin should be enabled or not. + */ + enabled?: boolean; + + /** + * A list of strings representing the names of components to ignore. The plugin will not apply `data-sentry` annotations on the DOM element for these components. + */ + ignoredComponents?: string[]; + }; +}; + export type SentryBuildOptions = { /** * The slug of the Sentry organization associated with the app. @@ -363,6 +448,8 @@ export type SentryBuildOptions = { * When enabled, your app's DOM will automatically be annotated during build-time with their respective component names. * This will unlock the capability to search for Replays in Sentry by component name, as well as see component names in breadcrumbs and performance monitoring. * Please note that this feature is not currently supported by the esbuild bundler plugins, and will only annotate React components + * + * @deprecated Use `webpack.reactComponentAnnotation` instead. */ reactComponentAnnotation?: { /** @@ -381,6 +468,7 @@ export type SentryBuildOptions = { * You can use this option to override any options the SDK passes to the webpack plugin. * * Please note that this option is unstable and may change in a breaking way in any release. + * @deprecated Use `webpack.unstable_sentryWebpackPluginOptions` instead. */ unstable_sentryWebpackPluginOptions?: SentryWebpackPluginOptions; @@ -391,6 +479,8 @@ export type SentryBuildOptions = { * Disabling this option will leave you without readable stacktraces for dependencies and Next.js-internal code. * * Defaults to `false`. + * + * This option applies to both webpack and turbopack builds. */ // Enabling this option may upload a lot of source maps and since the sourcemap upload endpoint in Sentry is super // slow we don't enable it by default so that we don't opaquely increase build times for users. @@ -400,16 +490,19 @@ export type SentryBuildOptions = { /** * Automatically instrument Next.js data fetching methods and Next.js API routes with error and performance monitoring. * Defaults to `true`. + * @deprecated Use `webpack.autoInstrumentServerFunctions` instead. */ autoInstrumentServerFunctions?: boolean; /** * Automatically instrument Next.js middleware with error and performance monitoring. Defaults to `true`. + * @deprecated Use `webpack.autoInstrumentMiddleware` instead. */ autoInstrumentMiddleware?: boolean; /** * Automatically instrument components in the `app` directory with error monitoring. Defaults to `true`. + * @deprecated Use `webpack.autoInstrumentAppDirectory` instead. */ autoInstrumentAppDirectory?: boolean; @@ -423,6 +516,8 @@ export type SentryBuildOptions = { * * Notice: If you build Next.js with turbopack, the Sentry SDK will no longer apply build-time instrumentation and * purely rely on Next.js telemetry features, meaning that this option will effectively no-op. + * + * @deprecated Use `webpack.excludeServerRoutes` instead. */ excludeServerRoutes?: Array; @@ -439,6 +534,8 @@ export type SentryBuildOptions = { /** * Tree shakes Sentry SDK logger statements from the bundle. + * + * @deprecated Use `webpack.treeshake.removeDebugLogging` instead. */ disableLogger?: boolean; @@ -446,6 +543,8 @@ export type SentryBuildOptions = { * Automatically create cron monitors in Sentry for your Vercel Cron Jobs if configured via `vercel.json`. * * Defaults to `false`. + * + * @deprecated Use `webpack.automaticVercelMonitors` instead. */ automaticVercelMonitors?: boolean; @@ -497,6 +596,8 @@ export type SentryBuildOptions = { * the SDK from modifying your Webpack config (for example, if you want to handle Sentry integration manually * or if you are on an older version of Next.js while using Turbopack). * + * @deprecated Use `webpack.disableSentryConfig` instead. + * * @default false */ disableSentryWebpackConfig?: boolean; @@ -519,6 +620,11 @@ export type SentryBuildOptions = { _experimental?: Partial<{ thirdPartyOriginStackFrames?: boolean; }>; + + /** + * Options related to webpack builds, has no effect if you are using Turbopack. + */ + webpack?: SentryBuildWebpackOptions; }; export type NextConfigFunction = ( diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 3b4eae22898f..3630e1005c87 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -145,7 +145,7 @@ export function constructWebpackConfigFunction({ appDir: appDirPath, pagesDir: pagesDirPath, pageExtensionRegex, - excludeServerRoutes: userSentryOptions.excludeServerRoutes, + excludeServerRoutes: userSentryOptions.webpack?.excludeServerRoutes, nextjsRequestAsyncStorageModulePath: getRequestAsyncStorageModuleLocation( projectDir, rawNewConfig.resolve?.modules, @@ -220,7 +220,7 @@ export function constructWebpackConfigFunction({ ); }; - if (isServer && userSentryOptions.autoInstrumentServerFunctions !== false) { + if (isServer && userSentryOptions.webpack?.autoInstrumentServerFunctions !== false) { // It is very important that we insert our loaders at the beginning of the array because we expect any sort of transformations/transpilations (e.g. TS -> JS) to already have happened. // Wrap pages @@ -239,7 +239,7 @@ export function constructWebpackConfigFunction({ let vercelCronsConfig: VercelCronsConfig = undefined; try { - if (process.env.VERCEL && userSentryOptions.automaticVercelMonitors) { + if (process.env.VERCEL && userSentryOptions.webpack?.automaticVercelMonitors) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access vercelCronsConfig = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'vercel.json'), 'utf8')).crons; if (vercelCronsConfig) { @@ -277,7 +277,7 @@ export function constructWebpackConfigFunction({ // Wrap middleware const canWrapStandaloneMiddleware = userNextConfig.output !== 'standalone' || !major || major < 16; - if ((userSentryOptions.autoInstrumentMiddleware ?? true) && canWrapStandaloneMiddleware) { + if ((userSentryOptions.webpack?.autoInstrumentMiddleware ?? true) && canWrapStandaloneMiddleware) { newConfig.module.rules.unshift({ test: isMiddlewareResource, use: [ @@ -293,7 +293,7 @@ export function constructWebpackConfigFunction({ } } - if (isServer && userSentryOptions.autoInstrumentAppDirectory !== false) { + if (isServer && userSentryOptions.webpack?.autoInstrumentAppDirectory !== false) { // Wrap server components newConfig.module.rules.unshift({ test: isServerComponentResource, @@ -428,7 +428,7 @@ export function constructWebpackConfigFunction({ } } - if (userSentryOptions.disableLogger) { + if (userSentryOptions.webpack?.treeshake?.removeDebugLogging) { newConfig.plugins = newConfig.plugins || []; newConfig.plugins.push( new buildContext.webpack.DefinePlugin({ diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 892f4d6745fa..835ef6dc68a4 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -98,12 +98,102 @@ function generateRandomTunnelRoute(): string { return `/${randomString}`; } +/** + * Migrates deprecated top-level webpack options to the new `webpack.*` path for backward compatibility. + * The new path takes precedence over deprecated options. This mutates the userSentryOptions object. + */ +function migrateDeprecatedWebpackOptions(userSentryOptions: SentryBuildOptions): void { + // Initialize webpack options if not present + userSentryOptions.webpack = userSentryOptions.webpack || {}; + + const webpack = userSentryOptions.webpack; + + const withDeprecatedFallback = ( + newValue: T | undefined, + deprecatedValue: T | undefined, + message: string, + ): T | undefined => { + if (deprecatedValue !== undefined) { + // eslint-disable-next-line no-console + console.warn(message); + } + + return newValue ?? deprecatedValue; + }; + + const deprecatedMessage = (deprecatedPath: string, newPath: string): string => + `[@sentry/nextjs] DEPRECATION WARNING: ${deprecatedPath} is deprecated and will be removed in a future version. Use ${newPath} instead.`; + + /* eslint-disable deprecation/deprecation */ + // Migrate each deprecated option to the new path, but only if the new path isn't already set + webpack.autoInstrumentServerFunctions = withDeprecatedFallback( + webpack.autoInstrumentServerFunctions, + userSentryOptions.autoInstrumentServerFunctions, + deprecatedMessage('autoInstrumentServerFunctions', 'webpack.autoInstrumentServerFunctions'), + ); + + webpack.autoInstrumentMiddleware = withDeprecatedFallback( + webpack.autoInstrumentMiddleware, + userSentryOptions.autoInstrumentMiddleware, + deprecatedMessage('autoInstrumentMiddleware', 'webpack.autoInstrumentMiddleware'), + ); + + webpack.autoInstrumentAppDirectory = withDeprecatedFallback( + webpack.autoInstrumentAppDirectory, + userSentryOptions.autoInstrumentAppDirectory, + deprecatedMessage('autoInstrumentAppDirectory', 'webpack.autoInstrumentAppDirectory'), + ); + + webpack.excludeServerRoutes = withDeprecatedFallback( + webpack.excludeServerRoutes, + userSentryOptions.excludeServerRoutes, + deprecatedMessage('excludeServerRoutes', 'webpack.excludeServerRoutes'), + ); + + webpack.unstable_sentryWebpackPluginOptions = withDeprecatedFallback( + webpack.unstable_sentryWebpackPluginOptions, + userSentryOptions.unstable_sentryWebpackPluginOptions, + deprecatedMessage('unstable_sentryWebpackPluginOptions', 'webpack.unstable_sentryWebpackPluginOptions'), + ); + + webpack.disableSentryConfig = withDeprecatedFallback( + webpack.disableSentryConfig, + userSentryOptions.disableSentryWebpackConfig, + deprecatedMessage('disableSentryWebpackConfig', 'webpack.disableSentryConfig'), + ); + + // Handle treeshake.removeDebugLogging specially since it's nested + if (userSentryOptions.disableLogger !== undefined) { + webpack.treeshake = webpack.treeshake || {}; + webpack.treeshake.removeDebugLogging = withDeprecatedFallback( + webpack.treeshake.removeDebugLogging, + userSentryOptions.disableLogger, + deprecatedMessage('disableLogger', 'webpack.treeshake.removeDebugLogging'), + ); + } + + webpack.automaticVercelMonitors = withDeprecatedFallback( + webpack.automaticVercelMonitors, + userSentryOptions.automaticVercelMonitors, + deprecatedMessage('automaticVercelMonitors', 'webpack.automaticVercelMonitors'), + ); + + webpack.reactComponentAnnotation = withDeprecatedFallback( + webpack.reactComponentAnnotation, + userSentryOptions.reactComponentAnnotation, + deprecatedMessage('reactComponentAnnotation', 'webpack.reactComponentAnnotation'), + ); +} + // Modify the materialized object form of the user's next config by deleting the `sentry` property and wrapping the // `webpack` property function getFinalConfigObject( incomingUserNextConfigObject: NextConfigObject, userSentryOptions: SentryBuildOptions, ): NextConfigObject { + // Migrate deprecated webpack options to new webpack path for backward compatibility + migrateDeprecatedWebpackOptions(userSentryOptions); + // Only determine a release name if release creation is not explicitly disabled // This prevents injection of Git commit hashes that break build determinism const shouldCreateRelease = userSentryOptions.release?.create !== false; @@ -363,7 +453,7 @@ function getFinalConfigObject( ], }, }), - ...(isWebpack && !userSentryOptions.disableSentryWebpackConfig + ...(isWebpack && !userSentryOptions.webpack?.disableSentryConfig ? { webpack: constructWebpackConfigFunction({ userNextConfig: incomingUserNextConfigObject, diff --git a/packages/nextjs/test/config/getBuildPluginOptions.test.ts b/packages/nextjs/test/config/getBuildPluginOptions.test.ts index 609183d198bb..3e95eadafc96 100644 --- a/packages/nextjs/test/config/getBuildPluginOptions.test.ts +++ b/packages/nextjs/test/config/getBuildPluginOptions.test.ts @@ -573,13 +573,15 @@ describe('getBuildPluginOptions', () => { create: true, vcsRemote: 'origin', }, - unstable_sentryWebpackPluginOptions: { - release: { - setCommits: { - auto: true, - }, - deploy: { - env: 'production', + webpack: { + unstable_sentryWebpackPluginOptions: { + release: { + setCommits: { + auto: true, + }, + deploy: { + env: 'production', + }, }, }, }, @@ -592,7 +594,7 @@ describe('getBuildPluginOptions', () => { buildTool: 'webpack-client', }); - // The unstable_sentryWebpackPluginOptions.release is spread at the end and may override base properties + // The webpack.unstable_sentryWebpackPluginOptions.release is spread at the end and may override base properties expect(result.release).toHaveProperty('setCommits.auto', true); expect(result.release).toHaveProperty('deploy.env', 'production'); }); @@ -603,12 +605,14 @@ describe('getBuildPluginOptions', () => { const sentryBuildOptions: SentryBuildOptions = { org: 'test-org', project: 'test-project', - reactComponentAnnotation: { - enabled: true, - }, - unstable_sentryWebpackPluginOptions: { + webpack: { reactComponentAnnotation: { - enabled: false, // This will override the base setting + enabled: true, + }, + unstable_sentryWebpackPluginOptions: { + reactComponentAnnotation: { + enabled: false, // This will override the base setting + }, }, }, }; @@ -695,10 +699,12 @@ describe('getBuildPluginOptions', () => { const sentryBuildOptions: SentryBuildOptions = { org: 'test-org', project: 'test-project', - unstable_sentryWebpackPluginOptions: { - applicationKey: 'test-app-key', - sourcemaps: { - disable: false, + webpack: { + unstable_sentryWebpackPluginOptions: { + applicationKey: 'test-app-key', + sourcemaps: { + disable: false, + }, }, }, }; diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index b67a05845a7e..ed4b96a78125 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -267,6 +267,189 @@ describe('withSentryConfig', () => { expect(finalConfig.turbopack).toBeUndefined(); }); + + describe('webpack configuration options path', () => { + afterEach(() => { + delete process.env.TURBOPACK; + vi.restoreAllMocks(); + }); + + it('uses new webpack.disableSentryConfig option', () => { + delete process.env.TURBOPACK; + + const originalWebpackFunction = vi.fn(); + const configWithWebpack = { + ...exportedNextConfig, + webpack: originalWebpackFunction, + }; + + const sentryOptions = { + webpack: { + disableSentryConfig: true, + }, + }; + + const finalConfig = materializeFinalNextConfig(configWithWebpack, undefined, sentryOptions); + expect(finalConfig.webpack).toBe(originalWebpackFunction); + }); + + it('new webpack path takes precedence over deprecated top-level options', () => { + delete process.env.TURBOPACK; + + const originalWebpackFunction = vi.fn(); + const configWithWebpack = { + ...exportedNextConfig, + webpack: originalWebpackFunction, + }; + + // Both old and new paths set, new should win + const sentryOptions = { + disableSentryWebpackConfig: false, // deprecated - says enable + webpack: { + disableSentryConfig: true, // new - says disable + }, + }; + + const finalConfig = materializeFinalNextConfig(configWithWebpack, undefined, sentryOptions); + // Should preserve original webpack because new path disables it + expect(finalConfig.webpack).toBe(originalWebpackFunction); + }); + + it('falls back to deprecated option when new path is not set', () => { + delete process.env.TURBOPACK; + + const originalWebpackFunction = vi.fn(); + const configWithWebpack = { + ...exportedNextConfig, + webpack: originalWebpackFunction, + }; + + // Only deprecated path set + const sentryOptions = { + disableSentryWebpackConfig: true, + }; + + const finalConfig = materializeFinalNextConfig(configWithWebpack, undefined, sentryOptions); + // Should preserve original webpack because deprecated option disables it + expect(finalConfig.webpack).toBe(originalWebpackFunction); + }); + + it('merges webpack.treeshake.removeDebugLogging with deprecated disableLogger', () => { + delete process.env.TURBOPACK; + + // New webpack.treeshake.removeDebugLogging should map to disableLogger internally + const sentryOptionsNew = { + webpack: { + treeshake: { + removeDebugLogging: true, + }, + }, + }; + + const sentryOptionsOld = { + disableLogger: true, + }; + + // Both should work the same way internally (though we can't easily test the actual effect here) + const finalConfigNew = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptionsNew); + const finalConfigOld = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptionsOld); + + // Both should have webpack functions (not disabled) + expect(finalConfigNew.webpack).toBeInstanceOf(Function); + expect(finalConfigOld.webpack).toBeInstanceOf(Function); + }); + }); + + describe('deprecation warnings', () => { + let consoleWarnSpy: ReturnType; + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + delete process.env.TURBOPACK; + vi.restoreAllMocks(); + }); + + it('warns when using deprecated top-level options', () => { + delete process.env.TURBOPACK; + + const sentryOptions = { + disableLogger: true, + }; + + materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('[@sentry/nextjs] DEPRECATION WARNING: disableLogger is deprecated'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Use webpack.treeshake.removeDebugLogging instead'), + ); + }); + + it('does not warn when using new webpack path', () => { + delete process.env.TURBOPACK; + + const sentryOptions = { + webpack: { + treeshake: { + removeDebugLogging: true, + }, + }, + }; + + materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('warns even when new path is also set', () => { + delete process.env.TURBOPACK; + + const sentryOptions = { + disableLogger: true, // deprecated + webpack: { + treeshake: { + removeDebugLogging: false, // new path takes precedence + }, + }, + }; + + materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + // Should warn because deprecated value is present + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('[@sentry/nextjs] DEPRECATION WARNING: disableLogger is deprecated'), + ); + }); + + it('warns for multiple deprecated options at once', () => { + delete process.env.TURBOPACK; + + const sentryOptions = { + disableLogger: true, + automaticVercelMonitors: false, + excludeServerRoutes: ['/api/test'], + }; + + materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + // Should warn for all three deprecated options + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('[@sentry/nextjs] DEPRECATION WARNING: disableLogger is deprecated'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('[@sentry/nextjs] DEPRECATION WARNING: automaticVercelMonitors is deprecated'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('[@sentry/nextjs] DEPRECATION WARNING: excludeServerRoutes is deprecated'), + ); + expect(consoleWarnSpy).toHaveBeenCalledTimes(3); + }); + }); }); describe('bundler detection', () => { From a6eec86be57f17d5a6345814a38ce913ee81828a Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 9 Dec 2025 10:18:21 +0000 Subject: [PATCH 06/14] fix(react): Add transaction name guards for rapid lazy-route navigations (#18346) Following up on #18098 and #18155 Fixes a race condition where rapid navigations between lazy routes cause transaction names to be incorrectly assigned or corrupted. This occurs when async lazy handlers resolve after the user already navigated to a different route. The root cause was `captureCurrentLocation()` was reading `window.location` at the time of handler invocation, but during `patchRoutesOnNavigation`, the browser URL hasn't actually updated yet. So when handlers invoked during navigation A resolve later, they would capture navigation B's location (or whatever the current URL is), leading to incorrect span updates. The fix introduces a navigation context mechanism that captures both the correct `targetPath` and the active span at the start of `patchRoutesOnNavigation`, making them available to async handler proxies during invocation. This ensures handlers always use the navigation context they were invoked with, not the current browser state. The navigation context uses a stack to properly handle overlapping/concurrent `patchRoutesOnNavigation` calls. If a second navigation starts before the first one's handlers complete, each navigation maintains its own captured context. --- .../react-router-7-lazy-routes/src/index.tsx | 8 + .../src/pages/DelayedLazyRoute.tsx | 8 + .../src/pages/Index.tsx | 4 + .../src/pages/SlowFetchLazyRoutes.tsx | 42 +++ .../tests/transactions.test.ts | 347 +++++++++++++++++- .../src/reactrouter-compat-utils/index.ts | 5 + .../instrumentation.tsx | 72 ++-- .../reactrouter-compat-utils/lazy-routes.tsx | 93 ++++- .../src/reactrouter-compat-utils/utils.ts | 66 +++- .../instrumentation.test.tsx | 1 + .../lazy-routes.test.ts | 86 +++-- .../reactrouter-compat-utils/utils.test.ts | 138 ++++++- 12 files changed, 804 insertions(+), 66 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/SlowFetchLazyRoutes.tsx diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx index 7787b60be398..1bcad5eaf4ce 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx @@ -126,6 +126,14 @@ const router = sentryCreateBrowserRouter( lazyChildren: () => import('./pages/deep/Level1Routes').then(module => module.level2Routes), }, }, + { + path: '/slow-fetch', + handle: { + // This lazy handler takes 500ms due to the top-level await in SlowFetchLazyRoutes.tsx + // It also makes a fetch request during loading which creates a span + lazyChildren: () => import('./pages/SlowFetchLazyRoutes').then(module => module.slowFetchRoutes), + }, + }, ], { async patchRoutesOnNavigation({ matches, patch }: Parameters[0]) { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx index 41e5ba5463be..53bfe048ca4e 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx @@ -23,6 +23,14 @@ const DelayedLazyRoute = () => { Back Home
+ + Go to Slow Fetch Route (500ms) + +
+ + Go to Another Lazy Route + +
View: Detailed (query param) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx index 21b965f571f3..cf80af402b96 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx @@ -31,6 +31,10 @@ const Index = () => { Navigate to Deep Nested Route (3 levels, 900ms total) +
+ + Navigate to Slow Fetch Route (500ms delay with fetch) + ); }; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/SlowFetchLazyRoutes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/SlowFetchLazyRoutes.tsx new file mode 100644 index 000000000000..f24a8c56f416 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/SlowFetchLazyRoutes.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Link, useParams } from 'react-router-dom'; + +// Simulate a slow async fetch during lazy route loading +// This delay happens before the module exports, simulating network latency +const fetchPromise = fetch('/api/slow-data') + .then(res => res.json()) + .catch(() => ({ message: 'fallback data' })); + +// Add a 500ms delay to simulate slow lazy loading +await new Promise(resolve => setTimeout(resolve, 500)); + +// Component that displays the lazy-loaded data +const SlowFetchComponent = () => { + const { id } = useParams<{ id: string }>(); + const [data, setData] = React.useState<{ message: string } | null>(null); + + React.useEffect(() => { + fetchPromise.then(setData); + }, []); + + return ( +
+

Slow Fetch Route

+

ID: {id}

+

Data: {data?.message || 'loading...'}

+ + Go Home + + + Go to Another Lazy Route + +
+ ); +}; + +export const slowFetchRoutes = [ + { + path: ':id', + element: , + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts index ce8137d7f686..f7a3ec4a5519 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts @@ -666,7 +666,7 @@ test('Creates navigation transaction when navigating with query parameters from await expect(page.locator('id=delayed-lazy-source')).toHaveText('Source: homepage'); // Verify the navigation transaction has the correct parameterized route name - // Query parameters should NOT affect the transaction name (still /delayed-lazy/:id) + // Query parameters don't affect the transaction name (still /delayed-lazy/:id) expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); @@ -883,3 +883,348 @@ test('Creates navigation transaction when changing both query and hash on same r expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); expect(navigationEvent.contexts?.trace?.status).toBe('ok'); }); + +test('Creates navigation transaction with correct name for slow lazy route', async ({ page }) => { + // This test verifies that navigating to a slow lazy route (with top-level await) + // creates a correctly named navigation transaction. + // The route uses handle.lazyChildren with a 500ms delay. + + await page.goto('/'); + + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/slow-fetch/:id' + ); + }); + + // Navigate to slow-fetch route (500ms delay) + const navigationToSlowFetch = page.locator('id=navigation-to-slow-fetch'); + await expect(navigationToSlowFetch).toBeVisible(); + await navigationToSlowFetch.click(); + + const navigationEvent = await navigationPromise; + + // Wait for the component to render (after the 500ms delay) + const slowFetchContent = page.locator('id=slow-fetch-content'); + await expect(slowFetchContent).toBeVisible({ timeout: 5000 }); + await expect(page.locator('id=slow-fetch-id')).toHaveText('ID: 123'); + + // Verify the transaction has the correct parameterized route name + expect(navigationEvent.transaction).toBe('/slow-fetch/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); +}); + +test('Rapid navigation does not corrupt transaction names when lazy handlers resolve late', async ({ page }) => { + await page.goto('/'); + + const allTransactions: Array<{ name: string; op: string }> = []; + + const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op) { + allTransactions.push({ + name: transactionEvent.transaction, + op: transactionEvent.contexts.trace.op, + }); + } + return allTransactions.length >= 2; + }); + + // Navigate to slow-fetch route (500ms delay) + const slowFetchLink = page.locator('id=navigation-to-slow-fetch'); + await expect(slowFetchLink).toBeVisible(); + await slowFetchLink.click(); + + // Navigate away before lazy handler resolves + await page.waitForTimeout(200); + const anotherLink = page.locator('id=navigation-to-another'); + await anotherLink.click(); + + await expect(page.locator('id=another-lazy-route')).toBeVisible({ timeout: 10000 }); + await page.waitForTimeout(3000); + + await Promise.race([ + collectorPromise, + new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 15000)), + ]); + + const navigationTransactions = allTransactions.filter(t => t.op === 'navigation'); + + expect(navigationTransactions.length).toBeGreaterThanOrEqual(1); + + // No "/" corruption + const corruptedToRoot = navigationTransactions.filter(t => t.name === '/'); + expect(corruptedToRoot.length).toBe(0); + + // At least one valid route name + const validRoutePatterns = [ + '/slow-fetch/:id', + '/another-lazy/sub', + '/another-lazy/sub/:id', + '/another-lazy/sub/:id/:subId', + ]; + const hasValidRouteName = navigationTransactions.some(t => validRoutePatterns.includes(t.name)); + expect(hasValidRouteName).toBe(true); +}); + +test('Correctly names pageload transaction for slow lazy route with fetch', async ({ page }) => { + // This test verifies that a slow lazy route (with top-level await and fetch) + // creates a correctly named pageload transaction + + const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction === '/slow-fetch/:id' + ); + }); + + await page.goto('/slow-fetch/123'); + + const pageloadEvent = await pageloadPromise; + + // Wait for the component to render (after the 500ms delay) + const slowFetchContent = page.locator('id=slow-fetch-content'); + await expect(slowFetchContent).toBeVisible({ timeout: 5000 }); + await expect(page.locator('id=slow-fetch-id')).toHaveText('ID: 123'); + + // Verify the transaction has the correct parameterized route name + expect(pageloadEvent.transaction).toBe('/slow-fetch/:id'); + expect(pageloadEvent.contexts?.trace?.op).toBe('pageload'); + expect(pageloadEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + + // Verify the transaction contains a fetch span + const spans = pageloadEvent.spans || []; + const fetchSpan = spans.find( + (span: { op?: string; description?: string }) => + span.op === 'http.client' && span.description?.includes('/api/slow-data'), + ); + + // The fetch span should exist (even if the fetch failed, the span is created) + expect(fetchSpan).toBeDefined(); +}); + +test('Three-route rapid navigation preserves distinct transaction names', async ({ page }) => { + const navigationTransactions: Array<{ name: string }> = []; + + const navigationCollector = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + if (transactionEvent.contexts?.trace?.op === 'navigation') { + navigationTransactions.push({ name: transactionEvent.transaction || '' }); + } + return false; + }); + + const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + // Pageload to delayed-lazy route + await page.goto('/delayed-lazy/111'); + await pageloadPromise; + await expect(page.locator('id=delayed-lazy-ready')).toBeVisible({ timeout: 5000 }); + + // Navigate to slow-fetch (500ms delay) + const slowFetchLink = page.locator('id=delayed-lazy-to-slow-fetch'); + await slowFetchLink.click(); + await page.waitForTimeout(150); + + // Navigate to another-lazy before slow-fetch resolves + const anotherLazyLink = page.locator('id=delayed-lazy-to-another-lazy'); + await anotherLazyLink.click(); + + await expect(page.locator('id=another-lazy-route')).toBeVisible({ timeout: 10000 }); + await page.waitForTimeout(2000); + + await Promise.race([ + navigationCollector, + new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 5000)), + ]).catch(() => {}); + + expect(navigationTransactions.length).toBe(2); + + // Distinct names (corruption causes both to have same name) + const uniqueNames = new Set(navigationTransactions.map(t => t.name)); + expect(uniqueNames.size).toBe(2); + + // No "/" corruption + const corruptedToRoot = navigationTransactions.filter(t => t.name === '/'); + expect(corruptedToRoot.length).toBe(0); +}); + +test('Zero-wait rapid navigation does not corrupt transaction names', async ({ page }) => { + const navigationTransactions: Array<{ name: string }> = []; + + const collector = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + if (transactionEvent.contexts?.trace?.op === 'navigation') { + navigationTransactions.push({ name: transactionEvent.transaction || '' }); + } + return false; + }); + + await page.goto('/'); + + const slowFetchLink = page.locator('id=navigation-to-slow-fetch'); + const anotherLink = page.locator('id=navigation-to-another'); + await expect(slowFetchLink).toBeVisible(); + await expect(anotherLink).toBeVisible(); + + // Click first then immediately second (no wait) + await slowFetchLink.click(); + await anotherLink.click(); + + await expect(page.locator('id=another-lazy-route')).toBeVisible({ timeout: 10000 }); + await page.waitForTimeout(3000); + + await Promise.race([collector, new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 5000))]).catch( + () => {}, + ); + + expect(navigationTransactions.length).toBeGreaterThanOrEqual(1); + + // No "/" corruption + const corruptedToRoot = navigationTransactions.filter(t => t.name === '/'); + expect(corruptedToRoot.length).toBe(0); +}); + +test('Browser back during lazy handler resolution does not corrupt', async ({ page }) => { + const allTransactions: Array<{ name: string; op: string }> = []; + + const collector = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op) { + allTransactions.push({ + name: transactionEvent.transaction, + op: transactionEvent.contexts.trace.op, + }); + } + return false; + }); + + await page.goto('/'); + await expect(page.locator('id=navigation')).toBeVisible(); + + // Navigate to another-lazy to establish history + const anotherLink = page.locator('id=navigation-to-another'); + await anotherLink.click(); + await expect(page.locator('id=another-lazy-route')).toBeVisible({ timeout: 10000 }); + + // Navigate to slow-fetch route + await page.goto('/slow-fetch/123'); + await page.waitForTimeout(150); + + // Press browser back before handler resolves + await page.goBack(); + await page.waitForTimeout(3000); + + await Promise.race([collector, new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 10000))]).catch( + () => {}, + ); + + expect(allTransactions.length).toBeGreaterThanOrEqual(1); + expect(allTransactions.every(t => t.name.length > 0)).toBe(true); +}); + +test('Multiple overlapping lazy handlers do not corrupt each other', async ({ page }) => { + const navigationTransactions: Array<{ name: string }> = []; + + const collector = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + if (transactionEvent.contexts?.trace?.op === 'navigation') { + navigationTransactions.push({ name: transactionEvent.transaction || '' }); + } + return false; + }); + + await page.goto('/'); + + // Navigation 1: To delayed-lazy (400ms delay) + const delayedLazyLink = page.locator('id=navigation-to-delayed-lazy'); + await expect(delayedLazyLink).toBeVisible(); + await delayedLazyLink.click(); + await page.waitForTimeout(50); + + // Navigation 2: To slow-fetch (500ms delay) + const slowFetchLink = page.locator('id=navigation-to-slow-fetch'); + await slowFetchLink.click(); + await page.waitForTimeout(50); + + // Navigation 3: To another-lazy (fast) + const anotherLink = page.locator('id=navigation-to-another'); + await anotherLink.click(); + + await expect(page.locator('id=another-lazy-route')).toBeVisible({ timeout: 10000 }); + await page.waitForTimeout(3000); + + await Promise.race([collector, new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 5000))]).catch( + () => {}, + ); + + expect(navigationTransactions.length).toBeGreaterThanOrEqual(1); + + // No "/" corruption + const corruptedToRoot = navigationTransactions.filter(t => t.name === '/'); + expect(corruptedToRoot.length).toBe(0); + + // If multiple navigations, they should have distinct names + if (navigationTransactions.length >= 2) { + const allSameName = navigationTransactions.every(t => t.name === navigationTransactions[0].name); + expect(allSameName).toBe(false); + } +}); + +test('Query/hash navigation does not corrupt transaction name', async ({ page }) => { + const navigationTransactions: Array<{ name: string }> = []; + + const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation') { + navigationTransactions.push({ name: transactionEvent.transaction }); + } + return navigationTransactions.length >= 1; + }); + + await page.goto('/'); + + // Navigate to delayed-lazy route + const delayedLazyLink = page.locator('id=navigation-to-delayed-lazy'); + await expect(delayedLazyLink).toBeVisible(); + await delayedLazyLink.click(); + await expect(page.locator('id=delayed-lazy-ready')).toBeVisible({ timeout: 10000 }); + + // Trigger query-only navigation + const queryLink = page.locator('id=link-to-query-view-detailed'); + await expect(queryLink).toBeVisible(); + await queryLink.click(); + await page.waitForURL('**/delayed-lazy/**?view=detailed'); + + // Trigger hash-only navigation + const hashLink = page.locator('id=link-to-hash-section1'); + await expect(hashLink).toBeVisible(); + await hashLink.click(); + await page.waitForTimeout(500); + expect(page.url()).toContain('#section1'); + + // Trigger combined query+hash navigation + const combinedLink = page.locator('id=link-to-query-and-hash'); + await expect(combinedLink).toBeVisible(); + await combinedLink.click(); + await page.waitForTimeout(500); + expect(page.url()).toContain('view=grid'); + expect(page.url()).toContain('#results'); + + await page.waitForTimeout(2000); + await Promise.race([ + collectorPromise, + new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 5000)), + ]).catch(() => {}); + + expect(navigationTransactions.length).toBeGreaterThanOrEqual(1); + expect(navigationTransactions[0].name).toBe('/delayed-lazy/:id'); + + // No "/" corruption from query/hash navigations + const corruptedToRoot = navigationTransactions.filter(t => t.name === '/'); + expect(corruptedToRoot.length).toBe(0); +}); diff --git a/packages/react/src/reactrouter-compat-utils/index.ts b/packages/react/src/reactrouter-compat-utils/index.ts index bb91ba8d3072..968abd9ecae6 100644 --- a/packages/react/src/reactrouter-compat-utils/index.ts +++ b/packages/react/src/reactrouter-compat-utils/index.ts @@ -26,6 +26,11 @@ export { pathIsWildcardAndHasChildren, getNumberOfUrlSegments, transactionNameHasWildcard, + getActiveRootSpan, + // Navigation context functions (internal use and testing) + setNavigationContext, + clearNavigationContext, + getNavigationContext, } from './utils'; // Lazy route exports diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 6e19b9021ba5..d646624618f9 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -12,10 +12,8 @@ import type { Client, Integration, Span } from '@sentry/core'; import { addNonEnumerableProperty, debug, - getActiveSpan, getClient, getCurrentScope, - getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -41,7 +39,14 @@ import type { UseRoutes, } from '../types'; import { checkRouteForAsyncHandler } from './lazy-routes'; -import { initializeRouterUtils, resolveRouteNameAndSource, transactionNameHasWildcard } from './utils'; +import { + clearNavigationContext, + getActiveRootSpan, + initializeRouterUtils, + resolveRouteNameAndSource, + setNavigationContext, + transactionNameHasWildcard, +} from './utils'; let _useEffect: UseEffect; let _useLocation: UseLocation; @@ -230,11 +235,14 @@ function trackLazyRouteLoad(span: Span, promise: Promise): void { /** * Processes resolved routes by adding them to allRoutes and checking for nested async handlers. + * When capturedSpan is provided, updates that specific span instead of the current active span. + * This prevents race conditions where a lazy handler resolves after the user has navigated away. */ export function processResolvedRoutes( resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation: Location | null = null, + capturedSpan?: Span, ): void { resolvedRoutes.forEach(child => { allRoutes.add(child); @@ -249,17 +257,27 @@ export function processResolvedRoutes( addResolvedRoutesToParent(resolvedRoutes, parentRoute); } - // After processing lazy routes, check if we need to update an active transaction - const activeRootSpan = getActiveRootSpan(); - if (activeRootSpan) { - const spanOp = spanToJSON(activeRootSpan).op; + // Use captured span if provided, otherwise fall back to current active span + const targetSpan = capturedSpan ?? getActiveRootSpan(); + if (targetSpan) { + const spanJson = spanToJSON(targetSpan); - // Try to use the provided location first, then fall back to global window location if needed + // Skip update if span has already ended (timestamp is set when span.end() is called) + if (spanJson.timestamp) { + DEBUG_BUILD && debug.warn('[React Router] Lazy handler resolved after span ended - skipping update'); + return; + } + + const spanOp = spanJson.op; + + // Use captured location for route matching (ensures we match against the correct route) + // Fall back to window.location only if no captured location and no captured span + // (i.e., this is not from an async handler) let location = currentLocation; - if (!location) { + if (!location && !capturedSpan) { if (typeof WINDOW !== 'undefined') { const globalLocation = WINDOW.location; - if (globalLocation) { + if (globalLocation?.pathname) { location = { pathname: globalLocation.pathname }; } } @@ -269,14 +287,14 @@ export function processResolvedRoutes( if (spanOp === 'pageload') { // Re-run the pageload transaction update with the newly loaded routes updatePageloadTransaction({ - activeRootSpan, + activeRootSpan: targetSpan, location: { pathname: location.pathname }, routes: Array.from(allRoutes), allRoutes: Array.from(allRoutes), }); } else if (spanOp === 'navigation') { // For navigation spans, update the name with the newly loaded routes - updateNavigationSpan(activeRootSpan, location, Array.from(allRoutes), false, _matchRoutes); + updateNavigationSpan(targetSpan, location, Array.from(allRoutes), false, _matchRoutes); } } } @@ -713,7 +731,12 @@ function wrapPatchRoutesOnNavigation( (args as any).patch = (routeId: string, children: RouteObject[]) => { addRoutesToAllRoutes(children); const currentActiveRootSpan = getActiveRootSpan(); - if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') { + // Only update if we have a valid targetPath (patchRoutesOnNavigation can be called without path) + if ( + targetPath && + currentActiveRootSpan && + (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation' + ) { updateNavigationSpan( currentActiveRootSpan, { pathname: targetPath, search: '', hash: '', state: null, key: 'default' }, @@ -728,7 +751,14 @@ function wrapPatchRoutesOnNavigation( } const lazyLoadPromise = (async () => { - const result = await originalPatchRoutes(args); + // Set context so async handlers can access correct targetPath and span + const contextToken = setNavigationContext(targetPath, activeRootSpan); + let result; + try { + result = await originalPatchRoutes(args); + } finally { + clearNavigationContext(contextToken); + } const currentActiveRootSpan = getActiveRootSpan(); if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') { @@ -1184,17 +1214,3 @@ export function createV6CompatibleWithSentryReactRouterRouting

unknown, route: RouteObject, handlerKey: string, - processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void, + processResolvedRoutes: ( + resolvedRoutes: RouteObject[], + parentRoute?: RouteObject, + currentLocation?: Location, + capturedSpan?: Span, + ) => void, ): (...args: unknown[]) => unknown { const proxy = new Proxy(originalFunction, { apply(target: (...args: unknown[]) => unknown, thisArg, argArray) { + const locationAtInvocation = captureCurrentLocation(); + const spanAtInvocation = captureActiveSpan(); const result = target.apply(thisArg, argArray); - handleAsyncHandlerResult(result, route, handlerKey, processResolvedRoutes); + handleAsyncHandlerResult( + result, + route, + handlerKey, + processResolvedRoutes, + locationAtInvocation, + spanAtInvocation, + ); return result; }, }); @@ -26,25 +94,33 @@ export function createAsyncHandlerProxy( /** * Handles the result of an async handler function call. + * Passes the captured span through to ensure the correct span is updated. */ export function handleAsyncHandlerResult( result: unknown, route: RouteObject, handlerKey: string, - processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void, + processResolvedRoutes: ( + resolvedRoutes: RouteObject[], + parentRoute?: RouteObject, + currentLocation?: Location, + capturedSpan?: Span, + ) => void, + currentLocation: Location | null, + capturedSpan: Span | undefined, ): void { if (isThenable(result)) { (result as Promise) .then((resolvedRoutes: unknown) => { if (Array.isArray(resolvedRoutes)) { - processResolvedRoutes(resolvedRoutes, route); + processResolvedRoutes(resolvedRoutes, route, currentLocation ?? undefined, capturedSpan); } }) .catch((e: unknown) => { DEBUG_BUILD && debug.warn(`Error resolving async handler '${handlerKey}' for route`, route, e); }); } else if (Array.isArray(result)) { - processResolvedRoutes(result, route); + processResolvedRoutes(result, route, currentLocation ?? undefined, capturedSpan); } } @@ -53,7 +129,12 @@ export function handleAsyncHandlerResult( */ export function checkRouteForAsyncHandler( route: RouteObject, - processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void, + processResolvedRoutes: ( + resolvedRoutes: RouteObject[], + parentRoute?: RouteObject, + currentLocation?: Location, + capturedSpan?: Span, + ) => void, ): void { // Set up proxies for any functions in the route's handle if (route.handle && typeof route.handle === 'object') { diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index 8431e283108b..96c178b64c14 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -1,10 +1,57 @@ -import type { TransactionSource } from '@sentry/core'; +import type { Span, TransactionSource } from '@sentry/core'; +import { debug, getActiveSpan, getRootSpan, spanToJSON } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../types'; // Global variables that these utilities depend on let _matchRoutes: MatchRoutes; let _stripBasename: boolean = false; +// Navigation context stack for nested/concurrent patchRoutesOnNavigation calls. +// Required because window.location hasn't updated yet when handlers are invoked. +interface NavigationContext { + token: object; + targetPath: string | undefined; + span: Span | undefined; +} + +const _navigationContextStack: NavigationContext[] = []; +const MAX_CONTEXT_STACK_SIZE = 10; + +/** + * Pushes a navigation context and returns a unique token for cleanup. + * The token uses object identity for uniqueness (no counter needed). + */ +export function setNavigationContext(targetPath: string | undefined, span: Span | undefined): object { + const token = {}; + // Prevent unbounded stack growth - oldest (likely stale) contexts are evicted first + if (_navigationContextStack.length >= MAX_CONTEXT_STACK_SIZE) { + DEBUG_BUILD && debug.warn('[React Router] Navigation context stack overflow - removing oldest context'); + _navigationContextStack.shift(); + } + _navigationContextStack.push({ token, targetPath, span }); + return token; +} + +/** + * Clears the navigation context if it's on top of the stack (LIFO). + * If our context is not on top (out-of-order completion), we leave it - + * it will be cleaned up by overflow protection when the stack fills up. + */ +export function clearNavigationContext(token: object): void { + const top = _navigationContextStack[_navigationContextStack.length - 1]; + if (top?.token === token) { + _navigationContextStack.pop(); + } +} + +/** Gets the current (most recent) navigation context if inside a patchRoutesOnNavigation call. */ +export function getNavigationContext(): NavigationContext | null { + const length = _navigationContextStack.length; + // The `?? null` converts undefined (from array access) to null to match return type + return length > 0 ? (_navigationContextStack[length - 1] ?? null) : null; +} + /** * Initialize function to set dependencies that the router utilities need. * Must be called before using any of the exported utility functions. @@ -273,3 +320,20 @@ export function resolveRouteNameAndSource( return [name || location.pathname, source]; } + +/** + * Gets the active root span if it's a pageload or navigation span. + */ +export function getActiveRootSpan(): Span | undefined { + const span = getActiveSpan(); + const rootSpan = span ? getRootSpan(span) : undefined; + + if (!rootSpan) { + return undefined; + } + + const op = spanToJSON(rootSpan).op; + + // Only use this root span if it is a pageload or navigation span + return op === 'navigation' || op === 'pageload' ? rootSpan : undefined; +} diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index 276a5b9950fc..3d2b4f198cf5 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -59,6 +59,7 @@ vi.mock('../../src/reactrouter-compat-utils/utils', () => ({ transactionNameHasWildcard: vi.fn((name: string) => { return name.includes('/*') || name === '*' || name.endsWith('*'); }), + getActiveRootSpan: vi.fn(() => undefined), })); vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({ diff --git a/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts b/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts index 732b893ea8f8..0d1a493e08f2 100644 --- a/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts +++ b/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts @@ -106,7 +106,9 @@ describe('reactrouter-compat-utils/lazy-routes', () => { proxy(); // Since handleAsyncHandlerResult is called internally, we verify through its side effects - expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(['route1', 'route2'], route); + // The third parameter is the captured location (undefined in jsdom test environment) + // The fourth parameter is the captured span (undefined since no active span in test) + expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(['route1', 'route2'], route, undefined, undefined); }); it('should handle functions that throw exceptions', () => { @@ -137,35 +139,38 @@ describe('reactrouter-compat-utils/lazy-routes', () => { const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes); proxy(); - expect(mockProcessResolvedRoutes).toHaveBeenCalledWith([], route); + // The third parameter is the captured location (undefined in jsdom test environment) + // The fourth parameter is the captured span (undefined since no active span in test) + expect(mockProcessResolvedRoutes).toHaveBeenCalledWith([], route, undefined, undefined); }); }); describe('handleAsyncHandlerResult', () => { const route: RouteObject = { path: '/test' }; const handlerKey = 'testHandler'; + const mockLocation = { pathname: '/test', search: '', hash: '', state: null, key: 'default' }; it('should handle array results directly', () => { const routes: RouteObject[] = [{ path: '/route1' }, { path: '/route2' }]; - handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); - expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route); + expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined); }); it('should handle empty array results', () => { const routes: RouteObject[] = []; - handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); - expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route); + expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined); }); it('should handle Promise results that resolve to arrays', async () => { const routes: RouteObject[] = [{ path: '/route1' }, { path: '/route2' }]; const promiseResult = Promise.resolve(routes); - handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); // Wait for the promise to resolve await promiseResult; @@ -173,25 +178,25 @@ describe('reactrouter-compat-utils/lazy-routes', () => { // Use setTimeout to wait for the async handling await new Promise(resolve => setTimeout(resolve, 0)); - expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route); + expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined); }); it('should handle Promise results that resolve to empty arrays', async () => { const routes: RouteObject[] = []; const promiseResult = Promise.resolve(routes); - handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); await promiseResult; await new Promise(resolve => setTimeout(resolve, 0)); - expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route); + expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined); }); it('should handle Promise results that resolve to non-arrays', async () => { const promiseResult = Promise.resolve('not an array'); - handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); await promiseResult; await new Promise(resolve => setTimeout(resolve, 0)); @@ -202,7 +207,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => { it('should handle Promise results that resolve to null', async () => { const promiseResult = Promise.resolve(null); - handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); await promiseResult; await new Promise(resolve => setTimeout(resolve, 0)); @@ -213,7 +218,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => { it('should handle Promise results that resolve to undefined', async () => { const promiseResult = Promise.resolve(undefined); - handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); await promiseResult; await new Promise(resolve => setTimeout(resolve, 0)); @@ -224,7 +229,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => { it('should handle Promise rejections gracefully', async () => { const promiseResult = Promise.reject(new Error('Test error')); - handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); // Wait for the promise to be handled await new Promise(resolve => setTimeout(resolve, 0)); @@ -240,7 +245,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => { it('should handle Promise rejections with non-Error values', async () => { const promiseResult = Promise.reject('string error'); - handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); await new Promise(resolve => setTimeout(resolve, 0)); @@ -253,25 +258,25 @@ describe('reactrouter-compat-utils/lazy-routes', () => { }); it('should ignore non-promise, non-array results', () => { - handleAsyncHandlerResult('string result', route, handlerKey, mockProcessResolvedRoutes); - handleAsyncHandlerResult(123, route, handlerKey, mockProcessResolvedRoutes); - handleAsyncHandlerResult({ not: 'array' }, route, handlerKey, mockProcessResolvedRoutes); - handleAsyncHandlerResult(null, route, handlerKey, mockProcessResolvedRoutes); - handleAsyncHandlerResult(undefined, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult('string result', route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); + handleAsyncHandlerResult(123, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); + handleAsyncHandlerResult({ not: 'array' }, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); + handleAsyncHandlerResult(null, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); + handleAsyncHandlerResult(undefined, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); expect(mockProcessResolvedRoutes).not.toHaveBeenCalled(); }); it('should ignore boolean values', () => { - handleAsyncHandlerResult(true, route, handlerKey, mockProcessResolvedRoutes); - handleAsyncHandlerResult(false, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult(true, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); + handleAsyncHandlerResult(false, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); expect(mockProcessResolvedRoutes).not.toHaveBeenCalled(); }); it('should ignore functions as results', () => { const functionResult = () => 'test'; - handleAsyncHandlerResult(functionResult, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult(functionResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); expect(mockProcessResolvedRoutes).not.toHaveBeenCalled(); }); @@ -281,7 +286,14 @@ describe('reactrouter-compat-utils/lazy-routes', () => { then: 'not a function', }; - handleAsyncHandlerResult(fakeThenableButNotPromise, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult( + fakeThenableButNotPromise, + route, + handlerKey, + mockProcessResolvedRoutes, + mockLocation, + undefined, + ); expect(mockProcessResolvedRoutes).not.toHaveBeenCalled(); }); @@ -291,7 +303,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => { then: null, }; - handleAsyncHandlerResult(almostPromise, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult(almostPromise, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); expect(mockProcessResolvedRoutes).not.toHaveBeenCalled(); }); @@ -306,12 +318,19 @@ describe('reactrouter-compat-utils/lazy-routes', () => { const routes: RouteObject[] = [{ path: '/dynamic1' }, { path: '/dynamic2' }]; const promiseResult = Promise.resolve(routes); - handleAsyncHandlerResult(promiseResult, complexRoute, 'complexHandler', mockProcessResolvedRoutes); + handleAsyncHandlerResult( + promiseResult, + complexRoute, + 'complexHandler', + mockProcessResolvedRoutes, + mockLocation, + undefined, + ); await promiseResult; await new Promise(resolve => setTimeout(resolve, 0)); - expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, complexRoute); + expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, complexRoute, mockLocation, undefined); }); it('should handle nested route objects in arrays', () => { @@ -322,9 +341,18 @@ describe('reactrouter-compat-utils/lazy-routes', () => { }, ]; - handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes); + handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined); + + expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined); + }); + + it('should convert null location to undefined for processResolvedRoutes', () => { + const routes: RouteObject[] = [{ path: '/route1' }]; + + handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes, null, undefined); - expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route); + // When null is passed, it should convert to undefined for processResolvedRoutes + expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, undefined, undefined); }); }); diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 438b026104bd..401ea648b0fc 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -1,5 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + clearNavigationContext, + getNavigationContext, getNormalizedName, getNumberOfUrlSegments, initializeRouterUtils, @@ -9,6 +11,7 @@ import { prefixWithSlash, rebuildRoutePathFromAllRoutes, resolveRouteNameAndSource, + setNavigationContext, transactionNameHasWildcard, } from '../../src/reactrouter-compat-utils'; import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../../src/types'; @@ -664,4 +667,137 @@ describe('reactrouter-compat-utils/utils', () => { expect(transactionNameHasWildcard('/path/to/asterisk')).toBe(false); // 'asterisk' contains 'isk' but not '*' }); }); + + describe('navigation context management', () => { + // Clean up navigation context after each test by popping until empty + afterEach(() => { + // Pop all remaining contexts + while (getNavigationContext() !== null) { + const ctx = getNavigationContext(); + if (ctx) { + clearNavigationContext((ctx as any).token); + } + } + }); + + describe('setNavigationContext', () => { + it('should return unique tokens (object identity)', () => { + const token1 = setNavigationContext('/path1', undefined); + const token2 = setNavigationContext('/path2', undefined); + const token3 = setNavigationContext('/path3', undefined); + + // Each token should be a unique object + expect(token1).not.toBe(token2); + expect(token2).not.toBe(token3); + expect(token1).not.toBe(token3); + }); + + it('should store targetPath and span in context', () => { + const mockSpan = { name: 'test-span' } as any; + setNavigationContext('/test-path', mockSpan); + + const context = getNavigationContext(); + expect(context).not.toBeNull(); + expect(context?.targetPath).toBe('/test-path'); + expect(context?.span).toBe(mockSpan); + }); + + it('should handle undefined targetPath', () => { + setNavigationContext(undefined, undefined); + + const context = getNavigationContext(); + expect(context).not.toBeNull(); + expect(context?.targetPath).toBeUndefined(); + }); + }); + + describe('clearNavigationContext', () => { + it('should remove context when token matches top of stack (LIFO)', () => { + const token = setNavigationContext('/test', undefined); + + expect(getNavigationContext()).not.toBeNull(); + + clearNavigationContext(token); + + expect(getNavigationContext()).toBeNull(); + }); + + it('should NOT remove context when token is not on top (out-of-order completion)', () => { + // Simulate: Nav1 starts, Nav2 starts, Nav1 tries to complete first + const token1 = setNavigationContext('/nav1', undefined); + const token2 = setNavigationContext('/nav2', undefined); + + // Most recent should be nav2 + expect(getNavigationContext()?.targetPath).toBe('/nav2'); + + // Nav1 tries to complete first (out of order) - should NOT pop because nav1 is not on top + clearNavigationContext(token1); + + // Nav2 should still be the current context (nav1's context is still buried) + expect(getNavigationContext()?.targetPath).toBe('/nav2'); + + // Nav2 completes - should pop because nav2 IS on top + clearNavigationContext(token2); + + // Now nav1's stale context is on top (will be cleaned by overflow protection) + expect(getNavigationContext()?.targetPath).toBe('/nav1'); + }); + + it('should not throw when clearing with unknown token', () => { + const unknownToken = {}; + expect(() => clearNavigationContext(unknownToken)).not.toThrow(); + }); + + it('should correctly handle LIFO cleanup order', () => { + const token1 = setNavigationContext('/path1', undefined); + const token2 = setNavigationContext('/path2', undefined); + const token3 = setNavigationContext('/path3', undefined); + + // Clear in LIFO order + clearNavigationContext(token3); + expect(getNavigationContext()?.targetPath).toBe('/path2'); + + clearNavigationContext(token2); + expect(getNavigationContext()?.targetPath).toBe('/path1'); + + clearNavigationContext(token1); + expect(getNavigationContext()).toBeNull(); + }); + }); + + describe('getNavigationContext', () => { + it('should return null when stack is empty', () => { + expect(getNavigationContext()).toBeNull(); + }); + + it('should return the most recent context', () => { + setNavigationContext('/first', undefined); + setNavigationContext('/second', undefined); + setNavigationContext('/third', undefined); + + expect(getNavigationContext()?.targetPath).toBe('/third'); + }); + }); + + describe('stack overflow protection', () => { + it('should remove oldest context when stack exceeds limit', () => { + // Push 12 contexts (limit is 10) + const tokens: object[] = []; + for (let i = 0; i < 12; i++) { + tokens.push(setNavigationContext(`/path${i}`, undefined)); + } + + // Most recent should be /path11 + expect(getNavigationContext()?.targetPath).toBe('/path11'); + + // The oldest contexts (path0, path1) were evicted due to overflow + // Trying to clear them does nothing (their tokens no longer match anything) + clearNavigationContext(tokens[0]!); + clearNavigationContext(tokens[1]!); + + // /path11 should still be current + expect(getNavigationContext()?.targetPath).toBe('/path11'); + }); + }); + }); }); From c786fc52e5957747a59ce1ad085cf51be1f97d97 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 9 Dec 2025 11:57:35 +0100 Subject: [PATCH 07/14] chore(ci): Fix double issue creation for unreferenced PRs (#18442) This fixes a problem, where our unreferenced PR GH workflow would trigger another new issue being created because of a race condition between creating the issue and updating the PR description automatically. --- .../workflows/create-issue-for-unreferenced-prs.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/create-issue-for-unreferenced-prs.yml b/.github/workflows/create-issue-for-unreferenced-prs.yml index a0eee060f282..a47df32738d7 100644 --- a/.github/workflows/create-issue-for-unreferenced-prs.yml +++ b/.github/workflows/create-issue-for-unreferenced-prs.yml @@ -41,6 +41,14 @@ jobs: return; } + // Bail if this edit was made by the GitHub Actions bot (this workflow) + // This prevents infinite loops when we update the PR body with the new issue reference + // We check login specifically to not skip edits from other legitimate bots + if (context.payload.sender && context.payload.sender.login === 'github-actions[bot]') { + console.log(`PR #${pr.number} was edited by github-actions[bot] (this workflow), skipping.`); + return; + } + // Check if the PR is already approved const reviewsResponse = await github.rest.pulls.listReviews({ owner: context.repo.owner, @@ -109,7 +117,7 @@ jobs: console.log(`Created issue #${issueID}.`); // Update the PR body to reference the new issue - const updatedPrBody = `${prBody}\n\nCloses #${issueID}`; + const updatedPrBody = `${prBody}\n\nCloses #${issueID} (added automatically)`; await github.rest.pulls.update({ owner: context.repo.owner, From ca146a57deaca8e6da3eb660177401f8a014ce66 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 9 Dec 2025 12:00:12 +0100 Subject: [PATCH 08/14] chore(publish): Fix publish order for `@sentry/types` (#18429) Types depends on core but we accidentally published it before core. Not the end of the world but theoretically, if publishing core failed, we would have published a faulty types package. h/t @BYK for detecting this! Closes #18431 --- .craft.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.craft.yml b/.craft.yml index cf86175ca43d..f2ffca132f23 100644 --- a/.craft.yml +++ b/.craft.yml @@ -4,14 +4,14 @@ preReleaseCommand: bash scripts/craft-pre-release.sh targets: # NPM Targets ## 1. Base Packages, node or browser SDKs depend on - ## 1.1 Types - - name: npm - id: '@sentry/types' - includeNames: /^sentry-types-\d.*\.tgz$/ - ## 1.2 Core SDKs + ## 1.1 Core SDKs - name: npm id: '@sentry/core' includeNames: /^sentry-core-\d.*\.tgz$/ + ## 1.2 Types + - name: npm + id: '@sentry/types' + includeNames: /^sentry-types-\d.*\.tgz$/ - name: npm id: '@sentry/node-core' includeNames: /^sentry-node-core-\d.*\.tgz$/ From 49facf21411058f14d9651a6609c1d6cd1193de0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:05:14 +0100 Subject: [PATCH 09/14] chore(deps): bump next from 16.0.0 to 16.0.7 in /dev-packages/e2e-tests/test-applications/nextjs-16-tunnel (#18439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [next](https://github.com/vercel/next.js) from 16.0.0 to 16.0.7.

Release notes

Sourced from next's releases.

v16.0.7

Please see CVE-2025-66478 for additional details about this release.

v16.0.6

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • bump the browserslist version to silence a warning in CI (#86625)

Credits

Huge thanks to @​lukesandberg for helping!

v16.0.5

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • fix(nodejs-middleware): await for body cloning to be properly finalized (#85418)

Credits

Huge thanks to @​lucasadrianof for helping!

v16.0.4

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • fix: Rename proxy.js to middleware.js in NFT file (#86214)
  • fix: prevent fetch abort errors propagating to user error boundaries (#86277)
  • Turbopack: fix passing project options from napi (#86256)

Credits

Huge thanks to @​devjiwonchoi, @​sokra and @​ztanner for helping!

v16.0.3

Core Changes

  • fix: Rspack throw error when using ForceCompleteRuntimePlugin: #85221
  • fix: build CLI output not displaying Proxy (Middleware) when nodejs runtime: #85403
  • fix: staleTimes.static should consistently enforce a 30s minimum: #85479
  • [turbopack] fix build of empty entries of pages: #84873
  • Cache the head separately from the route tree: #84724
  • Allow inspecting dev server on default port with next dev --inspect: #85037

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=next&package-manager=npm_and_yarn&previous-version=16.0.0&new-version=16.0.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../e2e-tests/test-applications/nextjs-16-tunnel/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index 40389ad0888f..724dc9e58e4d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -27,7 +27,7 @@ "@sentry/core": "latest || *", "ai": "^3.0.0", "import-in-the-middle": "^1", - "next": "16.0.0", + "next": "16.0.7", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^7", From 23c16fd6fd7ffb6bf25db285ab1562d227716dfa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:05:22 +0100 Subject: [PATCH 10/14] chore(deps): bump next from 16.0.0 to 16.0.7 in /dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents (#18427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [next](https://github.com/vercel/next.js) from 16.0.0 to 16.0.7.
Release notes

Sourced from next's releases.

v16.0.7

Please see CVE-2025-66478 for additional details about this release.

v16.0.6

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • bump the browserslist version to silence a warning in CI (#86625)

Credits

Huge thanks to @​lukesandberg for helping!

v16.0.5

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • fix(nodejs-middleware): await for body cloning to be properly finalized (#85418)

Credits

Huge thanks to @​lucasadrianof for helping!

v16.0.4

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • fix: Rename proxy.js to middleware.js in NFT file (#86214)
  • fix: prevent fetch abort errors propagating to user error boundaries (#86277)
  • Turbopack: fix passing project options from napi (#86256)

Credits

Huge thanks to @​devjiwonchoi, @​sokra and @​ztanner for helping!

v16.0.3

Core Changes

  • fix: Rspack throw error when using ForceCompleteRuntimePlugin: #85221
  • fix: build CLI output not displaying Proxy (Middleware) when nodejs runtime: #85403
  • fix: staleTimes.static should consistently enforce a 30s minimum: #85479
  • [turbopack] fix build of empty entries of pages: #84873
  • Cache the head separately from the route tree: #84724
  • Allow inspecting dev server on default port with next dev --inspect: #85037

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=next&package-manager=npm_and_yarn&previous-version=16.0.0&new-version=16.0.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../test-applications/nextjs-16-cacheComponents/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json index de2d67b0ed4b..bbd1573fc5be 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json @@ -26,7 +26,7 @@ "@sentry/nextjs": "latest || *", "@sentry/core": "latest || *", "import-in-the-middle": "^1", - "next": "16.0.0", + "next": "16.0.7", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^7", From 859608626338666a807699bd8be7b0b4b2cb520d Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 9 Dec 2025 20:57:00 +0000 Subject: [PATCH 11/14] fix(aws-serverless): Remove hyphens from AWS-lambda origins (#18353) Following up: https://github.com/getsentry/sentry-javascript/pull/18351 Looks like these are also forgotten in hyphens --- .github/workflows/build.yml | 4 ++ dev-packages/e2e-tests/run.ts | 2 + .../aws-serverless/src/stack.ts | 55 ++++++++++++++++++- .../aws-serverless/tests/lambda-fixtures.ts | 28 +++++++++- .../aws-serverless/tests/layer.test.ts | 12 ++-- .../aws-serverless/tests/npm.test.ts | 8 +-- .../src/integration/awslambda.ts | 2 +- 7 files changed, 96 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5575f81c9e4a..b351bdc647a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -992,6 +992,8 @@ jobs: working-directory: ${{ runner.temp }}/test-application timeout-minutes: 7 run: ${{ matrix.build-command || 'pnpm test:build' }} + env: + SENTRY_E2E_WORKSPACE_ROOT: ${{ github.workspace }} - name: Install Playwright uses: ./.github/actions/install-playwright @@ -1003,6 +1005,8 @@ jobs: working-directory: ${{ runner.temp }}/test-application timeout-minutes: 10 run: ${{ matrix.assert-command || 'pnpm test:assert' }} + env: + SENTRY_E2E_WORKSPACE_ROOT: ${{ github.workspace }} - name: Upload Playwright Traces uses: actions/upload-artifact@v5 diff --git a/dev-packages/e2e-tests/run.ts b/dev-packages/e2e-tests/run.ts index 5312dc664cee..e0331f0694f8 100644 --- a/dev-packages/e2e-tests/run.ts +++ b/dev-packages/e2e-tests/run.ts @@ -76,6 +76,8 @@ async function run(): Promise { REACT_APP_E2E_TEST_DSN: dsn, E2E_TEST_SENTRY_ORG_SLUG: process.env.E2E_TEST_SENTRY_ORG_SLUG || DEFAULT_SENTRY_ORG_SLUG, E2E_TEST_SENTRY_PROJECT: process.env.E2E_TEST_SENTRY_PROJECT || DEFAULT_SENTRY_PROJECT, + // Pass workspace root so tests copied to temp dirs can find local packages + SENTRY_E2E_WORKSPACE_ROOT: resolve(__dirname, '../..'), }; const env = { diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts index d23feae60811..63463c914e1d 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts @@ -15,6 +15,17 @@ const LAYER_DIR = './node_modules/@sentry/aws-serverless/'; const DEFAULT_NODE_VERSION = '22'; export const SAM_PORT = 3001; +function resolvePackagesDir(): string { + // When running via the e2e test runner, tests are copied to a temp directory + // so we need the workspace root passed via env var + const workspaceRoot = process.env.SENTRY_E2E_WORKSPACE_ROOT; + if (workspaceRoot) { + return path.join(workspaceRoot, 'packages'); + } + // Fallback for local development when running from the original location + return path.resolve(__dirname, '../../../../../packages'); +} + export class LocalLambdaStack extends Stack { sentryLayer: CfnResource; @@ -67,10 +78,48 @@ export class LocalLambdaStack extends Stack { const functionName = `${addLayer ? 'Layer' : 'Npm'}${lambdaDir}`; if (!addLayer) { + const lambdaPath = path.resolve(functionsDir, lambdaDir); + const packageLockPath = path.join(lambdaPath, 'package-lock.json'); + const nodeModulesPath = path.join(lambdaPath, 'node_modules'); + + // Point the dependency at the locally built packages so tests use the current workspace bits + // We need to link all @sentry/* packages that are dependencies of aws-serverless + // because otherwise npm will try to install them from the registry, where the current version is not yet published + const packagesToLink = ['aws-serverless', 'node', 'core', 'node-core', 'opentelemetry']; + const dependencies: Record = {}; + + const packagesDir = resolvePackagesDir(); + for (const pkgName of packagesToLink) { + const pkgDir = path.join(packagesDir, pkgName); + if (!fs.existsSync(pkgDir)) { + throw new Error( + `[LocalLambdaStack] Workspace package ${pkgName} not found at ${pkgDir}. Did you run the build?`, + ); + } + const relativePath = path.relative(lambdaPath, pkgDir); + dependencies[`@sentry/${pkgName}`] = `file:${relativePath.replace(/\\/g, '/')}`; + } + console.log(`[LocalLambdaStack] Install dependencies for ${functionName}`); - const packageJson = { dependencies: { '@sentry/aws-serverless': '* || latest' } }; - fs.writeFileSync(path.join(functionsDir, lambdaDir, 'package.json'), JSON.stringify(packageJson, null, 2)); - execFileSync('npm', ['install', '--prefix', path.join(functionsDir, lambdaDir)], { stdio: 'inherit' }); + + if (fs.existsSync(packageLockPath)) { + // Prevent stale lock files from pinning the published package version + fs.rmSync(packageLockPath); + } + + if (fs.existsSync(nodeModulesPath)) { + // Ensure we reinstall from the workspace instead of reusing cached dependencies + fs.rmSync(nodeModulesPath, { recursive: true, force: true }); + } + + const packageJson = { + dependencies, + }; + + fs.writeFileSync(path.join(lambdaPath, 'package.json'), JSON.stringify(packageJson, null, 2)); + // Use --install-links to copy files instead of creating symlinks for file: dependencies. + // Symlinks don't work inside the Docker container because the target paths don't exist there. + execFileSync('npm', ['install', '--install-links', '--prefix', lambdaPath], { stdio: 'inherit' }); } new CfnResource(this, functionName, { diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts index d6f331c7e96b..23aab3a7d683 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts @@ -17,7 +17,7 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien console.log('[testEnvironment fixture] Setting up AWS Lambda test infrastructure'); execSync('docker network prune -f'); - execSync(`docker network create --driver bridge ${DOCKER_NETWORK_NAME}`); + createDockerNetwork(); const hostIp = await getHostIp(); const app = new App(); @@ -71,6 +71,8 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien resolve(void 0); }, 5000); }); + + removeDockerNetwork(); } }, { scope: 'worker', auto: true }, @@ -88,3 +90,27 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien await use(lambdaClient); }, }); + +function createDockerNetwork() { + try { + execSync(`docker network create --driver bridge ${DOCKER_NETWORK_NAME}`); + } catch (error) { + const stderr = (error as { stderr?: Buffer }).stderr?.toString() ?? ''; + if (stderr.includes('already exists')) { + console.log(`[testEnvironment fixture] Reusing existing docker network ${DOCKER_NETWORK_NAME}`); + return; + } + throw error; + } +} + +function removeDockerNetwork() { + try { + execSync(`docker network rm ${DOCKER_NETWORK_NAME}`); + } catch (error) { + const stderr = (error as { stderr?: Buffer }).stderr?.toString() ?? ''; + if (!stderr.includes('No such network')) { + console.warn(`[testEnvironment fixture] Failed to remove docker network ${DOCKER_NETWORK_NAME}: ${stderr}`); + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts index bb7ae03a96e7..966ddf032218 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts @@ -23,7 +23,7 @@ test.describe('Lambda layer', () => { data: { 'sentry.sample_rate': 1, 'sentry.source': 'custom', - 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.origin': 'auto.otel.aws_lambda', 'sentry.op': 'function.aws.lambda', 'cloud.account.id': '012345678912', 'faas.execution': expect.any(String), @@ -32,7 +32,7 @@ test.describe('Lambda layer', () => { 'otel.kind': 'SERVER', }, op: 'function.aws.lambda', - origin: 'auto.otel.aws-lambda', + origin: 'auto.otel.aws_lambda', span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -91,7 +91,7 @@ test.describe('Lambda layer', () => { data: { 'sentry.sample_rate': 1, 'sentry.source': 'custom', - 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.origin': 'auto.otel.aws_lambda', 'sentry.op': 'function.aws.lambda', 'cloud.account.id': '012345678912', 'faas.execution': expect.any(String), @@ -100,7 +100,7 @@ test.describe('Lambda layer', () => { 'otel.kind': 'SERVER', }, op: 'function.aws.lambda', - origin: 'auto.otel.aws-lambda', + origin: 'auto.otel.aws_lambda', span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -214,7 +214,7 @@ test.describe('Lambda layer', () => { data: { 'sentry.sample_rate': 1, 'sentry.source': 'custom', - 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.origin': 'auto.otel.aws_lambda', 'sentry.op': 'function.aws.lambda', 'cloud.account.id': '012345678912', 'faas.execution': expect.any(String), @@ -223,7 +223,7 @@ test.describe('Lambda layer', () => { 'otel.kind': 'SERVER', }, op: 'function.aws.lambda', - origin: 'auto.otel.aws-lambda', + origin: 'auto.otel.aws_lambda', span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts index 9b4183425c95..e5b6ee1b9f32 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts @@ -23,7 +23,7 @@ test.describe('NPM package', () => { data: { 'sentry.sample_rate': 1, 'sentry.source': 'custom', - 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.origin': 'auto.otel.aws_lambda', 'sentry.op': 'function.aws.lambda', 'cloud.account.id': '012345678912', 'faas.execution': expect.any(String), @@ -32,7 +32,7 @@ test.describe('NPM package', () => { 'otel.kind': 'SERVER', }, op: 'function.aws.lambda', - origin: 'auto.otel.aws-lambda', + origin: 'auto.otel.aws_lambda', span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -91,7 +91,7 @@ test.describe('NPM package', () => { data: { 'sentry.sample_rate': 1, 'sentry.source': 'custom', - 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.origin': 'auto.otel.aws_lambda', 'sentry.op': 'function.aws.lambda', 'cloud.account.id': '012345678912', 'faas.execution': expect.any(String), @@ -100,7 +100,7 @@ test.describe('NPM package', () => { 'otel.kind': 'SERVER', }, op: 'function.aws.lambda', - origin: 'auto.otel.aws-lambda', + origin: 'auto.otel.aws_lambda', span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), diff --git a/packages/aws-serverless/src/integration/awslambda.ts b/packages/aws-serverless/src/integration/awslambda.ts index 2eaa1fd17354..0da2ea148a3f 100644 --- a/packages/aws-serverless/src/integration/awslambda.ts +++ b/packages/aws-serverless/src/integration/awslambda.ts @@ -24,7 +24,7 @@ export const instrumentAwsLambda = generateInstrumentOnce( ...options, eventContextExtractor, requestHook(span) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.otel.aws-lambda'); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.otel.aws_lambda'); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'function.aws.lambda'); }, responseHook(_span, { err }) { From fd67c1937818ec6778795129552c9392985edd1f Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:21:59 +0100 Subject: [PATCH 12/14] fix(core): Parse method from Request object in fetch (#18453) Fixes the case that the instrumentation defaulted to `GET` in case `undefined` was parsed as a second `fetch` argument. ```js const request = new Request("https://httpbin.org/post", { method: "POST" }); const response = await fetch(request, undefined); <-- will be GET (should be POST) ``` Closes #18455 (added automatically) --- packages/core/src/instrument/fetch.ts | 11 +++++--- .../core/test/lib/instrument/fetch.test.ts | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/core/src/instrument/fetch.ts b/packages/core/src/instrument/fetch.ts index 0780b25bb29f..ef69ba8223e0 100644 --- a/packages/core/src/instrument/fetch.ts +++ b/packages/core/src/instrument/fetch.ts @@ -240,11 +240,16 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str } if (fetchArgs.length === 2) { - const [url, options] = fetchArgs as [FetchResource, object]; + const [resource, options] = fetchArgs as [FetchResource, object]; return { - url: getUrlFromResource(url), - method: hasProp(options, 'method') ? String(options.method).toUpperCase() : 'GET', + url: getUrlFromResource(resource), + method: hasProp(options, 'method') + ? String(options.method).toUpperCase() + : // Request object as first argument + isRequest(resource) && hasProp(resource, 'method') + ? String(resource.method).toUpperCase() + : 'GET', }; } diff --git a/packages/core/test/lib/instrument/fetch.test.ts b/packages/core/test/lib/instrument/fetch.test.ts index 88d780a7dbad..215b0c513ee5 100644 --- a/packages/core/test/lib/instrument/fetch.test.ts +++ b/packages/core/test/lib/instrument/fetch.test.ts @@ -27,4 +27,29 @@ describe('instrument > parseFetchArgs', () => { expect(actual).toEqual(expected); }); + + describe('fetch with Request object', () => { + it.each([ + [ + 'Request object (as only arg)', + [new Request('http://example.com', { method: 'POST' })], + { method: 'POST', url: 'http://example.com/' }, + ], + [ + 'Request object (with undefined options arg)', + [new Request('http://example.com', { method: 'POST' }), undefined], + { method: 'POST', url: 'http://example.com/' }, + ], + [ + 'Request object (with overwritten options arg)', + [new Request('http://example.com', { method: 'POST' }), { method: 'DELETE' }], + // fetch options overwrite Request object options + { method: 'DELETE', url: 'http://example.com/' }, + ], + ])('%s', (_name, args, expected) => { + const actual = parseFetchArgs(args as unknown[]); + + expect(actual).toEqual(expected); + }); + }); }); From 741ad6a39a05c2dee5f3f466e39718b6796b360f Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 10 Dec 2025 11:28:04 +0100 Subject: [PATCH 13/14] feat(node): Capture scope when event loop blocked (#18040) - Closes #17887 This currently only capture scope if it gets forked as isolation scope (`Sentry.withIsolationScope`) --- .../suites/thread-blocked-native/isolated.mjs | 37 ++++++++++ .../suites/thread-blocked-native/test.ts | 71 +++++++++++++++++-- .../node-integration-tests/utils/index.ts | 2 +- packages/node-core/src/sdk/client.ts | 4 +- packages/node-native/package.json | 2 +- .../src/event-loop-block-integration.ts | 32 +++++---- .../src/event-loop-block-watchdog.ts | 44 ++++++++++-- packages/node/src/sdk/initOtel.ts | 21 ++++-- packages/opentelemetry/src/contextManager.ts | 28 +++++++- packages/opentelemetry/src/index.ts | 1 + yarn.lock | 8 +-- 11 files changed, 212 insertions(+), 38 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs b/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs new file mode 100644 index 000000000000..c2c0f39fc44e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/node'; +import { longWork } from './long-work.js'; + +setTimeout(() => { + process.exit(); +}, 10000); + +function neverResolve() { + return new Promise(() => { + // + }); +} + +const fns = [ + neverResolve, + neverResolve, + neverResolve, + neverResolve, + neverResolve, + longWork, // [5] + neverResolve, + neverResolve, + neverResolve, + neverResolve, +]; + +setTimeout(() => { + for (let id = 0; id < 10; id++) { + Sentry.withIsolationScope(async () => { + // eslint-disable-next-line no-console + console.log(`Starting task ${id}`); + Sentry.setUser({ id }); + + await fns[id](); + }); + } +}, 1000); diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts index d168b8ce75d5..75f957f07af5 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts @@ -1,6 +1,7 @@ import { join } from 'node:path'; import type { Event } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; +import { NODE_VERSION } from '../../utils/index'; import { cleanupChildProcesses, createRunner } from '../../utils/runner'; function EXCEPTION(thread_id = '0', fn = 'longWork') { @@ -34,9 +35,17 @@ function EXCEPTION(thread_id = '0', fn = 'longWork') { }; } -const ANR_EVENT = { +const ANR_EVENT = (trace: boolean = false) => ({ // Ensure we have context contexts: { + ...(trace + ? { + trace: { + span_id: expect.stringMatching(/[a-f\d]{16}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), + }, + } + : {}), device: { arch: expect.any(String), }, @@ -63,11 +72,11 @@ const ANR_EVENT = { }, // and an exception that is our ANR exception: EXCEPTION(), -}; +}); function ANR_EVENT_WITH_DEBUG_META(file: string): Event { return { - ...ANR_EVENT, + ...ANR_EVENT(), debug_meta: { images: [ { @@ -103,7 +112,7 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { test('Custom appRootPath', async () => { const ANR_EVENT_WITH_SPECIFIC_DEBUG_META: Event = { - ...ANR_EVENT, + ...ANR_EVENT(), debug_meta: { images: [ { @@ -134,7 +143,7 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { test('blocked indefinitely', async () => { await createRunner(__dirname, 'indefinite.mjs') .withMockSentryServer() - .expect({ event: ANR_EVENT }) + .expect({ event: ANR_EVENT() }) .start() .completed(); }); @@ -160,7 +169,7 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { .withMockSentryServer() .expect({ event: { - ...ANR_EVENT, + ...ANR_EVENT(), exception: EXCEPTION('0', 'longWorkOther'), }, }) @@ -179,7 +188,7 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { expect(crashedThread).toBeDefined(); expect(event).toMatchObject({ - ...ANR_EVENT, + ...ANR_EVENT(), exception: { ...EXCEPTION(crashedThread), }, @@ -210,4 +219,52 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { .start() .completed(); }); + + test('Capture scope via AsyncLocalStorage', async ctx => { + if (NODE_VERSION < 24) { + ctx.skip(); + return; + } + + const instrument = join(__dirname, 'instrument.mjs'); + await createRunner(__dirname, 'isolated.mjs') + .withMockSentryServer() + .withInstrument(instrument) + .expect({ + event: event => { + const crashedThread = event.threads?.values?.find(thread => thread.crashed)?.id as string; + expect(crashedThread).toBeDefined(); + + expect(event).toMatchObject({ + ...ANR_EVENT(true), + exception: { + ...EXCEPTION(crashedThread), + }, + breadcrumbs: [ + { + timestamp: expect.any(Number), + category: 'console', + data: { arguments: ['Starting task 5'], logger: 'console' }, + level: 'log', + message: 'Starting task 5', + }, + ], + user: { id: 5 }, + threads: { + values: [ + { + id: '0', + name: 'main', + crashed: true, + current: true, + main: true, + }, + ], + }, + }); + }, + }) + .start() + .completed(); + }); }); diff --git a/dev-packages/node-integration-tests/utils/index.ts b/dev-packages/node-integration-tests/utils/index.ts index e08d89a92131..92851b42ba5e 100644 --- a/dev-packages/node-integration-tests/utils/index.ts +++ b/dev-packages/node-integration-tests/utils/index.ts @@ -3,7 +3,7 @@ import { parseSemver } from '@sentry/core'; import type * as http from 'http'; import { describe } from 'vitest'; -const NODE_VERSION = parseSemver(process.versions.node).major; +export const NODE_VERSION = parseSemver(process.versions.node).major || 0; export type TestServerConfig = { url: string; diff --git a/packages/node-core/src/sdk/client.ts b/packages/node-core/src/sdk/client.ts index efc144989421..1e783ee24b80 100644 --- a/packages/node-core/src/sdk/client.ts +++ b/packages/node-core/src/sdk/client.ts @@ -12,7 +12,7 @@ import { SDK_VERSION, ServerRuntimeClient, } from '@sentry/core'; -import { getTraceContextForScope } from '@sentry/opentelemetry'; +import { type AsyncLocalStorageLookup, getTraceContextForScope } from '@sentry/opentelemetry'; import { isMainThread, threadId } from 'worker_threads'; import { DEBUG_BUILD } from '../debug-build'; import type { NodeClientOptions } from '../types'; @@ -22,6 +22,8 @@ const DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS = 60_000; // 60s was chosen arbitr /** A client for using Sentry with Node & OpenTelemetry. */ export class NodeClient extends ServerRuntimeClient { public traceProvider: BasicTracerProvider | undefined; + public asyncLocalStorageLookup: AsyncLocalStorageLookup | undefined; + private _tracer: Tracer | undefined; private _clientReportInterval: NodeJS.Timeout | undefined; private _clientReportOnExitFlushListener: (() => void) | undefined; diff --git a/packages/node-native/package.json b/packages/node-native/package.json index f5f2ef232c4b..e4da1791544c 100644 --- a/packages/node-native/package.json +++ b/packages/node-native/package.json @@ -63,7 +63,7 @@ "build:tarball": "npm pack" }, "dependencies": { - "@sentry-internal/node-native-stacktrace": "^0.2.2", + "@sentry-internal/node-native-stacktrace": "^0.3.0", "@sentry/core": "10.29.0", "@sentry/node": "10.29.0" }, diff --git a/packages/node-native/src/event-loop-block-integration.ts b/packages/node-native/src/event-loop-block-integration.ts index 713093f77961..7b5c4bc43430 100644 --- a/packages/node-native/src/event-loop-block-integration.ts +++ b/packages/node-native/src/event-loop-block-integration.ts @@ -1,7 +1,6 @@ import { isPromise } from 'node:util/types'; import { isMainThread, Worker } from 'node:worker_threads'; import type { - Client, ClientOptions, Contexts, DsnComponents, @@ -47,7 +46,7 @@ function poll(enabled: boolean, clientOptions: ClientOptions): void { // serialized without making it a SerializedSession const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; // message the worker to tell it the main event loop is still running - threadPoll({ session, debugImages: getFilenameToDebugIdMap(clientOptions.stackParser) }, !enabled); + threadPoll(enabled, { session, debugImages: getFilenameToDebugIdMap(clientOptions.stackParser) }); } catch { // we ignore all errors } @@ -57,10 +56,15 @@ function poll(enabled: boolean, clientOptions: ClientOptions): void { * Starts polling */ function startPolling( - client: Client, + client: NodeClient, integrationOptions: Partial, ): IntegrationInternal | undefined { - registerThread(); + if (client.asyncLocalStorageLookup) { + const { asyncLocalStorage, contextSymbol } = client.asyncLocalStorageLookup; + registerThread({ asyncLocalStorage, stateLookup: ['_currentContext', contextSymbol] }); + } else { + registerThread(); + } let enabled = true; @@ -160,15 +164,19 @@ const _eventLoopBlockIntegration = ((options: Partial { + try { + polling = startPolling(client, options); + + if (isMainThread) { + await startWorker(dsn, client, options); + } + } catch (err) { + log('Failed to start integration', err); + return; } - } catch (err) { - log('Failed to start integration', err); - } + }); }, start() { polling?.start(); diff --git a/packages/node-native/src/event-loop-block-watchdog.ts b/packages/node-native/src/event-loop-block-watchdog.ts index 492070a2d1dc..a4eb696c7a95 100644 --- a/packages/node-native/src/event-loop-block-watchdog.ts +++ b/packages/node-native/src/event-loop-block-watchdog.ts @@ -1,12 +1,16 @@ import { workerData } from 'node:worker_threads'; -import type { DebugImage, Event, Session, StackFrame, Thread } from '@sentry/core'; +import type { DebugImage, Event, ScopeData, Session, StackFrame, Thread } from '@sentry/core'; import { + applyScopeDataToEvent, createEventEnvelope, createSessionEnvelope, filenameIsInApp, + generateSpanId, getEnvelopeEndpointWithUrlEncodedAuth, makeSession, + mergeScopeData, normalizeUrlToBase, + Scope, stripSentryFramesAndReverse, updateSession, uuid4, @@ -16,6 +20,11 @@ import { captureStackTrace, getThreadsLastSeen } from '@sentry-internal/node-nat import type { ThreadState, WorkerStartData } from './common'; import { POLL_RATIO } from './common'; +type CurrentScopes = { + scope: Scope; + isolationScope: Scope; +}; + const { threshold, appRootPath, @@ -178,7 +187,7 @@ function applyDebugMeta(event: Event, debugImages: Record): void function getExceptionAndThreads( crashedThreadId: string, - threads: ReturnType>, + threads: ReturnType>, ): Event { const crashedThread = threads[crashedThreadId]; @@ -217,12 +226,28 @@ function getExceptionAndThreads( }; } +function applyScopeToEvent(event: Event, scope: ScopeData): void { + applyScopeDataToEvent(event, scope); + + if (!event.contexts?.trace) { + const { traceId, parentSpanId, propagationSpanId } = scope.propagationContext; + event.contexts = { + trace: { + trace_id: traceId, + span_id: propagationSpanId || generateSpanId(), + parent_span_id: parentSpanId, + }, + ...event.contexts, + }; + } +} + async function sendBlockEvent(crashedThreadId: string): Promise { if (isRateLimited()) { return; } - const threads = captureStackTrace(); + const threads = captureStackTrace(); const crashedThread = threads[crashedThreadId]; if (!crashedThread) { @@ -231,7 +256,7 @@ async function sendBlockEvent(crashedThreadId: string): Promise { } try { - await sendAbnormalSession(crashedThread.state?.session); + await sendAbnormalSession(crashedThread.pollState?.session); } catch (error) { log(`Failed to send abnormal session for thread '${crashedThreadId}':`, error); } @@ -250,8 +275,17 @@ async function sendBlockEvent(crashedThreadId: string): Promise { ...getExceptionAndThreads(crashedThreadId, threads), }; + const asyncState = threads[crashedThreadId]?.asyncState; + if (asyncState) { + // We need to rehydrate the scopes from the serialized objects so we can call getScopeData() + const scope = Object.assign(new Scope(), asyncState.scope).getScopeData(); + const isolationScope = Object.assign(new Scope(), asyncState.isolationScope).getScopeData(); + mergeScopeData(scope, isolationScope); + applyScopeToEvent(event, scope); + } + const allDebugImages: Record = Object.values(threads).reduce((acc, threadState) => { - return { ...acc, ...threadState.state?.debugImages }; + return { ...acc, ...threadState.pollState?.debugImages }; }, {}); applyDebugMeta(event, allDebugImages); diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 9eec5d752371..a0f1951c376b 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -14,7 +14,12 @@ import { SentryContextManager, setupOpenTelemetryLogger, } from '@sentry/node-core'; -import { SentryPropagator, SentrySampler, SentrySpanProcessor } from '@sentry/opentelemetry'; +import { + type AsyncLocalStorageLookup, + SentryPropagator, + SentrySampler, + SentrySpanProcessor, +} from '@sentry/opentelemetry'; import { DEBUG_BUILD } from '../debug-build'; import { getOpenTelemetryInstrumentationToPreload } from '../integrations/tracing'; @@ -34,8 +39,9 @@ export function initOpenTelemetry(client: NodeClient, options: AdditionalOpenTel setupOpenTelemetryLogger(); } - const provider = setupOtel(client, options); + const [provider, asyncLocalStorageLookup] = setupOtel(client, options); client.traceProvider = provider; + client.asyncLocalStorageLookup = asyncLocalStorageLookup; } interface NodePreloadOptions { @@ -82,7 +88,10 @@ function getPreloadMethods(integrationNames?: string[]): ((() => void) & { id: s } /** Just exported for tests. */ -export function setupOtel(client: NodeClient, options: AdditionalOpenTelemetryOptions = {}): BasicTracerProvider { +export function setupOtel( + client: NodeClient, + options: AdditionalOpenTelemetryOptions = {}, +): [BasicTracerProvider, AsyncLocalStorageLookup] { // Create and configure NodeTracerProvider const provider = new BasicTracerProvider({ sampler: new SentrySampler(client), @@ -106,9 +115,11 @@ export function setupOtel(client: NodeClient, options: AdditionalOpenTelemetryOp // Register as globals trace.setGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); - context.setGlobalContextManager(new SentryContextManager()); - return provider; + const ctxManager = new SentryContextManager(); + context.setGlobalContextManager(ctxManager); + + return [provider, ctxManager.getAsyncLocalStorageLookup()]; } /** Just exported for tests. */ diff --git a/packages/opentelemetry/src/contextManager.ts b/packages/opentelemetry/src/contextManager.ts index e8632b095c02..ac8b2eab5c9b 100644 --- a/packages/opentelemetry/src/contextManager.ts +++ b/packages/opentelemetry/src/contextManager.ts @@ -1,3 +1,4 @@ +import type { AsyncLocalStorage } from 'node:async_hooks'; import type { Context, ContextManager } from '@opentelemetry/api'; import type { Scope } from '@sentry/core'; import { getCurrentScope, getIsolationScope } from '@sentry/core'; @@ -5,10 +6,22 @@ import { SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY, SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, SENTRY_FORK_SET_SCOPE_CONTEXT_KEY, + SENTRY_SCOPES_CONTEXT_KEY, } from './constants'; import { getScopesFromContext, setContextOnScope, setScopesOnContext } from './utils/contextData'; import { setIsSetup } from './utils/setupCheck'; +export type AsyncLocalStorageLookup = { + asyncLocalStorage: AsyncLocalStorage; + contextSymbol: symbol; +}; + +type ExtendedContextManagerInstance = new ( + ...args: unknown[] +) => ContextManagerInstance & { + getAsyncLocalStorageLookup(): AsyncLocalStorageLookup; +}; + /** * Wrap an OpenTelemetry ContextManager in a way that ensures the context is kept in sync with the Sentry Scope. * @@ -19,7 +32,7 @@ import { setIsSetup } from './utils/setupCheck'; */ export function wrapContextManagerClass( ContextManagerClass: new (...args: unknown[]) => ContextManagerInstance, -): typeof ContextManagerClass { +): ExtendedContextManagerInstance { /** * This is a custom ContextManager for OpenTelemetry, which extends the default AsyncLocalStorageContextManager. * It ensures that we create new scopes per context, so that the OTEL Context & the Sentry Scope are always in sync. @@ -69,7 +82,18 @@ export function wrapContextManagerClass; } diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index 6958d1c9fbdd..e0112812dc69 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -41,6 +41,7 @@ export { setupEventContextTrace } from './setupEventContextTrace'; export { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy'; export { wrapContextManagerClass } from './contextManager'; +export type { AsyncLocalStorageLookup } from './contextManager'; export { SentryPropagator, shouldPropagateTraceForUrl } from './propagator'; export { SentrySpanProcessor } from './spanProcessor'; export { SentrySampler, wrapSamplingDecision } from './sampler'; diff --git a/yarn.lock b/yarn.lock index 210e9d186cfb..a0d0b72f6718 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6993,10 +6993,10 @@ detect-libc "^2.0.3" node-abi "^3.73.0" -"@sentry-internal/node-native-stacktrace@^0.2.2": - version "0.2.2" - resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.2.2.tgz#b32dde884642f100dd691b12b643361040825eeb" - integrity sha512-ZRS+a1Ik+w6awjp9na5vHBqLNkIxysfGDswLVAkjtVdBUxtfsEVI8OA6r8PijJC5Gm1oAJJap2e9H7TSiCUQIQ== +"@sentry-internal/node-native-stacktrace@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.3.0.tgz#68c80dcf11ee070a3a54406b35d4571952caa793" + integrity sha512-ef0M2y2JDrC/H0AxMJJQInGTdZTlnwa6AAVWR4fMOpJRubkfdH2IZXE/nWU0Nj74oeJLQgdPtS6DeijLJtqq8Q== dependencies: detect-libc "^2.0.4" node-abi "^3.73.0" From cce2c81e4cc2767c3af1f8f58955b3549f9b5734 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 10 Dec 2025 13:24:57 +0100 Subject: [PATCH 14/14] meta(changelog): Update changelog for 10.30.0 --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15972e85dfdd..d967a7c39408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.30.0 + +- feat(nextjs): Deprecate Webpack top-level options ([#18343](https://github.com/getsentry/sentry-javascript/pull/18343)) +- feat(node): Capture scope when event loop blocked ([#18040](https://github.com/getsentry/sentry-javascript/pull/18040)) +- fix(aws-serverless): Remove hyphens from AWS-lambda origins ([#18353](https://github.com/getsentry/sentry-javascript/pull/18353)) +- fix(core): Parse method from Request object in fetch ([#18453](https://github.com/getsentry/sentry-javascript/pull/18453)) +- fix(react): Add transaction name guards for rapid lazy-route navigations ([#18346](https://github.com/getsentry/sentry-javascript/pull/18346)) + +
+ Internal Changes + +- chore(ci): Fix double issue creation for unreferenced PRs ([#18442](https://github.com/getsentry/sentry-javascript/pull/18442)) +- chore(deps): bump next from 15.5.4 to 15.5.7 in /dev-packages/e2e-tests/test-applications/nextjs-15 ([#18411](https://github.com/getsentry/sentry-javascript/pull/18411)) +- chore(deps): bump next from 15.5.4 to 15.5.7 in /dev-packages/e2e-tests/test-applications/nextjs-15-intl ([#18400](https://github.com/getsentry/sentry-javascript/pull/18400)) +- chore(deps): bump next from 16.0.0 to 16.0.7 in /dev-packages/e2e-tests/test-applications/nextjs-16 ([#18399](https://github.com/getsentry/sentry-javascript/pull/18399)) +- chore(deps): bump next from 16.0.0 to 16.0.7 in /dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents ([#18427](https://github.com/getsentry/sentry-javascript/pull/18427)) +- chore(deps): bump next from 16.0.0 to 16.0.7 in /dev-packages/e2e-tests/test-applications/nextjs-16-tunnel ([#18439](https://github.com/getsentry/sentry-javascript/pull/18439)) +- chore(publish): Fix publish order for `@sentry/types` ([#18429](https://github.com/getsentry/sentry-javascript/pull/18429)) +- ci(deps): bump actions/create-github-app-token from 2.1.4 to 2.2.0 ([#18362](https://github.com/getsentry/sentry-javascript/pull/18362)) + +
+ ## 10.29.0 ### Important Changes