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$/ 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/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/.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, 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 }} 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 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/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", 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", 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", 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", 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", 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/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/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 }) { 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); + }); + }); }); 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', () => { 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/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'); + }); + }); + }); }); 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"