diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml index 5f51f918c34b..76ca231532f9 100644 --- a/.github/actions/install-dependencies/action.yml +++ b/.github/actions/install-dependencies/action.yml @@ -1,10 +1,5 @@ name: 'Install pnpm dependencies' -description: 'Installs pnpm dependencies and caches them.' - -outputs: - cache_key: - description: 'The dependency cache key' - value: ${{ steps.compute_lockfile_hash.outputs.hash }} +description: 'Installs pnpm dependencies into node_modules folders on the Namespace cache volume.' runs: using: 'composite' @@ -12,19 +7,25 @@ runs: - name: Install pnpm uses: ./.github/actions/install-pnpm - - name: Compute dependency cache key - id: compute_lockfile_hash - run: node ./scripts/dependency-hash-key.js >> "$GITHUB_OUTPUT" + - name: List dependency paths + id: paths + run: | + { + echo 'paths<> "$GITHUB_OUTPUT" shell: bash - - name: Check dependency cache - uses: actions/cache@v6.1.0 - id: cache_dependencies + - name: Mount dependency cache + uses: namespacelabs/nscloud-cache-action@1124a6f3ce44e5cf84cc22111530961f4d2a15f9 # v1.7.0 with: - path: ${{ env.CACHED_DEPENDENCY_PATHS }} - key: ${{ steps.compute_lockfile_hash.outputs.hash }} + cache: pnpm + path: ${{ steps.paths.outputs.paths }} + # The cache volume can hold node_modules of another commit or branch. This is a no-op when they match + # pnpm-lock.yaml. - name: Install dependencies - if: steps.cache_dependencies.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile shell: bash diff --git a/.github/actions/install-playwright/action.yml b/.github/actions/install-playwright/action.yml index 30815292dea2..bd84a13e9107 100644 --- a/.github/actions/install-playwright/action.yml +++ b/.github/actions/install-playwright/action.yml @@ -1,5 +1,5 @@ name: 'Install Playwright dependencies' -description: 'Installs Playwright dependencies and caches them.' +description: 'Installs Playwright browsers and system dependencies, keeping both on the Namespace cache volume.' inputs: browsers: description: 'What browsers to install.' @@ -26,40 +26,21 @@ runs: shell: bash working-directory: ${{ inputs.cwd }} - - name: Restore cached playwright binaries - uses: actions/cache/restore@v6.1.0 - id: playwright-cache + - name: Mount Playwright cache + uses: namespacelabs/nscloud-cache-action@1124a6f3ce44e5cf84cc22111530961f4d2a15f9 # v1.7.0 if: steps.playwright-version.outputs.version != '' with: - path: | - ~/.cache/ms-playwright - # Bump the iteration when bumping runner images to use a new cache - key: playwright-${{ runner.os }}-iteration-1-${{ steps.playwright-version.outputs.version }} + cache: | + playwright + apt - # We always install all browsers, if uncached - - name: Install Playwright dependencies (uncached) - env: - PLAYWRIGHT_VERSION: ${{ steps.playwright-version.outputs.version }} - run: npx -y "playwright@$PLAYWRIGHT_VERSION" install chromium webkit firefox --with-deps - if: steps.playwright-version.outputs.version != '' && steps.playwright-cache.outputs.cache-hit != 'true' - shell: bash - - - name: Install Playwright system dependencies only (cached) + # Downloads only the browsers and apt packages that are not on the cache volume yet. `npx` runs outside the repo, + # so it can't pick up a `playwright` that a parallel `pnpm install` is still linking. + - name: Install Playwright + if: steps.playwright-version.outputs.version != '' env: PLAYWRIGHT_BROWSERS: ${{ inputs.browsers || 'chromium webkit firefox' }} PLAYWRIGHT_VERSION: ${{ steps.playwright-version.outputs.version }} - run: npx -y "playwright@$PLAYWRIGHT_VERSION" install-deps "$PLAYWRIGHT_BROWSERS" - if: steps.playwright-version.outputs.version != '' && steps.playwright-cache.outputs.cache-hit == 'true' + run: npx -y "playwright@$PLAYWRIGHT_VERSION" install --with-deps $PLAYWRIGHT_BROWSERS shell: bash - - # Only store cache on develop branch - - name: Store cached playwright binaries - uses: actions/cache/save@v6.1.0 - if: - steps.playwright-version.outputs.version != '' && github.event_name == 'push' && github.ref == - 'refs/heads/develop' - with: - path: | - ~/.cache/ms-playwright - # Bump the iteration when bumping runner images to use a new cache - key: playwright-${{ runner.os }}-iteration-1-${{ steps.playwright-version.outputs.version }} + working-directory: ${{ runner.temp }} diff --git a/.github/actions/restore-cache/action.yml b/.github/actions/restore-cache/action.yml index daa5943f9877..c647c74405b9 100644 --- a/.github/actions/restore-cache/action.yml +++ b/.github/actions/restore-cache/action.yml @@ -1,30 +1,13 @@ name: 'Restore dependency & build cache' -description: 'Restore the dependency & build cache.' - -inputs: - dependency_cache_key: - description: 'The dependency cache key' - required: true +description: 'Installs dependencies from the Namespace cache volume and restores the build output.' runs: using: 'composite' steps: - - name: Install pnpm - uses: ./.github/actions/install-pnpm - - - name: Check dependency cache - id: dep-cache - uses: actions/cache/restore@v6.1.0 - with: - path: ${{ env.CACHED_DEPENDENCY_PATHS }} - key: ${{ inputs.dependency_cache_key }} - - name: Restore build artifacts uses: actions/download-artifact@v7 with: name: build-output - name: Install dependencies - if: steps.dep-cache.outputs.cache-hit != 'true' - run: pnpm install --frozen-lockfile - shell: bash + uses: ./.github/actions/install-dependencies diff --git a/.github/workflows/auto-fix-issue.yml b/.github/workflows/auto-fix-issue.yml index 559f255e3e98..8feb478c6edb 100644 --- a/.github/workflows/auto-fix-issue.yml +++ b/.github/workflows/auto-fix-issue.yml @@ -23,7 +23,7 @@ concurrency: jobs: auto-fix-issue: - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript environment: ci-triage permissions: # Required to create a new branch and commit the fix diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 1e79b8667dce..38802d1c0ad6 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -13,7 +13,7 @@ permissions: jobs: release: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small name: 'Prepare a new version' steps: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c3267dedc200..59962d0d401f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,14 +29,6 @@ concurrency: env: HEAD_COMMIT: ${{ github.event.inputs.commit || github.sha }} - CACHED_DEPENDENCY_PATHS: | - ${{ github.workspace }}/node_modules - ${{ github.workspace }}/packages/*/node_modules - ${{ github.workspace }}/dev-packages/*/node_modules - ~/.cache/mongodb-binaries/ - - # DEPENDENCY_CACHE_KEY: can't be set here because we don't have access to pnpm-lock.yaml - # build-output artifact paths are computed in job_build (step output from # pnpm run ci:print-build-artifact-paths — Nx merged outputs for build:transpile, and build:types). @@ -60,6 +52,8 @@ env: # This option both controls the `v8-compile-cache-lib` and `v8-compile-cache` packages. DISABLE_V8_COMPILE_CACHE: '1' +# All jobs share one Namespace cache volume through `overrides.cache-tag`. Pull request jobs get their own volume +# (`-pr`), so they cannot change the node_modules that develop and release builds install from. jobs: job_get_metadata: uses: ./.github/workflows/ci-metadata.yml @@ -72,7 +66,9 @@ jobs: job_build: name: Build needs: job_get_metadata - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 15 if: | needs.job_get_metadata.outputs.changed_any_code == 'true' || @@ -98,7 +94,6 @@ jobs: - name: Install Dependencies uses: ./.github/actions/install-dependencies - id: install_dependencies - name: Check for Affected Nx Projects uses: ./.github/actions/nx-affected-list @@ -156,7 +151,6 @@ jobs: working-directory: dev-packages/e2e-tests outputs: - dependency_cache_key: ${{ steps.install_dependencies.outputs.cache_key }} changed_node_integration: ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, '@sentry-internal/node-integration-tests') }} @@ -197,7 +191,9 @@ jobs: needs.job_build.outputs.changed_aws_serverless == 'true' || contains(needs.job_build.outputs.e2e-matrix, 'aws-serverless') || github.event_name != 'pull_request' timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -217,8 +213,6 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Build Lambda layer run: pnpm run build:layer @@ -236,7 +230,9 @@ jobs: name: Build bundles needs: [job_get_metadata, job_build] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -256,8 +252,6 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Build bundles run: pnpm run build:bundle @@ -274,7 +268,9 @@ jobs: job_check_branches: name: Check PR branches needs: job_get_metadata - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-small;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} if: github.event_name == 'pull_request' permissions: pull-requests: write @@ -290,7 +286,9 @@ jobs: name: Size Check needs: [job_get_metadata, job_build, job_build_bundles] timeout-minutes: 15 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} if: github.event_name == 'pull_request' || needs.job_get_metadata.outputs.is_base_branch == 'true' || needs.job_get_metadata.outputs.is_release == 'true' @@ -305,8 +303,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Restore build bundle artifacts uses: actions/download-artifact@v8 with: @@ -325,7 +321,9 @@ jobs: # inter-package dependencies resolve cleanly. needs: [job_get_metadata, job_build] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -337,8 +335,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - parallel: - name: Lint source files run: pnpm run lint @@ -346,12 +342,21 @@ jobs: run: pnpm run lint:es-compatibility - name: Lint types run: pnpm run lint:types + - name: Check Playwright versions + run: | + version=$(node -p "require('./dev-packages/test-utils/package.json').devDependencies['@playwright/test']") + if git grep -n '"@playwright/test": "' -- '*package.json' | grep -vF "\"$version\""; then + echo "::error::All @playwright/test dependencies must be pinned to $version (from @sentry-internal/test-utils)." + exit 1 + fi job_check_lockfile: name: Check lockfile needs: [job_get_metadata] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} if: | needs.job_get_metadata.outputs.changed_deps == 'true' || needs.job_get_metadata.outputs.is_base_branch == 'true' || @@ -382,7 +387,9 @@ jobs: name: Check file formatting needs: [job_get_metadata] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -396,7 +403,6 @@ jobs: - name: Install Dependencies uses: ./.github/actions/install-dependencies - id: install_dependencies - name: Check file formatting run: pnpm run format:check @@ -405,7 +411,9 @@ jobs: name: Circular Dependency Check needs: [job_get_metadata, job_build] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -417,15 +425,15 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run madge run: pnpm run circularDepCheck job_artifacts: name: Upload Artifacts needs: [job_get_metadata, job_build, job_build_layer, job_build_bundles, job_build_tarballs] - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} # Build artifacts are only needed for releasing workflow. if: needs.job_get_metadata.outputs.is_release == 'true' steps: @@ -439,8 +447,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Restore tarball artifacts uses: actions/download-artifact@v8 @@ -477,7 +483,9 @@ jobs: name: Browser Unit Tests needs: [job_get_metadata, job_build] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} steps: - name: Check out base commit (${{ github.event.pull_request.base.sha }}) uses: actions/checkout@v7 @@ -495,8 +503,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run affected tests run: pnpm run test:pr:browser --base=${{ github.event.pull_request.base.sha }} @@ -511,7 +517,9 @@ jobs: needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_bun == 'true' || github.event_name != 'pull_request' timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} strategy: fail-fast: false matrix: @@ -532,8 +540,6 @@ jobs: bun-version: ${{ matrix.bun }} - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run tests run: | pnpm run test:ci:bun @@ -543,7 +549,9 @@ jobs: needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_deno == 'true' || github.event_name != 'pull_request' timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -559,8 +567,6 @@ jobs: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run tests run: | cd packages/deno @@ -571,7 +577,9 @@ jobs: name: Node (${{ matrix.node }}) Unit Tests needs: [job_get_metadata, job_build] timeout-minutes: 10 - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} strategy: fail-fast: false matrix: @@ -592,8 +600,6 @@ jobs: node-version: ${{ matrix.node }} - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run affected tests run: pnpm run test:pr:node --base=${{ github.event.pull_request.base.sha }} @@ -613,7 +619,9 @@ jobs: matrix.project) || ''}}${{ matrix.shard && format(' ({0}/{1})', matrix.shard, matrix.shards) || ''}} Tests needs: [job_get_metadata, job_build, job_build_bundles] if: needs.job_build.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04-large-js + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 25 strategy: fail-fast: false @@ -673,8 +681,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Restore build bundle artifacts uses: actions/download-artifact@v8 @@ -711,7 +717,9 @@ jobs: name: PW ${{ matrix.bundle }} Tests needs: [job_get_metadata, job_build, job_build_bundles] if: needs.job_build.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} timeout-minutes: 15 strategy: fail-fast: false @@ -736,8 +744,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Restore build bundle artifacts uses: actions/download-artifact@v8 @@ -770,7 +776,9 @@ jobs: job_check_for_faulty_dts: name: Check for faulty .d.ts files needs: [job_get_metadata, job_build] - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} timeout-minutes: 5 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) @@ -783,8 +791,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Check for dts files that reference stuff in the temporary build folder run: | if grep -r --include "*.d.ts" --exclude-dir ".nx" 'import("@sentry(-internal)?/[^/]*/build' .; then @@ -798,7 +804,9 @@ jobs: Tests (${{ matrix.shard }}/3) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_node_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 20 strategy: fail-fast: false @@ -842,8 +850,6 @@ jobs: node-version: ${{ matrix.node }} - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Overwrite typescript version if: matrix.typescript == '5.0' @@ -858,7 +864,9 @@ jobs: name: Cloudflare Integration Tests (${{ matrix.shard }}/2) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_cloudflare_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} timeout-minutes: 15 strategy: fail-fast: false @@ -875,8 +883,6 @@ jobs: node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run integration tests working-directory: dev-packages/cloudflare-integration-tests @@ -886,7 +892,9 @@ jobs: name: Bundler Plugin Integration Tests (Node ${{ matrix.node }}) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_bundler_plugin_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 15 strategy: fail-fast: false @@ -903,8 +911,6 @@ jobs: node-version: ${{ matrix.node }} - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run integration tests working-directory: dev-packages/bundler-plugin-integration-tests @@ -914,7 +920,9 @@ jobs: name: Bun (${{ matrix.bun }}) Integration Tests (${{ matrix.shard }}/2) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_bun_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 15 strategy: fail-fast: false @@ -937,8 +945,6 @@ jobs: bun-version: ${{ matrix.bun }} - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Run integration tests working-directory: dev-packages/bun-integration-tests @@ -948,7 +954,9 @@ jobs: name: Deno Integration Tests (${{ matrix.shard }}/2) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_deno_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 15 strategy: fail-fast: false @@ -969,8 +977,6 @@ jobs: deno-version: 'v2.8.3' - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Build @sentry/deno working-directory: packages/deno run: pnpm run build @@ -993,7 +999,9 @@ jobs: always() && needs.job_build.result == 'success' needs: [job_get_metadata, job_build] - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-large;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} timeout-minutes: 15 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) @@ -1014,8 +1022,6 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Build tarballs run: pnpm run build:tarball @@ -1047,7 +1053,9 @@ jobs: if: always() && needs.job_build_tarballs.result == 'success' && needs.job_build.outputs.e2e-matrix !='{"include":[]}' needs: [job_get_metadata, job_build, job_build_layer, job_build_tarballs] - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} timeout-minutes: 15 env: # We just use a dummy DSN here, only send to the tunnel anyhow @@ -1060,6 +1068,8 @@ jobs: E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests' # The `runtime` of a runtime variant, see dev-packages/e2e-tests/README.md RUNTIME: ${{ matrix.runtime }} + # Without the root node_modules, `pnpm run` would first install the whole workspace. + pnpm_config_verify_deps_before_run: 'false' strategy: fail-fast: false matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix) }} @@ -1092,6 +1102,11 @@ jobs: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} - name: Install pnpm uses: ./.github/actions/install-pnpm + # Keeps the pnpm store on the cache volume for the `pnpm install` of the test application + - name: Mount pnpm store + uses: namespacelabs/nscloud-cache-action@1124a6f3ce44e5cf84cc22111530961f4d2a15f9 # v1.7.0 + with: + cache: pnpm - name: Restore tarball artifacts uses: actions/download-artifact@v8 @@ -1119,16 +1134,6 @@ jobs: with: name: build-output - # Installs Playwright while the app builds. - # Started after the artifact downloads: starting next to them made `download-artifact` fail to read the event file. - - name: Install Playwright - id: install-playwright - background: true - uses: ./.github/actions/install-playwright - with: - browsers: chromium - cwd: dev-packages/e2e-tests/test-applications/${{ matrix.test-application }} - - name: Prepare e2e tests run: pnpm run test:prepare working-directory: dev-packages/e2e-tests @@ -1149,6 +1154,17 @@ jobs: }}/dev-packages/e2e-tests/packed working-directory: dev-packages/e2e-tests + # Installs Playwright while the app builds. Keep it right before the build: the runner sets up a background + # composite action's steps from the job's shared `github` context, which fails with "Collection was modified" if + # a foreground step starts at that moment. Starting next to `download-artifact` also made it fail to read the event file. + - name: Install Playwright + id: install-playwright + background: true + uses: ./.github/actions/install-playwright + with: + browsers: chromium + cwd: dev-packages/e2e-tests/test-applications/${{ matrix.test-application }} + - name: Build E2E app working-directory: ${{ runner.temp }}/test-application timeout-minutes: 7 @@ -1185,7 +1201,9 @@ jobs: needs.job_build.outputs.e2e-matrix-optional != '{"include":[]}' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]' needs: [job_get_metadata, job_build, job_build_tarballs] - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript${{ github.event_name == 'pull_request' + && '-pr' || '' }} timeout-minutes: 15 env: E2E_TEST_AUTH_TOKEN: ${{ secrets.E2E_TEST_AUTH_TOKEN }} @@ -1206,6 +1224,8 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # The `runtime` of a runtime variant, see dev-packages/e2e-tests/README.md RUNTIME: ${{ matrix.runtime }} + # Without the root node_modules, `pnpm run` would first install the whole workspace. + pnpm_config_verify_deps_before_run: 'false' strategy: fail-fast: false matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix-optional) }} @@ -1233,6 +1253,11 @@ jobs: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} - name: Install pnpm uses: ./.github/actions/install-pnpm + # Keeps the pnpm store on the cache volume for the `pnpm install` of the test application + - name: Mount pnpm store + uses: namespacelabs/nscloud-cache-action@1124a6f3ce44e5cf84cc22111530961f4d2a15f9 # v1.7.0 + with: + cache: pnpm - name: Restore tarball artifacts uses: actions/download-artifact@v8 @@ -1246,16 +1271,6 @@ jobs: name: build-test-utils-tarball-output path: dev-packages/test-utils - # Installs Playwright while the app builds. - # Started after the artifact downloads: starting next to them made `download-artifact` fail to read the event file. - - name: Install Playwright - id: install-playwright - background: true - uses: ./.github/actions/install-playwright - with: - browsers: chromium - cwd: dev-packages/e2e-tests/test-applications/${{ matrix.test-application }} - - name: Prepare E2E tests run: pnpm run test:prepare working-directory: dev-packages/e2e-tests @@ -1276,6 +1291,17 @@ jobs: }}/dev-packages/e2e-tests/packed working-directory: dev-packages/e2e-tests + # Installs Playwright while the app builds. Keep it right before the build: the runner sets up a background + # composite action's steps from the job's shared `github` context, which fails with "Collection was modified" if + # a foreground step starts at that moment. Starting next to `download-artifact` also made it fail to read the event file. + - name: Install Playwright + id: install-playwright + background: true + uses: ./.github/actions/install-playwright + with: + browsers: chromium + cwd: dev-packages/e2e-tests/test-applications/${{ matrix.test-application }} + - name: Build E2E app working-directory: ${{ runner.temp }}/test-application timeout-minutes: 7 @@ -1317,7 +1343,9 @@ jobs: ] # Always run this, even if a dependent job failed if: always() - runs-on: ubuntu-24.04 + runs-on: + namespace-profile-sentry-javascript-small;overrides.cache-tag=sentry-javascript${{ github.event_name == + 'pull_request' && '-pr' || '' }} permissions: {} steps: - name: Check for failures diff --git a/.github/workflows/bump-size-limits.yml b/.github/workflows/bump-size-limits.yml index e6ee2195ab4b..0e4c3d80d7b7 100644 --- a/.github/workflows/bump-size-limits.yml +++ b/.github/workflows/bump-size-limits.yml @@ -10,13 +10,6 @@ permissions: pull-requests: write issues: write -env: - CACHED_DEPENDENCY_PATHS: | - ${{ github.workspace }}/node_modules - ${{ github.workspace }}/packages/*/node_modules - ${{ github.workspace }}/dev-packages/*/node_modules - ~/.cache/mongodb-binaries/ - concurrency: group: bump-size-limits cancel-in-progress: false @@ -24,7 +17,7 @@ concurrency: jobs: bump: name: Bump size-limit thresholds - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-large timeout-minutes: 25 steps: - name: Generate GitHub App token diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 3db23de94844..45d9191abae5 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -28,7 +28,7 @@ permissions: jobs: job_e2e_prepare: name: Prepare E2E Canary tests - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-large timeout-minutes: 30 steps: - name: Check out current commit @@ -57,7 +57,7 @@ jobs: job_e2e_tests: name: E2E ${{ matrix.label }} Test needs: [job_e2e_prepare] - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript timeout-minutes: 20 env: # We just use a dummy DSN here, only send to the tunnel anyhow diff --git a/.github/workflows/ci-metadata.yml b/.github/workflows/ci-metadata.yml index fde346820f10..ed114f394a19 100644 --- a/.github/workflows/ci-metadata.yml +++ b/.github/workflows/ci-metadata.yml @@ -38,7 +38,7 @@ on: jobs: get_metadata: name: Get Metadata - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small permissions: pull-requests: read steps: diff --git a/.github/workflows/cleanup-e2e-workers.yml b/.github/workflows/cleanup-e2e-workers.yml index 69aae225e408..917de52e997d 100644 --- a/.github/workflows/cleanup-e2e-workers.yml +++ b/.github/workflows/cleanup-e2e-workers.yml @@ -8,7 +8,7 @@ jobs: cleanup: # The optional E2E job deploys only for PRs from this repository, so forks never have a worker to delete. if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-small permissions: {} timeout-minutes: 5 env: diff --git a/.github/workflows/cleanup-pr-caches.yml b/.github/workflows/cleanup-pr-caches.yml index e4543e524c91..4b7dbb4ee476 100644 --- a/.github/workflows/cleanup-pr-caches.yml +++ b/.github/workflows/cleanup-pr-caches.yml @@ -6,7 +6,7 @@ on: jobs: cleanup: - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-small permissions: # `actions:write` permission is required to delete caches # See also: https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28#delete-a-github-actions-cache-for-a-repository-using-a-cache-id diff --git a/.github/workflows/clear-cache.yml b/.github/workflows/clear-cache.yml index 750b39305d1f..28dba37b8d7c 100644 --- a/.github/workflows/clear-cache.yml +++ b/.github/workflows/clear-cache.yml @@ -21,7 +21,7 @@ on: jobs: clear-caches: name: Delete all caches - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4e41c7f5d145..79b9bb42a677 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,7 +32,7 @@ concurrency: jobs: analyze: name: Analyze - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-large # Skip for pushes from dependabot, which is not supported if: github.event_name == 'pull_request' || github.actor != 'dependabot[bot]' diff --git a/.github/workflows/dependabot-auto-triage.yml b/.github/workflows/dependabot-auto-triage.yml index c0298ccee87d..56ebdfe42f2f 100644 --- a/.github/workflows/dependabot-auto-triage.yml +++ b/.github/workflows/dependabot-auto-triage.yml @@ -35,11 +35,6 @@ env: FIX_CAP_DEV: '3' # ⬇️ debatable but let's start off with this PR_REVIEWERS: 'getsentry/team-javascript-sdks-framework' - CACHED_DEPENDENCY_PATHS: | - ${{ github.workspace }}/node_modules - ${{ github.workspace }}/packages/*/node_modules - ${{ github.workspace }}/dev-packages/*/node_modules - ~/.cache/mongodb-binaries/ concurrency: group: dependabot-auto-triage @@ -48,7 +43,7 @@ concurrency: jobs: classify: name: Classify alerts & auto-dismiss noise - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript timeout-minutes: 20 outputs: runtime_alerts: ${{ steps.select.outputs.runtime_alerts }} @@ -129,7 +124,7 @@ jobs: if: (github.event_name == 'schedule' || github.event.inputs.mode == 'full') && needs.classify.outputs.runtime_alerts != '' - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript timeout-minutes: 40 environment: ci-triage steps: @@ -218,7 +213,7 @@ jobs: needs: classify if: (github.event_name == 'schedule' || github.event.inputs.mode == 'full') && needs.classify.outputs.dev_alerts != '' - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript timeout-minutes: 40 environment: ci-triage steps: diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 5942bdb0c355..e748668b92db 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -19,7 +19,7 @@ on: jobs: enforce-license-compliance: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript steps: - name: 'Enforce License Compliance' uses: getsentry/action-enforce-license-compliance@main diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml index 1b131781dc05..6e3a3c5aff8c 100644 --- a/.github/workflows/external-contributors.yml +++ b/.github/workflows/external-contributors.yml @@ -12,7 +12,7 @@ jobs: permissions: pull-requests: write contents: write - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript if: | github.event.pull_request.merged == true && github.event.pull_request.author_association != 'COLLABORATOR' diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index b95132dbb488..fbde86cf1a0f 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -18,7 +18,7 @@ concurrency: jobs: flaky-detector: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-large timeout-minutes: 60 name: 'Check tests for flakiness' # Also skip if PR is from master -> develop diff --git a/.github/workflows/gitflow-merge-conflict.yml b/.github/workflows/gitflow-merge-conflict.yml index 102d05b6d03b..6195a73350d5 100644 --- a/.github/workflows/gitflow-merge-conflict.yml +++ b/.github/workflows/gitflow-merge-conflict.yml @@ -9,7 +9,7 @@ on: jobs: check-merge-conflicts: name: Detect merge conflicts in gitflow PRs - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small if: | ${{ contains(github.event.pull_request.labels.*.name, 'Dev: Gitflow') }} permissions: diff --git a/.github/workflows/gitflow-sync-develop.yml b/.github/workflows/gitflow-sync-develop.yml index 20f8b335fd0f..21d3c45dc1d0 100644 --- a/.github/workflows/gitflow-sync-develop.yml +++ b/.github/workflows/gitflow-sync-develop.yml @@ -17,7 +17,7 @@ env: jobs: main: name: Create PR master->develop - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small permissions: pull-requests: write contents: write diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index 8ac1c7987801..c855066f318f 100644 --- a/.github/workflows/issue-package-label.yml +++ b/.github/workflows/issue-package-label.yml @@ -7,7 +7,7 @@ on: jobs: add_labels: name: Add package label - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-small if: ${{ !github.event.issue.pull_request }} steps: - name: Get used package from issue body diff --git a/.github/workflows/label-external-prs.yml b/.github/workflows/label-external-prs.yml index e6a73aaae5a7..6caf6fae7387 100644 --- a/.github/workflows/label-external-prs.yml +++ b/.github/workflows/label-external-prs.yml @@ -9,7 +9,7 @@ jobs: name: Add external label permissions: pull-requests: write - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small if: | github.event.pull_request.author_association != 'COLLABORATOR' && github.event.pull_request.author_association != 'MEMBER' diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index baa9f4838f23..1073b0c17b51 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -11,7 +11,7 @@ on: jobs: build-and-upload: name: Bundle and upload Lighthouse cells - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-large timeout-minutes: 30 env: LIGHTHOUSE_LAB_URL: ${{ secrets.LIGHTHOUSE_LAB_URL }} diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml index 457430fc957c..e601f859d2c4 100644 --- a/.github/workflows/pr-review-reminder.yml +++ b/.github/workflows/pr-review-reminder.yml @@ -22,7 +22,7 @@ jobs: remind-reviewers: # `schedule` has no `repository` on github.event; forks must be skipped only for workflow_dispatch. if: github.event_name == 'schedule' || github.event.repository.fork != true - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-small steps: - name: Checkout repository uses: actions/checkout@v7 diff --git a/.github/workflows/pr-stale-notifier.yml b/.github/workflows/pr-stale-notifier.yml index 13bba260a0b0..3c4816dafebe 100644 --- a/.github/workflows/pr-stale-notifier.yml +++ b/.github/workflows/pr-stale-notifier.yml @@ -15,7 +15,7 @@ concurrency: jobs: stale: - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-small steps: - name: Warn and close stale PRs uses: actions/stale@v11 diff --git a/.github/workflows/release-comment-issues.yml b/.github/workflows/release-comment-issues.yml index dfb782b1b6d8..88055af892c8 100644 --- a/.github/workflows/release-comment-issues.yml +++ b/.github/workflows/release-comment-issues.yml @@ -12,7 +12,7 @@ on: # This workflow is triggered when a release is published jobs: release-comment-issues: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small name: 'Notify issues' steps: - name: Get version diff --git a/.github/workflows/release-size-info.yml b/.github/workflows/release-size-info.yml index a1f75303d1ff..591a14b6b553 100644 --- a/.github/workflows/release-size-info.yml +++ b/.github/workflows/release-size-info.yml @@ -13,7 +13,7 @@ on: # It fetches the size-limit info from the release branch and adds it to the release jobs: release-size-info: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small name: 'Add size-limit info to release' steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3924c5bf2a2d..e579c239dcfe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ permissions: jobs: release: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small name: 'Release a new version' steps: - name: Get auth token diff --git a/.github/workflows/track-framework-updates.yml b/.github/workflows/track-framework-updates.yml index 0a74bad0b923..3e0172884e7f 100644 --- a/.github/workflows/track-framework-updates.yml +++ b/.github/workflows/track-framework-updates.yml @@ -18,7 +18,7 @@ concurrency: jobs: track-updates: - runs-on: ubuntu-latest + runs-on: namespace-profile-sentry-javascript-small environment: ci-triage permissions: contents: read diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 9e29641264dc..f3c9f955547f 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -6,7 +6,7 @@ on: jobs: validate-pr: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small permissions: pull-requests: write steps: diff --git a/.github/workflows/weekly-flaky-tests.yml b/.github/workflows/weekly-flaky-tests.yml index 5c99a4b5f93f..bb0e5b3866c9 100644 --- a/.github/workflows/weekly-flaky-tests.yml +++ b/.github/workflows/weekly-flaky-tests.yml @@ -18,7 +18,7 @@ concurrency: jobs: report: name: Weekly flaky test report - runs-on: ubuntu-24.04 + runs-on: namespace-profile-sentry-javascript-small timeout-minutes: 20 steps: - uses: actions/checkout@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 902b8e0173f5..31501d2458b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,51 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **feat(nextjs): Add a tunnel route handler that sends only the event payload to Sentry ([#25456](https://github.com/getsentry/sentry-javascript/pull/25456))** + + The `tunnelRoute` option can now send events through a route handler in your app. The handler forwards only the event payload to Sentry, and no request headers. To use it, add this file next to your `tunnelRoute` option: + + ```ts + // app/sentry-tunnel/route.ts (or src/app/sentry-tunnel/route.ts) + export { POST } from '@sentry/nextjs/tunnel'; + ``` + + The handler accepts events for the DSN of your server SDK. To accept other DSNs, use `createSentryTunnelRoute({ allowedDsns })` from the same import. Without the file, the tunnel route works as before. + + Also, the server no longer sends spans of tunnel requests when span streaming is on, because these spans only show the SDK's own traffic. + +- **feat(nextjs): Send tunnel route requests with `credentials: 'omit'` ([#25509](https://github.com/getsentry/sentry-javascript/pull/25509))** + + With `tunnelRoute` and no tunnel route handler, `@sentry/nextjs` now sends events from the browser with `credentials: 'omit'`, because the tunnel route does not need cookies. + If a layer in front of your app checks cookies before it lets requests through (for example Vercel Deployment Protection), you can send them again in your browser `Sentry.init` call: + + ```js + // instrumentation-client.ts + Sentry.init({ + transportOptions: { fetchOptions: { credentials: 'same-origin' } }, + }); + ``` + +- **feat(nextjs): Never emit trace meta tags from a prerender ([#25141](https://github.com/getsentry/sentry-javascript/pull/25141))** + + Pageloads of apps with `cacheComponents` enabled are connected to their server request again. Pages that Next.js prerenders no longer carry trace information, so a cached page can no longer connect visitors to a request that was not theirs. This also applies to ISR pages. The pageload waits for the trace information when it arrives with the part of the page that streams in after a cached shell. + + If you set up OpenTelemetry yourself and register `SentryPropagator`, register `NextSentryPropagator` instead and pass your tracer provider to `markPrerendersOnTracerProvider`, so that your prerendered pages carry no trace information either. + Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @philipatkinson, @Shubham-Padkonde, @mikamikasuki, @mketiku, and @kyrylolvov. Thank you for your contributions! +- **feat(deno): Add incoming request options to `denoServeIntegration`** + + `denoServeIntegration` now accepts `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`, with the same meaning as in `denoHttpIntegration`. The callbacks receive the web `Request`. + + `ignoreStaticAssets` defaults to `true`, so `Deno.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `denoServeIntegration({ ignoreStaticAssets: false })`. `Deno.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. + +- **feat(deno): Record request sessions for `Deno.serve`** + + `denoServeIntegration` now records a request session for each incoming request, as `denoHttpIntegration` does for `node:http`. This gives release health and crash-free rates for `Deno.serve` apps. Sessions are sent only when a `release` is set. To turn them off, set `denoServeIntegration({ sessions: false })`. `sessionFlushingDelayMS` sets how long the SDK waits before it sends the session aggregate. + ## 11.8.0 ### Important Changes diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts new file mode 100644 index 000000000000..45173ed825e8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts @@ -0,0 +1,32 @@ +import { wrapRequestHandler } from '@sentry/cloudflare/request'; + +interface Env { + SENTRY_DSN: string; +} + +export default { + async fetch(request, env, ctx) { + const contentType = new URL(request.url).searchParams.get('content-type') ?? 'text/x-component'; + + return wrapRequestHandler( + { + options: { + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + }, + request, + context: ctx, + }, + () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('
shell
')); + controller.close(); + }, + }); + + return new Response(body, { headers: { 'content-type': contentType } }); + }, + ); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts new file mode 100644 index 000000000000..b675a9ce8714 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; + +it.for(['text/x-component;charset=utf-8', 'text/event-stream'])( + 'sends the http.server span of a streamed %s response when TransformStream ignores the transformer', + async (contentType, { signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + }) + .start(signal); + + const body = await runner.makeRequest('get', `/?content-type=${encodeURIComponent(contentType)}`); + expect(body).toBe('
shell
'); + + await runner.completed(); + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc new file mode 100644 index 000000000000..10f17305c6f2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "name": "worker-name", + // With this date and only this flag, `new TransformStream()` ignores the transformer. Mini-oxygen + // releases before 4.0.0 run Workers with this setup. + "compatibility_date": "2022-10-31", + "compatibility_flags": ["streams_enable_constructors"], + "main": "index.ts", +} diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts new file mode 100644 index 000000000000..774290a56061 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts @@ -0,0 +1,19 @@ +interface Env { + SENTRY_DSN: string; +} + +const TEXT = 'precompressed'; + +export default { + async fetch(): Promise { + const gzipped = await new Response( + new Blob([TEXT]).stream().pipeThrough(new CompressionStream('gzip')), + ).arrayBuffer(); + + // `text/plain` without a Content-Length is classified as streaming, so the SDK replaces the response. + return new Response(gzipped, { + encodeBody: 'manual', + headers: { 'content-type': 'text/plain;charset=utf-8', 'content-encoding': 'gzip' }, + }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts new file mode 100644 index 000000000000..dd1ba461f2d6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; + +it('does not compress a response with `encodeBody: manual` a second time', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/' }); + }) + .start(signal); + + const body = await runner.makeRequest('get', '/', { headers: { 'accept-encoding': 'gzip' } }); + expect(body).toBe('precompressed'); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc new file mode 100644 index 000000000000..ffc67decd4d2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-request-handler-precompressed-response", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts new file mode 100644 index 000000000000..f983d2cd0faf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts @@ -0,0 +1,43 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +const SHELL = '
shell
'; +const SUSPENDED = '
suspended
'; + +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const contentType = url.searchParams.get('content-type'); + if (!contentType) { + return new Response('Missing content-type', { status: 400 }); + } + + if (url.searchParams.get('body') === 'stream') { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(SHELL)); + // Waits for a later task, so the suspended part renders after the handler has returned. + await new Promise(resolve => setTimeout(resolve, 0)); + Sentry.startSpan({ name: 'render' }, () => { + controller.enqueue(encoder.encode(SUSPENDED)); + }); + controller.close(); + }, + }); + + return new Response(body, { headers: { 'content-type': contentType } }); + } + + const html = Sentry.startSpan({ name: 'render' }, () => SHELL + SUSPENDED); + const headers = new Headers({ 'content-type': contentType }); + if (url.searchParams.get('body') === 'string-with-content-length') { + headers.set('content-length', String(new TextEncoder().encode(html).byteLength)); + } + + return new Response(html, { headers }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts new file mode 100644 index 000000000000..f4c87d6d4b28 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts @@ -0,0 +1,7 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + isStreamingResponse: response => (response.headers.get('content-type')?.startsWith('text/html') ? true : undefined), +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts new file mode 100644 index 000000000000..9bc187460cbe --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts @@ -0,0 +1,35 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp } from '../../../spanUtils'; + +it.for([ + { body: 'stream', contentType: 'text/html;charset=utf-8' }, + { body: 'stream', contentType: 'text/x-component;charset=utf-8' }, + { body: 'string', contentType: 'text/html;charset=utf-8' }, + { body: 'string', contentType: 'text/x-component;charset=utf-8' }, + { body: 'string-with-content-length', contentType: 'text/html;charset=utf-8' }, + { body: 'string-with-content-length', contentType: 'text/x-component;charset=utf-8' }, +])( + 'nests the render span in the http.server span for a $contentType response with a $body body', + async ({ body, contentType }, { signal }) => { + const runner = createRunner(__dirname).start(signal); + + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.some(span => span.is_segment) && spansOfTrace.some(span => span.name === 'render'), + ); + + const responseBody = await runner.makeRequest( + 'get', + `/?body=${body}&content-type=${encodeURIComponent(contentType)}`, + ); + expect(responseBody).toBe('
shell
suspended
'); + + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment); + const renderSpan = spans.find(span => span.name === 'render'); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(renderSpan?.parent_span_id).toBe(segmentSpan?.span_id); + expect(segmentSpan!.end_timestamp).toBeGreaterThanOrEqual(renderSpan!.end_timestamp); + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc new file mode 100644 index 000000000000..d41141882a2c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-request-handler-streamed-response", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts index 38b56136ab20..791d9aa83c20 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts @@ -7,15 +7,16 @@ export class ScheduleService { constructor(private schedulerRegistry: SchedulerRegistry) {} // @Cron error test (auto-instrumentation, no @SentryCron) + // The timestamp keeps the Dedupe integration from dropping the error on repeated ticks. @Cron('*/5 * * * * *', { name: 'test-schedule-cron-error' }) handleCronError() { - throw new Error('Test error from schedule cron'); + throw new Error(`Test error from schedule cron ${Date.now()}`); } // @Interval error test @Interval('test-schedule-interval-error', 2000) async handleIntervalError() { - throw new Error('Test error from schedule interval'); + throw new Error(`Test error from schedule interval ${Date.now()}`); } // @Timeout error test diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts index aa8a90c66cd5..b3f5a416f090 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts @@ -2,10 +2,12 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends exceptions to Sentry on error in @Cron decorated method', async ({ baseURL }) => { + const testStart = Date.now(); const errorEventPromise = waitForError('nestjs-basic', event => { + const thrownAt = event.exception?.values?.[0]?.value?.match(/^Test error from schedule cron (\d+)$/)?.[1]; return ( !event.type && - event.exception?.values?.[0]?.value === 'Test error from schedule cron' && + Number(thrownAt) >= testStart && event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.cron' ); }); @@ -23,10 +25,12 @@ test('Sends exceptions to Sentry on error in @Cron decorated method', async ({ b }); test('Sends exceptions to Sentry on error in @Interval decorated method', async ({ baseURL }) => { + const testStart = Date.now(); const errorEventPromise = waitForError('nestjs-basic', event => { + const thrownAt = event.exception?.values?.[0]?.value?.match(/^Test error from schedule interval (\d+)$/)?.[1]; return ( !event.type && - event.exception?.values?.[0]?.value === 'Test error from schedule interval' && + Number(thrownAt) >= testStart && event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.interval' ); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts index 705b9208675e..8379d544a78e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts @@ -1,50 +1,57 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => { - // Navigate to ISR page - await page.goto('/isr-test/laptop'); +const isProductionBuild = process.env.TEST_ENV === 'production' || process.env.TEST_ENV === 'prod-turbopack'; - // Wait for page to be fully loaded - await expect(page.locator('#isr-product-id')).toHaveText('laptop'); +test.describe('prerendered ISR pages', () => { + // The dev server renders ISR pages for every request, so there the trace tags are fresh and kept. + test.skip(!isProductionBuild, 'ISR pages are only prerendered in production builds'); - // Check that sentry-trace and baggage meta tags are removed for ISR pages - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); -}); + test('does not carry trace meta tags on an ISR dynamic route', async ({ page }) => { + // Navigate to ISR page + await page.goto('/isr-test/laptop'); -test('should remove sentry-trace and baggage meta tags on ISR static route', async ({ page }) => { - // Navigate to ISR static page - await page.goto('/isr-test/static'); + // Wait for page to be fully loaded + await expect(page.locator('#isr-product-id')).toHaveText('laptop'); - // Wait for page to be fully loaded - await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); + // The shell was prerendered, and a prerender writes no trace tags + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); - // Check that sentry-trace and baggage meta tags are removed for ISR pages - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); -}); + test('does not carry trace meta tags on an ISR static route', async ({ page }) => { + // Navigate to ISR static page + await page.goto('/isr-test/static'); -test('should remove meta tags for different ISR dynamic route values', async ({ page }) => { - // Test with 'phone' (one of the pre-generated static params) - await page.goto('/isr-test/phone'); - await expect(page.locator('#isr-product-id')).toHaveText('phone'); + // Wait for page to be fully loaded + await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + // The shell was prerendered, and a prerender writes no trace tags + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); + + test('does not carry trace meta tags for different ISR dynamic route values', async ({ page }) => { + // Test with 'phone' (one of the pre-generated static params) + await page.goto('/isr-test/phone'); + await expect(page.locator('#isr-product-id')).toHaveText('phone'); - // Test with 'tablet' - await page.goto('/isr-test/tablet'); - await expect(page.locator('#isr-product-id')).toHaveText('tablet'); + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + // Test with 'tablet' + await page.goto('/isr-test/tablet'); + await expect(page.locator('#isr-product-id')).toHaveText('tablet'); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); }); test('should create unique traces for ISR pages on each visit', async ({ page }) => { const traceIds: string[] = []; - // Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed + // Load the same ISR page 5 times: the cached HTML carries no trace tag, so every visit starts its own trace for (let i = 0; i < 5; i++) { const spanPromise = waitForStreamedSpan('nextjs-15', span => { return span.name === '/isr-test/:product' && getSpanOp(span) === 'pageload' && span.is_segment; @@ -64,7 +71,7 @@ test('should create unique traces for ISR pages on each visit', async ({ page }) traceIds.push(traceId!); } - // Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags) + // Verify all 5 page loads have unique trace IDs (no trace replayed from the cached HTML) const uniqueTraceIds = new Set(traceIds); expect(uniqueTraceIds.size).toBe(5); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/blocking/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/blocking/page.tsx new file mode 100644 index 000000000000..babed4b8590c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/blocking/page.tsx @@ -0,0 +1,10 @@ +import { headers } from 'next/headers'; + +// Opts out of the static shell: the whole page renders on the server for every request, so the +// trace meta tags land in like in a classic dynamic render. +export const instant = false; + +export default async function Page() { + const requestHeaders = await headers(); + return

Blocking render for {requestHeaders.get('user-agent') ? 'a browser' : 'nobody'}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/static-shell/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/static-shell/page.tsx new file mode 100644 index 000000000000..3ebb9903b2e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/static-shell/page.tsx @@ -0,0 +1,4 @@ +// No request data anywhere, so the whole page is a static shell and nothing renders per request. +export default function Page() { + return

Static shell

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts index 3cb2472c0927..29c90f5bc423 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts @@ -81,38 +81,6 @@ test('Should generate metadata async', async ({ page }) => { await expect(page).toHaveTitle('Product: 1'); }); -test('Prerendered shell does not stitch the pageload onto a stale trace', async ({ page }) => { - const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /pageload-tracing' - ); - }); - - const pageloadTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/pageload-tracing'; - }); - - await page.goto('/pageload-tracing'); - - await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); - - const [serverTx, pageloadTx] = await Promise.all([serverTxPromise, pageloadTxPromise]); - - const serverTraceId = serverTx.contexts?.trace?.trace_id; - const pageloadTraceId = pageloadTx.contexts?.trace?.trace_id; - - // Under Cache Components the shell is prerendered and rendered in a context detached from the - // runtime server request, so a `sentry-trace` meta tag would carry a stale/unrelated trace. The - // SDK therefore does not enable the trace meta tags, and the browser pageload starts a fresh trace - // instead of stitching onto a trace that doesn't match the server request. - expect(pageloadTraceId).toBeTruthy(); - expect(serverTraceId).not.toBe(pageloadTraceId); - - // No trace meta tags should be injected when Cache Components is enabled. - expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); - expect(await page.locator('meta[name="baggage"]').count()).toBe(0); -}); - test('Should prerender a page that captures an exception in generateMetadata', async ({ page }) => { await page.goto('/capture-metadata'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts index c182106f5d10..900ce43679e9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts @@ -1,20 +1,24 @@ +import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -// TODO: Remove the inverse assertion in cacheComponents.spec.ts ("Prerendered shell does not stitch the pageload onto a stale trace") when this test passes. -test('connects the pageload trace to the runtime server request', async ({ page }) => { - test.fail(); +const APP = 'nextjs-16-cacheComponents'; + +// The server only writes `sentry-trace` meta tags for a render that happened for the current request, +// never while it prerenders a shell. So a tag in the document can always be continued; the only +// question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started, +// which the pageload instrumentation waits for. +test('connects the pageload trace to the runtime server request', async ({ page }) => { // The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`) // runs on the server on every request and produces the `GET /pageload-tracing` transaction. - // A fully prerendered page would produce no server transaction. - const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + const serverTxPromise = waitForTransaction(APP, transactionEvent => { return ( transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /pageload-tracing' ); }); - const pageloadTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/pageload-tracing'; }); @@ -26,4 +30,83 @@ test('connects the pageload trace to the runtime server request', async ({ page expect(serverTx.contexts?.trace?.trace_id).toBeTruthy(); expect(pageloadTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id); + expect(pageloadTx.contexts?.trace?.links).toBeUndefined(); +}); + +test('connects the pageload trace to a blocking render', async ({ page }) => { + const serverTxPromise = waitForTransaction(APP, transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /blocking'; + }); + + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/blocking'; + }); + + await page.goto('/blocking'); + + await expect(page.locator('#blocking')).toHaveText('Blocking render for a browser'); + + const [serverTx, pageloadTx] = await Promise.all([serverTxPromise, pageloadTxPromise]); + + // The whole page renders per request, so the tag in is this request's and is continued. + expect(serverTx.contexts?.trace?.trace_id).toBeTruthy(); + expect(pageloadTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id); + expect(pageloadTx.contexts?.trace?.links).toBeUndefined(); +}); + +test('does not share a trace between visitors of a shell generated at request time', async ({ browser }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds'); + + // `[id]` only lists `test` in `generateStaticParams`, so the first visit prerenders and caches the + // shell for this id. The prerender writes no trace tag, so every visitor starts a fresh trace. + const id = `shell-${Date.now()}`; + + const visit = async (): Promise<{ traceId: string | undefined; links: unknown }> => { + const context = await browser.newContext(); + const page: Page = await context.newPage(); + + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.contexts?.trace?.data?.['url.path'] === `/${id}` + ); + }); + + await page.goto(`/${id}`); + await expect(page.locator('#result')).toHaveText(`Hello, ${id}!`); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + + const pageloadTx = await pageloadTxPromise; + await context.close(); + + return { traceId: pageloadTx.contexts?.trace?.trace_id, links: pageloadTx.contexts?.trace?.links }; + }; + + const first = await visit(); + const second = await visit(); + + expect(first.traceId).toBeTruthy(); + expect(second.traceId).toBeTruthy(); + expect(second.traceId).not.toBe(first.traceId); + expect(first.links).toBeUndefined(); + expect(second.links).toBeUndefined(); +}); + +test('starts a fresh trace on a fully static page', async ({ page }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Pages are only prerendered in production builds'); + + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/static-shell'; + }); + + await page.goto('/static-shell'); + + await expect(page.locator('#static-shell')).toHaveText('Static shell'); + + const pageloadTx = await pageloadTxPromise; + + // A build-time shell carries no trace context at all. + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + expect(pageloadTx.contexts?.trace?.trace_id).toBeTruthy(); + expect(pageloadTx.contexts?.trace?.links).toBeUndefined(); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isr-routes.test.ts index 4fde597dfeaa..5d363110424d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isr-routes.test.ts @@ -1,50 +1,57 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => { - // Navigate to ISR page - await page.goto('/isr-test/laptop'); +const isProductionBuild = process.env.TEST_ENV === 'production' || process.env.TEST_ENV === 'prod-turbopack'; - // Wait for page to be fully loaded - await expect(page.locator('#isr-product-id')).toHaveText('laptop'); +test.describe('prerendered ISR pages', () => { + // The dev server renders ISR pages for every request, so there the trace tags are fresh and kept. + test.skip(!isProductionBuild, 'ISR pages are only prerendered in production builds'); - // Check that sentry-trace and baggage meta tags are removed for ISR pages - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); -}); + test('does not carry trace meta tags on an ISR dynamic route', async ({ page }) => { + // Navigate to ISR page + await page.goto('/isr-test/laptop'); -test('should remove sentry-trace and baggage meta tags on ISR static route', async ({ page }) => { - // Navigate to ISR static page - await page.goto('/isr-test/static'); + // Wait for page to be fully loaded + await expect(page.locator('#isr-product-id')).toHaveText('laptop'); - // Wait for page to be fully loaded - await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); + // The shell was prerendered, and a prerender writes no trace tags + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); - // Check that sentry-trace and baggage meta tags are removed for ISR pages - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); -}); + test('does not carry trace meta tags on an ISR static route', async ({ page }) => { + // Navigate to ISR static page + await page.goto('/isr-test/static'); -test('should remove meta tags for different ISR dynamic route values', async ({ page }) => { - // Test with 'phone' (one of the pre-generated static params) - await page.goto('/isr-test/phone'); - await expect(page.locator('#isr-product-id')).toHaveText('phone'); + // Wait for page to be fully loaded + await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + // The shell was prerendered, and a prerender writes no trace tags + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); + + test('does not carry trace meta tags for different ISR dynamic route values', async ({ page }) => { + // Test with 'phone' (one of the pre-generated static params) + await page.goto('/isr-test/phone'); + await expect(page.locator('#isr-product-id')).toHaveText('phone'); - // Test with 'tablet' - await page.goto('/isr-test/tablet'); - await expect(page.locator('#isr-product-id')).toHaveText('tablet'); + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + // Test with 'tablet' + await page.goto('/isr-test/tablet'); + await expect(page.locator('#isr-product-id')).toHaveText('tablet'); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); }); test('should create unique transactions for ISR pages on each visit', async ({ page }) => { const traceIds: string[] = []; - // Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed + // Load the same ISR page 5 times: the cached HTML carries no trace tag, so every visit starts its own trace for (let i = 0; i < 5; i++) { const transactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => { return !!( @@ -66,7 +73,7 @@ test('should create unique transactions for ISR pages on each visit', async ({ p traceIds.push(traceId!); } - // Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags) + // Verify all 5 page loads have unique trace IDs (no trace replayed from the cached HTML) const uniqueTraceIds = new Set(traceIds); expect(uniqueTraceIds.size).toBe(5); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/blocking/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/blocking/page.tsx new file mode 100644 index 000000000000..babed4b8590c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/blocking/page.tsx @@ -0,0 +1,10 @@ +import { headers } from 'next/headers'; + +// Opts out of the static shell: the whole page renders on the server for every request, so the +// trace meta tags land in like in a classic dynamic render. +export const instant = false; + +export default async function Page() { + const requestHeaders = await headers(); + return

Blocking render for {requestHeaders.get('user-agent') ? 'a browser' : 'nobody'}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/slow-hole/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/slow-hole/page.tsx new file mode 100644 index 000000000000..201e4102cb0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/slow-hole/page.tsx @@ -0,0 +1,24 @@ +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { Suspense } from 'react'; + +// The dynamic part, and with it the trace meta tag, streams in seconds after the shell, so a test +// can navigate while the pageload still waits for the tag. +async function SlowContent() { + await headers(); + await new Promise(resolve => setTimeout(resolve, 4000)); + return

Slow content

; +} + +export default function Page() { + return ( + <> + + Static shell + + Loading...

}> + +
+ + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/static-shell/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/static-shell/page.tsx new file mode 100644 index 000000000000..3ebb9903b2e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/static-shell/page.tsx @@ -0,0 +1,4 @@ +// No request data anywhere, so the whole page is a static shell and nothing renders per request. +export default function Page() { + return

Static shell

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts index 49f2f49cb5b2..35a5c82080f5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpans } from '@sentry-internal/test-utils'; test('Should render cached component', async ({ page }) => { const spansPromise = waitForStreamedSpans('nextjs-16-streaming-cacheComponents', spans => { @@ -93,38 +93,6 @@ test('Should generate metadata async', async ({ page }) => { await expect(page).toHaveTitle('Product: 1'); }); -test('Prerendered shell does not stitch the pageload onto a stale trace', async ({ page }) => { - const serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { - return span.name === 'GET /pageload-tracing' && getSpanOp(span) === 'http.server' && span.is_segment; - }); - - const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { - return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment; - }); - - await page.goto('/pageload-tracing'); - - await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); - - const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]); - - expect(pageloadSpan.attributes).toMatchObject({ - ['sentry.segment.name.source']: { value: 'route', type: 'string' }, - ['url.path']: { value: '/pageload-tracing', type: 'string' }, - ['url.template']: { value: '/pageload-tracing', type: 'string' }, - }); - // Under Cache Components the can be prerendered and rendered in a context detached from the - // runtime server request, so a `sentry-trace` meta tag would carry a stale/unrelated trace. The - // SDK therefore does not enable the trace meta tags, and the browser pageload starts a fresh trace - // instead of stitching onto a trace that doesn't match the server request. - expect(pageloadSpan.trace_id).toBeTruthy(); - expect(serverSpan.trace_id).not.toBe(pageloadSpan.trace_id); - - // No trace meta tags should be injected when Cache Components is enabled. - expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); - expect(await page.locator('meta[name="baggage"]').count()).toBe(0); -}); - test('Should prerender a page that captures an exception in generateMetadata', async ({ page }) => { await page.goto('/capture-metadata'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts index efc35fd92c3d..89f9dc32d26c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts @@ -1,18 +1,22 @@ +import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -// TODO: Remove the inverse assertion in cacheComponents.spec.ts ("Prerendered shell does not stitch the pageload onto a stale trace") when this test passes. -test('connects the pageload trace to the runtime server request', async ({ page }) => { - test.fail(); +const APP = 'nextjs-16-streaming-cacheComponents'; + +// The server only writes `sentry-trace` meta tags for a render that happened for the current request, +// never while it prerenders a shell. So a tag in the document can always be continued; the only +// question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started, +// which the pageload instrumentation waits for. +test('connects the pageload trace to the runtime server request', async ({ page }) => { // The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`) // runs on the server on every request and produces the `GET /pageload-tracing` root span. - // A fully prerendered page would produce no server span. - const serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { + const serverSpanPromise = waitForStreamedSpan(APP, span => { return span.name === 'GET /pageload-tracing' && getSpanOp(span) === 'http.server' && span.is_segment; }); - const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment; }); @@ -24,4 +28,111 @@ test('connects the pageload trace to the runtime server request', async ({ page expect(serverSpan.trace_id).toBeTruthy(); expect(pageloadSpan.trace_id).toBe(serverSpan.trace_id); + expect(pageloadSpan.links).toBeUndefined(); +}); + +test('connects the pageload trace to a blocking render', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan(APP, span => { + return span.name === 'GET /blocking' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { + return span.name === '/blocking' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/blocking'); + + await expect(page.locator('#blocking')).toHaveText('Blocking render for a browser'); + + const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]); + + // The whole page renders per request, so the tag in is this request's and is continued. + expect(serverSpan.trace_id).toBeTruthy(); + expect(pageloadSpan.trace_id).toBe(serverSpan.trace_id); + expect(pageloadSpan.links).toBeUndefined(); +}); + +test('does not share a trace between visitors of a shell generated at request time', async ({ browser }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds'); + + // `[id]` only lists `test` in `generateStaticParams`, so the first visit prerenders and caches the + // shell for this id. The prerender writes no trace tag, so every visitor starts a fresh trace. + const id = `shell-${Date.now()}`; + + const visit = async (): Promise<{ traceId: string | undefined; links: unknown }> => { + const context = await browser.newContext(); + const page: Page = await context.newPage(); + + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.attributes['url.path']?.value === `/${id}`; + }); + + await page.goto(`/${id}`); + await expect(page.locator('#result')).toHaveText(`Hello, ${id}!`); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + + const pageloadSpan = await pageloadSpanPromise; + await context.close(); + + return { traceId: pageloadSpan.trace_id, links: pageloadSpan.links }; + }; + + const first = await visit(); + const second = await visit(); + + expect(first.traceId).toBeTruthy(); + expect(second.traceId).toBeTruthy(); + expect(second.traceId).not.toBe(first.traceId); + expect(first.links).toBeUndefined(); + expect(second.links).toBeUndefined(); +}); + +test('starts a fresh trace on a fully static page', async ({ page }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Pages are only prerendered in production builds'); + + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { + return span.name === '/static-shell' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/static-shell'); + + await expect(page.locator('#static-shell')).toHaveText('Static shell'); + + const pageloadSpan = await pageloadSpanPromise; + + // A build-time shell carries no trace context at all. + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + expect(pageloadSpan.trace_id).toBeTruthy(); + expect(pageloadSpan.links).toBeUndefined(); +}); + +test('keeps a navigation that starts while the pageload waits for the trace meta tag', async ({ page }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Only a prerendered shell makes the trace meta tag arrive late'); + + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { + return span.name === '/slow-hole' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan(APP, span => { + return span.name === '/static-shell' && getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/slow-hole', { waitUntil: 'commit' }); + await page.waitForFunction(() => !!(window as { next?: { router?: unknown } }).next?.router); + + // The resumed part, which carries the tag, has not arrived yet, so the pageload is still waiting. + await expect(page.locator('#slow-fallback')).toBeVisible(); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + + await page.locator('#navigate').click(); + await expect(page.locator('#static-shell')).toHaveText('Static shell'); + + const [pageloadSpan, navigationSpan] = await Promise.all([pageloadSpanPromise, navigationSpanPromise]); + + // The navigation ends the pageload like any pageload it interrupts, and runs to its own idle timeout. + expect(pageloadSpan.attributes['sentry.idle_span_finish_reason']).toEqual({ type: 'string', value: 'cancelled' }); + expect(navigationSpan.attributes['sentry.idle_span_finish_reason']).toEqual({ + type: 'string', + value: 'idleTimeout', + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore index dd146b53d966..15bc7628b511 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore @@ -10,6 +10,9 @@ !.yarn/releases !.yarn/versions +# Copied by `test:build-handler*` +/app/sentry-tunnel/ + # testing /coverage diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/api/flush/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/api/flush/route.ts new file mode 100644 index 000000000000..1173242c5d10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/api/flush/route.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/nextjs'; + +// Sends all buffered spans of the server, so a test can wait for them. +export async function GET(): Promise { + await Sentry.flush(); + return new Response(null, { status: 204 }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts index 6709e1cf54a7..84b61b956e4a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nextjs'; +import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + dsn: SENTRY_DSN, // No tunnel option - using tunnelRoute from withSentryConfig tracesSampleRate: 1.0, }); 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 8f63d6f08a43..adbc49ec455c 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 @@ -15,6 +15,8 @@ "test:dev-webpack": "TEST_ENV=development-webpack _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test", "test:build": "pnpm install && pnpm build", "test:build-webpack": "pnpm install && pnpm build-webpack", + "test:build-handler": "pnpm install && cp -R tunnel-handler/node/sentry-tunnel app/ && pnpm build", + "test:build-handler-webpack": "pnpm install && cp -R tunnel-handler/edge/sentry-tunnel app/ && pnpm build-webpack", "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build", "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", @@ -39,6 +41,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@sentry/conventions": "0.27.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -61,6 +64,16 @@ "build-command": "pnpm test:build", "label": "nextjs-16-tunnel (turbopack)", "assert-command": "pnpm test:assert" + }, + { + "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler", + "label": "nextjs-16-tunnel (handler, turbopack)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert" + }, + { + "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler-webpack", + "label": "nextjs-16-tunnel (handler, webpack, edge)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert-webpack" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts new file mode 100644 index 000000000000..fa0ad467f9bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts @@ -0,0 +1,6 @@ +// The handler variants send the server events to the event proxy too, so the tests also see server spans. +export const SENTRY_DSN = + process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler' + ? 'http://public@localhost:3031/1337' + : // A fake but properly formatted Sentry SaaS DSN, because the rewrite mode tunnels only SaaS DSNs + 'https://public@o12345.ingest.us.sentry.io/67890'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts index 4184d564044f..0333aa29412b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nextjs'; +import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + dsn: SENTRY_DSN, // No tunnel option - using tunnelRoute from withSentryConfig tracesSampleRate: 1.0, // debug: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts index 4184d564044f..0333aa29412b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nextjs'; +import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + dsn: SENTRY_DSN, // No tunnel option - using tunnelRoute from withSentryConfig tracesSampleRate: 1.0, // debug: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts index 3d44c8fc8108..e0c1b5e273ae 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; -import type { SerializedStreamedSpan } from '@sentry/core'; +import { HTTP_REQUEST_METHOD, SERVER_ADDRESS, URL_FULL } from '@sentry/conventions/attributes'; +import { getSpanOp, waitForRequest, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import type { Event, SerializedStreamedSpan } from '@sentry/core'; function getStringAttribute(span: SerializedStreamedSpan, key: string): string | undefined { const attribute = span.attributes[key]; @@ -106,7 +107,7 @@ test('Tunnel requests should not create middleware or fetch spans', async ({ pag // We WILL have a middleware span for GET / (the pageload) // But we should NOT have middleware spans for POST requests (tunnel route) const postMiddlewareSpans = middlewareSpans.filter( - span => span.name.includes('POST') || getStringAttribute(span, 'http.request.method') === 'POST', + span => span.name.includes('POST') || getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST', ); expect(postMiddlewareSpans).toHaveLength(0); @@ -114,9 +115,112 @@ test('Tunnel requests should not create middleware or fetch spans', async ({ pag // We should NOT have any fetch spans to Sentry ingest. Matched on the host attribute rather than a // substring of the full URL, which would also match an arbitrary host with `sentry.io` elsewhere in it. const sentryFetchSpans = allSpans.filter(span => { - const host = getStringAttribute(span, 'server.address') ?? ''; + const host = getStringAttribute(span, SERVER_ADDRESS) ?? ''; return getSpanOp(span) === 'http.client' && (host === 'sentry.io' || host.endsWith('.sentry.io')); }); expect(sentryFetchSpans).toHaveLength(0); }); + +test('Tunnel requests are sent without the cookies of the browser', async ({ page, context, baseURL }) => { + test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler', 'The handler variants send the cookies'); + await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]); + const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) => + envelope[1].some( + ([itemHeader, item]) => + itemHeader.type === 'event' && (item as Event).exception?.values?.[0]?.value === 'Tunnel cookie test', + ), + ); + + await page.goto('/'); + await page.waitForFunction(() => typeof (window as { __SENTRY__?: unknown }).__SENTRY__ === 'object'); + const tunnelRequestPromise = page.waitForRequest( + request => + request.method() === 'POST' && + new URL(request.url()).origin === baseURL && + (request.postData() ?? '').includes('Tunnel cookie test'), + ); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Tunnel cookie test'); + }); + }); + + const tunnelRequest = await tunnelRequestPromise; + const proxyRequest = await proxyRequestPromise; + + expect(await page.evaluate(() => document.cookie)).toBe('session=8f2c1d'); + expect((await tunnelRequest.allHeaders()).cookie).toBeUndefined(); + expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); +}); + +test.describe('Tunnel route handler', () => { + test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'handler', 'Only the handler variants use the handler'); + + test('forwards events without the cookies of the browser', async ({ page, context, baseURL }) => { + await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]); + const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) => + envelope[1].some( + ([itemHeader, item]) => + itemHeader.type === 'event' && + (item as Event).exception?.values?.[0]?.value === 'Tunnel route handler cookie test', + ), + ); + + await page.goto('/'); + await page.waitForFunction(() => typeof (window as { __SENTRY__?: unknown }).__SENTRY__ === 'object'); + const tunnelRequestPromise = page.waitForRequest( + request => + request.method() === 'POST' && + new URL(request.url()).origin === baseURL && + (request.postData() ?? '').includes('Tunnel route handler cookie test'), + ); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Tunnel route handler cookie test'); + }); + }); + + const tunnelRequest = await tunnelRequestPromise; + const proxyRequest = await proxyRequestPromise; + + expect(new URL(tunnelRequest.url()).search).toBe(''); + expect((await tunnelRequest.allHeaders()).cookie).toBe('session=8f2c1d'); + expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); + expect(proxyRequest.rawProxyRequestHeaders['content-type']).toBe('application/x-sentry-envelope'); + }); + + test('does not create server spans for tunnel requests', async ({ page, baseURL }) => { + // One listener, so it gets every span that the proxy received before the barrier span. + const spansBeforeBarrier: SerializedStreamedSpan[] = []; + const barrierSpanPromise = waitForStreamedSpans('nextjs-16-tunnel', spans => { + spansBeforeBarrier.push(...spans); + return spans.some(span => span.is_segment && span.name === 'GET /api/flush'); + }); + const pageloadTunnelResponsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + new URL(response.url()).origin === baseURL && + (response.request().postData() ?? '').includes('pageload'), + ); + + await page.goto('/'); + await pageloadTunnelResponsePromise; + // Flush the edge client of the edge tunnel route (a 405 on the Node.js one), then the Node.js client twice: + // the first flush sends the tunnel spans, the second one the barrier span of the first flush. + await page.request.get('/sentry-tunnel'); + await page.request.get('/api/flush'); + await page.request.get('/api/flush'); + await barrierSpanPromise; + + const postSpans = spansBeforeBarrier.filter( + span => span.name.startsWith('POST') || getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST', + ); + const ingestSpans = spansBeforeBarrier.filter( + span => + getSpanOp(span) === 'http.client' && !!getStringAttribute(span, URL_FULL)?.startsWith('http://localhost:3031/'), + ); + expect(postSpans).toEqual([]); + expect(ingestSpans).toEqual([]); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts new file mode 100644 index 000000000000..4eeb13efd97f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; +import { createSentryTunnelRoute } from '@sentry/nextjs/tunnel'; +import { SENTRY_DSN } from '@/sentry-dsn'; + +export const { POST } = createSentryTunnelRoute({ allowedDsns: [SENTRY_DSN] }); + +// Each edge route has its own SDK client, so the tests flush this one here. +export async function GET(): Promise { + await Sentry.flush(); + return new Response(null, { status: 204 }); +} + +export const runtime = 'edge'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts new file mode 100644 index 000000000000..23c975fb750d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts @@ -0,0 +1 @@ +export { POST } from '@sentry/nextjs/tunnel'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/runtime/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/runtime/page.tsx new file mode 100644 index 000000000000..097ccb54cc68 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/runtime/page.tsx @@ -0,0 +1,11 @@ +// A short revalidation window, so a test can make the server prerender this page again at runtime. +export const revalidate = 1; + +export default function ISRRuntimePage() { + return ( +
+

ISR Runtime Page

+
{Date.now()}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts index ce215ef457e9..5a210e8426c6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts @@ -1,50 +1,83 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => { - // Navigate to ISR page - await page.goto('/isr-test/laptop'); - - // Wait for page to be fully loaded - await expect(page.locator('#isr-product-id')).toHaveText('laptop'); +const isProductionBuild = process.env.TEST_ENV === 'production' || process.env.TEST_ENV === 'prod-turbopack'; + +test.describe('prerendered ISR pages', () => { + // The dev server and vinext render ISR pages for every request, so there the trace tags are fresh + // and kept. + test.skip( + !isProductionBuild || !!process.env.VINEXT, + 'ISR pages are only prerendered by a production build of Next.js', + ); + + test('does not carry trace meta tags on an ISR dynamic route', async ({ page }) => { + // Navigate to ISR page + await page.goto('/isr-test/laptop'); + + // Wait for page to be fully loaded + await expect(page.locator('#isr-product-id')).toHaveText('laptop'); + + // The shell was prerendered, and a prerender writes no trace tags + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); - // Check that sentry-trace and baggage meta tags are removed for ISR pages - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); -}); + test('does not carry trace meta tags on an ISR static route', async ({ page }) => { + // Navigate to ISR static page + await page.goto('/isr-test/static'); -test('should remove sentry-trace and baggage meta tags on ISR static route', async ({ page }) => { - // Navigate to ISR static page - await page.goto('/isr-test/static'); + // Wait for page to be fully loaded + await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); - // Wait for page to be fully loaded - await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); + // The shell was prerendered, and a prerender writes no trace tags + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); - // Check that sentry-trace and baggage meta tags are removed for ISR pages - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); -}); + test('does not carry trace meta tags for different ISR dynamic route values', async ({ page }) => { + // Test with 'phone' (one of the pre-generated static params) + await page.goto('/isr-test/phone'); + await expect(page.locator('#isr-product-id')).toHaveText('phone'); -test('should remove meta tags for different ISR dynamic route values', async ({ page }) => { - // Test with 'phone' (one of the pre-generated static params) - await page.goto('/isr-test/phone'); - await expect(page.locator('#isr-product-id')).toHaveText('phone'); + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + // Test with 'tablet' + await page.goto('/isr-test/tablet'); + await expect(page.locator('#isr-product-id')).toHaveText('tablet'); - // Test with 'tablet' - await page.goto('/isr-test/tablet'); - await expect(page.locator('#isr-product-id')).toHaveText('tablet'); + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); - await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); - await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + test('does not carry trace meta tags on an ISR page that was prerendered again at runtime', async ({ page }) => { + // The first visit serves the shell from the build. Once its revalidation window passed, a visit serves the stale + // shell and makes the server prerender the page again, inside that request, with the SDK running. The visits after + // that serve the shell from that prerender. + await page.goto('/isr-test/runtime'); + const renderedAtBuild = await page.locator('#isr-runtime-rendered-at').textContent(); + + await expect + .poll( + async () => { + await page.waitForTimeout(1500); + await page.goto('/isr-test/runtime'); + return page.locator('#isr-runtime-rendered-at').textContent(); + }, + { timeout: 30_000 }, + ) + .not.toBe(renderedAtBuild); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + }); }); test('should create unique traces for ISR pages on each visit', async ({ page }) => { const traceIds: string[] = []; - // Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed + // Load the same ISR page 5 times: the cached HTML carries no trace tag, so every visit starts its own trace for (let i = 0; i < 5; i++) { const spanPromise = waitForStreamedSpan('nextjs-16', span => { return span.name === '/isr-test/:product' && getSpanOp(span) === 'pageload' && span.is_segment; @@ -64,7 +97,7 @@ test('should create unique traces for ISR pages on each visit', async ({ page }) traceIds.push(traceId!); } - // Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags) + // Verify all 5 page loads have unique trace IDs (no trace replayed from the cached HTML) const uniqueTraceIds = new Set(traceIds); expect(uniqueTraceIds.size).toBe(5); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.client.test.ts index 0bf456015834..106e26ff68a2 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,7 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); - await page.waitForFunction(() => typeof window.__SENTRY__ === 'object'); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -65,6 +67,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts index 90771e4174ad..64726a13c12a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('server-side errors', async () => { test('captures api fetch error (fetched on click)', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -42,6 +44,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.client.test.ts index e9ef67ef75b6..1ed7de457433 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,6 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -64,6 +67,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts index e108a25ea9b1..3edf9e7d8744 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('server-side errors', async () => { test('captures api fetch error (fetched on click)', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -42,6 +44,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.client.test.ts index ff99eb1b192f..a77959867c2b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,6 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -64,6 +67,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts index eb8a9647cdd2..336f3966d94c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('server-side errors', async () => { test('captures api fetch error (fetched on click)', async ({ page }) => { @@ -18,6 +19,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const serverSpan = await serverSpanPromise; @@ -66,6 +68,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); await serverSpanPromise; @@ -82,6 +85,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts index 11e4a5a367d4..fc10459b544b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,6 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#error-in-error-boundary').click(); const error = await errorPromise; @@ -77,6 +80,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -106,6 +110,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts index a360209ec1ce..cdebac5593e5 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('server-side errors', async () => { test('captures api fetch error (fetched on click)', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -42,6 +44,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore index 1dca2550f7e3..e52212a76a51 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore @@ -5,6 +5,7 @@ .nitro .cache dist +.netlify # Node dependencies node_modules diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts index 671ec4526a7a..8a9ee339b1e6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts @@ -24,10 +24,22 @@ const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; /** `nuxt generate` emits no `.output/server`. Keyed on the command so a missing one under `nuxt build` still fails. */ const isStaticBuild = process.env.NUXT_COMMAND === 'generate'; -const CLIENT_OUTPUT = path.join('.output', 'public'); +const nitroPreset = process.env.NITRO_PRESET ?? 'node-server'; + +/** If Nitro moves one of these directories, the debug ID checks below fail. */ +const OUTPUT_DIRS: Record = { + 'node-server': { client: path.join('.output', 'public'), server: path.join('.output', 'server') }, + netlify: { client: 'dist', server: path.join('.netlify', 'functions-internal', 'server') }, + 'cloudflare-pages': { client: 'dist', server: path.join('dist', '_worker.js') }, +}; + +const outputDirs = OUTPUT_DIRS[nitroPreset]; +assert.ok(outputDirs, `Expected output directories for the Nitro preset ${nitroPreset}`); + +const CLIENT_OUTPUT = outputDirs.client; /** Vite's own output directory. Nitro copies it to `CLIENT_OUTPUT`, and Sentry deletes no maps here. */ const CLIENT_BUILD_OUTPUT = path.join('.nuxt', 'dist', 'client'); -const SERVER_OUTPUT = path.join('.output', 'server'); +const SERVER_OUTPUT = outputDirs.server; /** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; @@ -35,6 +47,12 @@ const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; +/** Some presets write the server into the client output, e.g. Cloudflare Pages. */ +function isClientFile(file: string): boolean { + const relativePath = path.relative(SERVER_OUTPUT, file); + return relativePath.startsWith('..') || path.isAbsolute(relativePath); +} + function filesContaining(dir: string, needle: string): string[] { return fs .readdirSync(dir, { recursive: true, withFileTypes: true }) @@ -64,7 +82,7 @@ function debugIdsOfChunksWithoutSourceMap(dir: string): Set { } console.log( - `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + + `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, Nitro preset ${nitroPreset}, ` + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, ); @@ -218,7 +236,7 @@ console.log(''); // --- What the build leaves behind in the client output --- -const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT }); +const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT }).filter(isClientFile); if (keepClientSourceMaps) { assert.ok(clientSourceMaps.length > 0, `Expected Sentry to leave the user-enabled maps in ${CLIENT_OUTPUT}`); @@ -228,20 +246,30 @@ if (keepClientSourceMaps) { assert.deepEqual(clientSourceMaps, [], `Expected no source maps in ${CLIENT_OUTPUT} after upload`); // The maps are gone, so a surviving reference only 404s in devtools and leaks where they were. - const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }); + const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }).filter(isClientFile); assert.deepEqual(danglingReferences, [], `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`); // Catches source maps inlined as `data:` URIs, which the reference check above skips by design. - const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER); + const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER).filter(isClientFile); assert.deepEqual(leakedSource, [], `Expected no original client source under ${CLIENT_OUTPUT}`); console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`); } -// --- What the built server answers for the client source maps --- +// --- What the build leaves behind in the server output --- -// `nuxt generate` emits no server, and a static host serves only the files that exist. +// Nuxt enables the server maps by default, so they are the user's and Sentry must keep them. if (!isStaticBuild) { + const serverSourceMaps = findSourceMapFiles({ outputDir: SERVER_OUTPUT }); + assert.ok(serverSourceMaps.length > 0, `Expected Sentry to keep the server source maps in ${SERVER_OUTPUT}`); + console.log(` ${serverSourceMaps.length} source map(s) kept in ${SERVER_OUTPUT}, as Nuxt enables them\n`); +} + +// --- What the built server answers for the client source maps --- + +// `nuxt generate` emits no server, and a static host serves only the files that exist. The other presets build a +// server for their platform, which Node cannot start. +if (!isStaticBuild && nitroPreset === 'node-server') { const clientChunkUrls = fs .readdirSync(CLIENT_OUTPUT, { recursive: true, withFileTypes: true }) .filter(entry => entry.isFile() && entry.name.endsWith('.js')) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts index ae2e95194fa7..db827c5e9c32 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts @@ -3,11 +3,17 @@ // own warning tells users to set, which makes it the setup worth regression-testing. const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; +// With `sourcemap.client` unset, Sentry enables the client maps and finds their output directory itself, which +// differs per Nitro preset. +const nitroPreset = process.env.NITRO_PRESET; + export default defineNuxtConfig({ compatibilityDate: '2025-06-06', imports: { autoImport: false }, - sourcemap: { client: 'hidden' }, + sourcemap: { client: nitroPreset ? undefined : 'hidden' }, + + nitro: { preset: nitroPreset }, modules: ['@sentry/nuxt/module'], @@ -27,7 +33,7 @@ export default defineNuxtConfig({ release: { name: 'test-release' }, // Dropping `filesToDeleteAfterUpload` is the whole point of the "kept" variant: Sentry should // upload the maps and leave the emitted files alone. - sourcemaps: keepClientSourceMaps ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] }, + sourcemaps: keepClientSourceMaps || nitroPreset ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] }, debug: true, }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json index c6237579668a..fed9ff583515 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json @@ -11,7 +11,11 @@ "test:build:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:build", "test:assert:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:assert", "test:build:static": "NUXT_COMMAND=generate pnpm test:build", - "test:assert:static": "NUXT_COMMAND=generate pnpm test:assert" + "test:assert:static": "NUXT_COMMAND=generate pnpm test:assert", + "test:build:netlify": "NITRO_PRESET=netlify pnpm test:build", + "test:assert:netlify": "NITRO_PRESET=netlify pnpm test:assert", + "test:build:cloudflare-pages": "NITRO_PRESET=cloudflare-pages pnpm test:build", + "test:assert:cloudflare-pages": "NITRO_PRESET=cloudflare-pages pnpm test:assert" }, "dependencies": { "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", @@ -38,6 +42,16 @@ "build-command": "pnpm test:build:static", "assert-command": "pnpm test:assert:static", "label": "nuxt-4-sourcemaps (static / nuxt generate)" + }, + { + "build-command": "pnpm test:build:netlify", + "assert-command": "pnpm test:assert:netlify", + "label": "nuxt-4-sourcemaps (netlify preset)" + }, + { + "build-command": "pnpm test:build:cloudflare-pages", + "assert-command": "pnpm test:assert:cloudflare-pages", + "label": "nuxt-4-sourcemaps (cloudflare-pages preset)" } ] } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts index 1c747e73fe77..64a3f6e7b88d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,6 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#error-in-error-boundary').click(); const error = await errorPromise; @@ -77,6 +80,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -106,6 +110,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts index b7dce20b0714..1111253ce495 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('server-side errors', async () => { test('captures api fetch error (fetched on click)', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-routes`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -42,6 +44,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml index c87cae49aab6..bcb384119acb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml @@ -18,7 +18,8 @@ services: start_period: 10s mongo: - image: mongo:8 + # `mongo:8` floats to the newest 8.x release, which keeps failing its healthcheck on CI. + image: mongo:8.0 restart: always container_name: e2e-tests-nuxt-4-mongo ports: diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs index 4d0386045cb1..0eabcb30c905 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs @@ -7,8 +7,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export default async function globalSetup() { // Start MySQL, MongoDB, and Redis via Docker Compose. `--wait` blocks until the // healthchecks in docker-compose.yml pass, so the app can connect immediately. - execSync('docker compose up -d --wait', { - cwd: __dirname, - stdio: 'inherit', - }); + try { + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); + } catch (error) { + execSync('docker compose logs --tail 50', { cwd: __dirname, stdio: 'inherit' }); + throw error; + } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.client.test.ts index 2aed77dbfed6..311c49dba8a9 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,6 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#error-in-error-boundary').click(); const error = await errorPromise; @@ -77,6 +80,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -106,6 +110,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts index 418c09f16c57..928434c44b27 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; +import { waitForHydration } from './waitForHydration'; IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { test.describe(`server-side errors (${name})`, () => { @@ -10,6 +11,7 @@ IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { }); await page.goto(`/fetch-server-routes?apiPrefix=${apiPrefix}`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -44,6 +46,7 @@ IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { }); await page.goto(`/test-param/1234?apiPrefix=${apiPrefix}`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; @@ -81,6 +84,7 @@ IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { }); await page.goto(`/fetch-server-routes?apiPrefix=${apiPrefix}`); + await waitForHydration(page); await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml index a392ebd433af..804b73fcee00 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml @@ -1,6 +1,7 @@ services: mongo: - image: mongo:8 + # `mongo:8` floats to the newest 8.x release, whose server sometimes exits right after start on CI. + image: mongo:8.0 restart: always container_name: e2e-tests-nuxt-5-mongo ports: diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs index fbc228dc4e55..826a78375d74 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs @@ -7,8 +7,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export default async function globalSetup() { // Start MongoDB via Docker Compose. `--wait` blocks until the // healthcheck in docker-compose.yml passes, so the app can connect immediately. - execSync('docker compose up -d --wait', { - cwd: __dirname, - stdio: 'inherit', - }); + try { + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); + } catch (error) { + execSync('docker compose logs --tail 50', { cwd: __dirname, stdio: 'inherit' }); + throw error; + } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts index 2c6a1be53662..826a578a7e6c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { waitForHydration } from './waitForHydration'; test.describe('client-side errors', async () => { test('captures error thrown on click', async ({ page }) => { @@ -8,6 +9,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -35,6 +37,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#error-in-error-boundary').click(); const error = await errorPromise; @@ -77,6 +80,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/test-param/1234`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error = await errorPromise; @@ -106,6 +110,7 @@ test.describe('client-side errors', async () => { }); await page.goto(`/client-error`); + await waitForHydration(page); await page.locator('#errorBtn').click(); const error1 = await error1Promise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts index 1917990af924..2dcc253a98d1 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; +import { waitForHydration } from './waitForHydration'; IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { test.describe(`server-side errors (${name})`, () => { @@ -10,6 +11,7 @@ IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { }); await page.goto(`/fetch-server-routes?apiPrefix=${apiPrefix}`); + await waitForHydration(page); await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -44,6 +46,7 @@ IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { }); await page.goto(`/test-param/1234?apiPrefix=${apiPrefix}`); + await waitForHydration(page); await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; @@ -81,6 +84,7 @@ IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { }); await page.goto(`/fetch-server-routes?apiPrefix=${apiPrefix}`); + await waitForHydration(page); await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/waitForHydration.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/waitForHydration.ts new file mode 100644 index 000000000000..71c53ecda4a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/waitForHydration.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +type NuxtRootContainer = HTMLElement & { __vue_app__?: { $nuxt?: { isHydrating?: boolean } } }; + +// Server-rendered elements are clickable before Vue hydration attaches their event handlers. +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => (document.getElementById('__nuxt') as NuxtRootContainer | null)?.__vue_app__?.$nuxt?.isHydrating === false, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index 6ee186f39e49..de5503c7626a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -11,6 +11,7 @@ "@react-router/serve": "^8.0.0", "@sentry/bun": "file:../../packed/sentry-bun-packed.tgz", "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz", "ioredis": "^5.11.1", diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/server-build.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/server-build.test.ts new file mode 100644 index 000000000000..27dc4f31a025 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/server-build.test.ts @@ -0,0 +1,24 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; +import { bundleReferencesModule } from '@sentry-internal/test-utils'; +import { RUNTIME } from './constants'; + +// `@sentry/core` is a direct dependency of this app so that Vite resolves it from the app root and +// keeps it external, as in an npm install. Bundled into the server build, it hides the case where +// bundled CommonJS `require()`s `@sentry/core`: Vite 5-7 turn that into a default import, and +// `@sentry/core` has no default export, so the server crashes at startup. +test.describe('server build', () => { + test.skip(RUNTIME === 'cloudflare', 'The Cloudflare build bundles all dependencies.'); + + test('imports the external @sentry/core by name only', () => { + const root = path.join(process.cwd(), 'build/server'); + const serverBundle = readdirSync(root, { recursive: true, encoding: 'utf8' }) + .filter(file => file.endsWith('.js')) + .map(file => readFileSync(path.join(root, file), 'utf8')) + .join('\n'); + + expect(bundleReferencesModule(serverBundle, '@sentry/core'), 'Vite must keep @sentry/core external').toBe(true); + expect(serverBundle.match(/\bimport\s+[\w$]+\s*(?:,\s*\{[^}]*\})?\s*from\s*["']@sentry\/[^"']+["']/g)).toBeNull(); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore new file mode 100644 index 000000000000..4187ae735082 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore @@ -0,0 +1,11 @@ +node_modules +.output +.nitro +.tanstack +src/routeTree.gen.ts + +# Mock Sentry server artifacts +.tmp_mock_uploads.json +.tmp_chunks +.tmp_build_stdout +.tmp_build_stderr diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts new file mode 100644 index 000000000000..2aa44e66f20e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts @@ -0,0 +1,191 @@ +import * as assert from 'assert/strict'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + findDebugIdsWithConflictingSourcemaps, + findFilesWithForeignSourcemaps, + findFilesWithMultipleDebugIds, + findInjectedDebugIds, + getArtifactBundles, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemapSources, + getSourcemaps, + loadMockServerResults, + startNodeServer, + type SourcemapEntry, +} from '@sentry-internal/test-utils'; + +const CLIENT_OUTPUT = path.join('.output', 'public'); +const SERVER_OUTPUT = path.join('.output', 'server'); + +/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ +const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; +const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; + +const DEBUG_ID_PATTERN = /sentry-dbid-([\da-f-]{36})/gi; + +function readDebugIds(file: string): string[] { + return [...fs.readFileSync(file, 'utf8').matchAll(DEBUG_ID_PATTERN)].map(match => (match[1] as string).toLowerCase()); +} + +function emittedJsFiles(dir: string): string[] { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && /\.m?js$/.test(entry.name) && !entry.parentPath.includes('node_modules')) + .map(entry => path.join(entry.parentPath, entry.name)); +} + +/** In debug mode, the Sentry plugin logs each file it deletes after the upload. */ +const deletedFiles = new Set( + [...fs.readFileSync('.tmp_build_stdout', 'utf8').matchAll(/Deleting asset after upload: (.+)$/gm)].map(match => + path.relative(fs.realpathSync('.'), match[1] as string), + ), +); + +/** A chunk without mappable source, such as Nitro's runtime, gets a debug ID but no source map to upload. */ +function debugIdsOfChunksWithoutSourceMap(dir: string): Set { + return new Set( + emittedJsFiles(dir) + .filter(file => !fs.existsSync(`${file}.map`) && !deletedFiles.has(`${file}.map`)) + .flatMap(readDebugIds), + ); +} + +function sourceMapsIn(dir: string): string[] { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.map')) + .map(entry => path.join(entry.parentPath, entry.name)); +} + +// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob +// characters, like Nitro's `_libs/@tanstack/react-router+[...].mjs`. Remove this when the uploader matches paths literally. +function debugIdsOfFilesWithGlobCharacters(dir: string): Set { + return new Set( + emittedJsFiles(dir) + .filter(file => /[[\]{}()*?]/.test(path.relative(dir, file))) + .flatMap(readDebugIds), + ); +} + +const requests = loadMockServerResults(); + +// --- The upload reached Sentry --- + +assert.ok( + getChunkUploadPosts(requests).some(request => request.bodySize > 0), + 'Expected at least one chunk upload with a non-empty body', +); + +const bundles = getArtifactBundles(requests); +assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); + +const sourcemaps = getSourcemaps(bundles); + +/** The uploaded source maps of the files in an output directory, matched by debug ID. */ +function sourceMapsOf(outputDir: string): SourcemapEntry[] { + const debugIds = new Set(findInjectedDebugIds({ outputDir })); + return sourcemaps.filter(map => map.debugId && debugIds.has(map.debugId)); +} + +const clientSourceMaps = sourceMapsOf(CLIENT_OUTPUT); +const serverSourceMaps = sourceMapsOf(SERVER_OUTPUT); +const containsMarker = (maps: SourcemapEntry[], marker: string): boolean => + maps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); + +// Both markers are in the same route file, which client and server source maps embed completely. So each marker only +// proves the upload of its side if the check only looks at the source maps of that side. +assert.ok(containsMarker(clientSourceMaps, CLIENT_MARKER), 'Expected a client source map carrying the client source'); +assert.ok(containsMarker(serverSourceMaps, SERVER_MARKER), 'Expected a server source map carrying the server source'); + +// --- Source paths match the files in the project --- + +const unresolvedSources = [...new Set(sourcemaps.flatMap(map => getSourcemapSources(map.sourcemap)))].filter( + source => source.startsWith('../') || path.isAbsolute(source) || /\?ts[rs]-/.test(source), +); +assert.deepEqual(unresolvedSources, [], 'Expected every uploaded source path to be relative to the project root'); + +// --- Server source maps reach the original source --- + +// Nitro bundles the SSR output again. Its maps must lead through that output to the file the server function is in. +const serverFunctionSources = serverSourceMaps + .filter(map => map.sourcemap.sourcesContent?.some(source => source?.includes(SERVER_MARKER))) + .flatMap(map => getSourcemapSources(map.sourcemap)); +assert.ok( + serverFunctionSources.includes('src/routes/index.tsx'), + `Expected a server source map to resolve to src/routes/index.tsx, got: ${serverFunctionSources.join(', ')}`, +); + +// --- Debug IDs tie the shipped bundles to the uploaded maps --- + +const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); + +for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) { + const injectedDebugIds = findInjectedDebugIds({ outputDir }); + assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); + + const exemptDebugIds = new Set([ + ...debugIdsOfChunksWithoutSourceMap(outputDir), + ...debugIdsOfFilesWithGlobCharacters(outputDir), + ]); + const unuploaded = injectedDebugIds.filter(debugId => !uploadedDebugIds.has(debugId) && !exemptDebugIds.has(debugId)); + assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded source map`); + + // A file of Nitro's output can carry the debug ID of the SSR chunk it was built from. + assert.deepEqual( + findFilesWithMultipleDebugIds({ outputDir }), + [], + `Expected at most one debug ID per file in ${outputDir}`, + ); + assert.deepEqual( + findFilesWithForeignSourcemaps({ outputDir }, sourcemaps), + [], + `Expected the source map uploaded for each file in ${outputDir} to be generated for that file`, + ); + + console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`); +} + +assert.deepEqual( + findDebugIdsWithConflictingSourcemaps(sourcemaps), + [], + 'Expected every uploaded debug ID to resolve to exactly one source map', +); + +// --- Sentry deletes the source maps it enabled after the upload --- + +for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) { + assert.deepEqual(sourceMapsIn(outputDir), [], `Expected no source maps in ${outputDir}`); +} + +const clientChunkUrls = emittedJsFiles(CLIENT_OUTPUT).map( + file => `/${path.relative(CLIENT_OUTPUT, file).split(path.sep).join('/')}`, +); +assert.ok(clientChunkUrls.length > 0, `Expected client chunks in ${CLIENT_OUTPUT}`); + +const server = await startNodeServer({ entry: path.join(SERVER_OUTPUT, 'index.mjs'), port: 3030 }); + +try { + // Without this, a server that serves no public files at all would pass the 404 checks. + const chunkStatus = (await fetch(`${server.baseUrl}${clientChunkUrls[0]}`)).status; + assert.equal(chunkStatus, 200, `Expected the server to serve the client chunk ${clientChunkUrls[0]}`); + + // Nitro serves public files from a list it writes into the server bundle. A map deleted after that + // stays on the list, and Nitro answers its request with a 500 instead of a 404. + const responses = await Promise.all( + clientChunkUrls.map(async chunkUrl => ({ + mapUrl: `${chunkUrl}.map`, + status: (await fetch(`${server.baseUrl}${chunkUrl}.map`)).status, + })), + ); + assert.deepEqual( + responses.filter(({ status }) => status !== 404).map(({ mapUrl, status }) => `${mapUrl} → ${status}`), + [], + 'Expected the server to answer each client source map URL with 404', + ); +} finally { + await server.close(); +} + +console.log('All source map assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json new file mode 100644 index 000000000000..865f7ace7a17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json @@ -0,0 +1,37 @@ +{ + "name": "tanstackstart-react-sourcemaps", + "description": "E2E test app asserting what the TanStack Start SDK uploads to Sentry when Nitro builds the server.", + "private": true, + "type": "module", + "scripts": { + "build": "node start-mock-sentry-server.mjs & vite build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", + "clean": "npx rimraf node_modules pnpm-lock.yaml .output", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm ts-node --script-mode assert-build.ts" + }, + "dependencies": { + "@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz", + "@tanstack/react-router": "1.170.44", + "@tanstack/react-start": "1.169.1", + "nitro": "latest", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@playwright/test": "1.64.1", + "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@types/node": "^22.20.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react-swc": "^4.2.0", + "ts-node": "10.9.1", + "typescript": "^5.9.0", + "vite": "^8.3.0" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml new file mode 100644 index 000000000000..4969718e7f8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + '@swc/core': true +minimumReleaseAge: 0 diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx new file mode 100644 index 000000000000..741d1aade5d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx @@ -0,0 +1,4 @@ +import { createRouter } from '@tanstack/react-router'; +import { routeTree } from './routeTree.gen'; + +export const getRouter = () => createRouter({ routeTree }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx new file mode 100644 index 000000000000..09432c62c40d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; +import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'; + +export const Route = createRootRoute({ + component: RootComponent, +}); + +function RootComponent(): ReactNode { + return ( + + + + + + + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx new file mode 100644 index 000000000000..51012573f928 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { createServerFn } from '@tanstack/react-start'; + +// SOURCEMAP_MARKER_CLIENT — a comment, so it is stripped from the emitted bundles but survives in `sourcesContent`. +// `assert-build.ts` uses the markers to check that the original source reached the uploaded maps. +const throwServerError = createServerFn().handler(async () => { + // SOURCEMAP_MARKER_SERVER + throw new Error('Server function error from the TanStack Start source map E2E app'); +}); + +export const Route = createFileRoute('/')({ + component: Home, +}); + +function Home() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..1680a6360165 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer({ org: 'test-org' }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..02faa5762420 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", ".output"] +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts new file mode 100644 index 000000000000..b43b2441b453 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts @@ -0,0 +1,21 @@ +import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; +import { tanstackStart } from '@tanstack/react-start/plugin/vite'; +import viteReact from '@vitejs/plugin-react-swc'; +import { nitro } from 'nitro/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + tanstackStart(), + nitro(), + viteReact(), + sentryTanstackStart({ + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, + debug: true, + }), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index d080dc4e25fd..ed739895d0d9 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -14,6 +14,7 @@ "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", "test:build:tunnel-object": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:build:hoisted": "pnpm install --config.node-linker=hoisted && pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest nitro@latest vite@latest @vitejs/plugin-react-swc@latest vite-tsconfig-paths@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test && TEST_ENV=development pnpm playwright test db-drivers", "test:assert": "pnpm test:assert:proxy", @@ -51,6 +52,10 @@ }, "sentryTest": { "variants": [ + { + "label": "tanstackstart-react (hoisted)", + "build-command": "pnpm test:build:hoisted" + }, { "label": "tanstackstart-react (tunnel-generated)", "build-command": "pnpm test:build:tunnel-generated", diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/server-build.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/server-build.test.ts new file mode 100644 index 000000000000..57bf54397164 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/server-build.test.ts @@ -0,0 +1,22 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; +import { bundleReferencesModule } from '@sentry-internal/test-utils'; + +// Nitro can't follow the `__require()` calls that Vite 8 emits for external requires in bundled +// CommonJS, so the deployed server fails with `Cannot find module`. Only flat installs (the hoisted +// variant) keep the drivers' dependencies external. +// https://vite.dev/guide/migration#require-calls-for-externalized-modules +test('bundles the CommonJS dependencies of the instrumented drivers', () => { + const root = path.join(process.cwd(), '.output/server'); + const serverBundle = readdirSync(root, { recursive: true, encoding: 'utf8' }) + .filter(file => /\.m?js$/.test(file) && !file.startsWith('node_modules')) + .map(file => readFileSync(path.join(root, file), 'utf8')) + .join('\n'); + const driverDependencies = ['sqlstring', 'bignumber.js', 'denque', 'cluster-key-slot', 'standard-as-callback']; + + const externalRequires = [...serverBundle.matchAll(/__require\(["']([^"']+)["']\)/g)].map(match => match[1]); + + expect(bundleReferencesModule(serverBundle, 'mysql'), 'mysql must be bundled to be instrumented').toBe(false); + expect(externalRequires.filter(name => driverDependencies.includes(name))).toEqual([]); +}); diff --git a/packages/browser/src/tracing/linkedTraces.ts b/packages/browser/src/tracing/linkedTraces.ts index 741212cf2065..e60f3fd59ff1 100644 --- a/packages/browser/src/tracing/linkedTraces.ts +++ b/packages/browser/src/tracing/linkedTraces.ts @@ -191,8 +191,8 @@ export function addPreviousTraceSpanLink( }, }); - // TODO(v12): Remove this once the Sentry trace view finds linked traces via span links. EAP stores - // span links, but the trace view still reads this attribute to navigate to the previous/next trace. + // TODO(v12): Remove once EAP can filter span links by type. The Sentry trace view searches this attribute to find the NEXT trace. + // Sentry 26.9 and older also read it to find the PREVIOUS trace. Newer versions read the span link. span.setAttribute( PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE, `${previousTraceSpanCtx.traceId}-${previousTraceSpanCtx.spanId}-${ diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index fd645190fb51..4edc5ebbed91 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -37,6 +37,58 @@ export function shouldSkipCodeInjection(code: string, facadeModuleId: string | n return false; } +export interface ChunkGraphNode { + fileName: string; + isEntry?: boolean; + imports?: string[]; +} + +const initialChunksCache = new WeakMap, Set>(); + +function getInitialChunks(chunks: Record): Set { + const cached = initialChunksCache.get(chunks); + if (cached) { + return cached; + } + + const initial = new Set(); + const queue = Object.values(chunks).filter(chunk => chunk.isEntry); + + for (let chunk = queue.shift(); chunk; chunk = queue.shift()) { + if (initial.has(chunk.fileName)) { + continue; + } + initial.add(chunk.fileName); + for (const imported of chunk.imports ?? []) { + const importedChunk = chunks[imported]; + if (importedChunk) { + queue.push(importedChunk); + } + } + } + + initialChunksCache.set(chunks, initial); + return initial; +} + +/** + * Whether a chunk loads at startup: it is an entry chunk, or an entry chunk + * reaches it through static imports. Chunks that only dynamic imports reach + * load later, after startup code already ran. + * + * Without the chunk graph every chunk counts as initial. + */ +export function isInitialChunk( + chunk: Pick, + chunks: Record | undefined, +): boolean { + if (chunk.isEntry || !chunks) { + return true; + } + + return getInitialChunks(chunks).has(chunk.fileName); +} + export { globFiles } from './glob'; export { getCodeInjectionPosition } from './get-code-injection-position'; diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index b3cc6861f55e..227f162eae8f 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -1,9 +1,10 @@ -import type { Options } from '../core'; +import type { ChunkGraphNode, Options } from '../core'; import { createSentryBuildPluginManager, generateReleaseInjectorCode, generateModuleMetadataInjectorCode, isJsFile, + isInitialChunk, shouldSkipCodeInjection, getDebugIdSnippet, createDebugIdUploadFunction, @@ -115,7 +116,8 @@ export function _rollupPluginInternal( const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); const sourcemapsEnabled = options.sourcemaps?.disable !== true; - const staticInjectionCode = new CodeInjection(); + const releaseInjectionCode = new CodeInjection(); + const chunkInjectionCode = new CodeInjection(); if (!options.release.inject) { logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); @@ -124,7 +126,7 @@ export function _rollupPluginInternal( 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', ); } else { - staticInjectionCode.append( + releaseInjectionCode.append( generateReleaseInjectorCode({ release: options.release.name, injectBuildInformation: options._experiments.injectBuildInformation || false, @@ -133,7 +135,7 @@ export function _rollupPluginInternal( } if (Object.keys(bundleMetadata).length > 0) { - staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); + chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); } const transformAnnotations = options.reactComponentAnnotation?.enabled @@ -181,9 +183,9 @@ export function _rollupPluginInternal( function renderChunk( this: PluginContext | undefined, code: string, - chunk: { fileName: string; facadeModuleId?: string | null }, + chunk: { fileName: string; facadeModuleId?: string | null; isEntry?: boolean }, _?: unknown, - meta?: { magicString?: MagicString }, + meta?: { magicString?: MagicString; chunks?: Record }, ): { code: string; map?: SourceMap; @@ -197,7 +199,13 @@ export function _rollupPluginInternal( return null; } - const injectCode = staticInjectionCode.clone(); + const injectCode = new CodeInjection(); + + if (isInitialChunk(chunk, meta?.chunks)) { + injectCode.append(releaseInjectionCode); + } + + injectCode.append(chunkInjectionCode); if (sourcemapsEnabled && !hasExistingDebugID(code)) { const debugId = getDebugIdForChunk(code, !!this?.meta?.rolldownVersion); diff --git a/packages/bundler-plugins/src/webpack/index.ts b/packages/bundler-plugins/src/webpack/index.ts index a86f81894821..84600ff47634 100644 --- a/packages/bundler-plugins/src/webpack/index.ts +++ b/packages/bundler-plugins/src/webpack/index.ts @@ -107,6 +107,7 @@ type WebpackCompilation = { files: Iterable; hash?: string; contentHash?: { javascript?: string }; + canBeInitial?: () => boolean; }>; outputOptions: { path?: string; @@ -198,7 +199,8 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) } const sourcemapsEnabled = options.sourcemaps?.disable !== true; - const staticInjectionCode = new CodeInjection(); + const releaseInjectionCode = new CodeInjection(); + const chunkInjectionCode = new CodeInjection(); if (!options.release.inject) { logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); @@ -207,7 +209,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', ); } else { - staticInjectionCode.append( + releaseInjectionCode.append( generateReleaseInjectorCode({ release: options.release.name, injectBuildInformation: options._experiments.injectBuildInformation || false, @@ -216,7 +218,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) } if (Object.keys(bundleMetadata).length > 0) { - staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); + chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); } const transformAnnotations = options.reactComponentAnnotation?.enabled @@ -230,7 +232,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) const transformReplace = Object.keys(replacementValues).length > 0; function addCodeInjection(compiler: WebpackCompiler): void { - if (staticInjectionCode.isEmpty() && !sourcemapsEnabled) { + if (releaseInjectionCode.isEmpty() && chunkInjectionCode.isEmpty() && !sourcemapsEnabled) { return; } @@ -267,7 +269,11 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) const sourceContents = source.source(); const code = typeof sourceContents === 'string' ? sourceContents : Buffer.from(sourceContents).toString(); - const codeToInject = staticInjectionCode.clone(); + const codeToInject = new CodeInjection(); + if (chunk.canBeInitial?.() ?? true) { + codeToInject.append(releaseInjectionCode); + } + codeToInject.append(chunkInjectionCode); if (sourcemapsEnabled) { const hash = chunk.contentHash?.javascript ?? chunk.hash; codeToInject.append(getDebugIdSnippet(hash ? stringToUUID(hash) : randomUUID())); diff --git a/packages/bundler-plugins/test/core/index.test.ts b/packages/bundler-plugins/test/core/index.test.ts index d5387fab9da7..3a0593bc4ec7 100644 --- a/packages/bundler-plugins/test/core/index.test.ts +++ b/packages/bundler-plugins/test/core/index.test.ts @@ -1,4 +1,4 @@ -import { createComponentNameAnnotateHooks, getDebugIdSnippet } from '../../src/core'; +import { createComponentNameAnnotateHooks, getDebugIdSnippet, isInitialChunk } from '../../src/core'; import { getOxcParseAstAsync } from '../../src/core/component-annotation-oxc'; import { containsOnlyImports } from '../../src/core/utils'; import { describe, it, expect, vi } from 'vitest'; @@ -63,6 +63,31 @@ describe('getDebugIdSnippet', () => { }); }); +describe('isInitialChunk', () => { + const chunks = { + 'entry.js': { fileName: 'entry.js', isEntry: true, imports: ['shared.js', 'external'] }, + 'shared.js': { fileName: 'shared.js', imports: ['vendor.js'] }, + 'vendor.js': { fileName: 'vendor.js', imports: ['shared.js'] }, + 'route.js': { fileName: 'route.js', imports: ['route-shared.js'] }, + 'route-shared.js': { fileName: 'route-shared.js', imports: [] }, + }; + + it('marks entry chunks and chunks they reach through static imports as initial', () => { + expect(isInitialChunk(chunks['entry.js'], chunks)).toBe(true); + expect(isInitialChunk(chunks['shared.js'], chunks)).toBe(true); + expect(isInitialChunk(chunks['vendor.js'], chunks)).toBe(true); + }); + + it('does not mark chunks that only dynamic imports reach as initial', () => { + expect(isInitialChunk(chunks['route.js'], chunks)).toBe(false); + expect(isInitialChunk(chunks['route-shared.js'], chunks)).toBe(false); + }); + + it('treats every chunk as initial without a chunk graph', () => { + expect(isInitialChunk({ fileName: 'route.js' }, undefined)).toBe(true); + }); +}); + describe('containsOnlyImports', () => { describe('should return true (import-only code)', () => { it.each([ diff --git a/packages/bundler-plugins/test/rollup/release-injection.test.ts b/packages/bundler-plugins/test/rollup/release-injection.test.ts new file mode 100644 index 000000000000..990f3415f65a --- /dev/null +++ b/packages/bundler-plugins/test/rollup/release-injection.test.ts @@ -0,0 +1,103 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { rollup } from 'rollup'; +import { rolldown } from 'rolldown'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sentryRollupPlugin } from '../../src/rollup'; +import { sentryVitePlugin } from '../../src/vite'; + +const RELEASE_SNIPPET = /SENTRY_RELEASE\s*=\s*\{\s*id:\s*"1\.2\.3"\s*\}/; +const DEBUG_ID_MARKER = /sentry-dbid-[0-9a-f-]{36}/; + +function readChunks(outDir: string): Record { + const chunks: Record = {}; + for (const fileName of fs.readdirSync(outDir)) { + if (fileName.endsWith('.js')) { + chunks[fileName] = fs.readFileSync(path.join(outDir, fileName), 'utf8'); + } + } + return chunks; +} + +function findChunk(chunks: Record, content: string): string { + const match = Object.entries(chunks).find(([, code]) => code.includes(content)); + expect(match, `no chunk contains ${content}`).toBeDefined(); + return match?.[1] ?? ''; +} + +describe('release injection', () => { + let tmpDir: string; + let entries: string[]; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-rollup-release-injection-')); + entries = [path.join(tmpDir, 'entry.js'), path.join(tmpDir, 'second.js')]; + fs.writeFileSync( + entries[0] ?? '', + 'import { shared } from "./shared.js";\nconsole.log("entry", shared);\nimport("./route.js");\n', + ); + fs.writeFileSync(entries[1] ?? '', 'import { shared } from "./shared.js";\nconsole.log("second", shared);\n'); + fs.writeFileSync(path.join(tmpDir, 'shared.js'), 'export const shared = "shared";\nconsole.log(shared);\n'); + fs.writeFileSync( + path.join(tmpDir, 'route.js'), + 'import { shared } from "./shared.js";\nconsole.log("route", shared);\n', + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('injects the release only into chunks that load at startup (rollup)', async () => { + const outDir = path.join(tmpDir, 'dist'); + const build = await rollup({ + input: entries, + plugins: [sentryRollupPlugin({ telemetry: false, release: { name: '1.2.3', create: false } })], + }); + await build.write({ dir: outDir, chunkFileNames: '[name].js' }); + + const chunks = readChunks(outDir); + expect(Object.keys(chunks)).toHaveLength(4); + + const entryChunk = findChunk(chunks, '"entry"'); + const secondChunk = findChunk(chunks, '"second"'); + const sharedChunk = findChunk(chunks, '"shared"'); + const routeChunk = findChunk(chunks, '"route"'); + + expect(entryChunk).toMatch(RELEASE_SNIPPET); + expect(secondChunk).toMatch(RELEASE_SNIPPET); + expect(sharedChunk).toMatch(RELEASE_SNIPPET); + expect(routeChunk).not.toMatch(RELEASE_SNIPPET); + + expect(entryChunk).toMatch(DEBUG_ID_MARKER); + expect(sharedChunk).toMatch(DEBUG_ID_MARKER); + expect(routeChunk).toMatch(DEBUG_ID_MARKER); + }); + + it('injects the release only into chunks that load at startup (rolldown / vite)', async () => { + const outDir = path.join(tmpDir, 'dist'); + const build = await rolldown({ + input: entries, + plugins: sentryVitePlugin({ telemetry: false, release: { name: '1.2.3', create: false } }), + }); + await build.write({ dir: outDir, chunkFileNames: '[name].js' }); + + const chunks = readChunks(outDir); + expect(Object.keys(chunks)).toHaveLength(4); + + const entryChunk = findChunk(chunks, '"entry"'); + const secondChunk = findChunk(chunks, '"second"'); + const sharedChunk = findChunk(chunks, '"shared"'); + const routeChunk = findChunk(chunks, '"route"'); + + expect(entryChunk).toMatch(RELEASE_SNIPPET); + expect(secondChunk).toMatch(RELEASE_SNIPPET); + expect(sharedChunk).toMatch(RELEASE_SNIPPET); + expect(routeChunk).not.toMatch(RELEASE_SNIPPET); + + expect(entryChunk).toMatch(DEBUG_ID_MARKER); + expect(sharedChunk).toMatch(DEBUG_ID_MARKER); + expect(routeChunk).toMatch(DEBUG_ID_MARKER); + }); +}); diff --git a/packages/bundler-plugins/test/webpack/release-injection.test.ts b/packages/bundler-plugins/test/webpack/release-injection.test.ts new file mode 100644 index 000000000000..55ee52f5a944 --- /dev/null +++ b/packages/bundler-plugins/test/webpack/release-injection.test.ts @@ -0,0 +1,66 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { webpack } from 'webpack'; +import type { Configuration, Stats } from 'webpack'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sentryWebpackPlugin } from '../../src/webpack/index'; + +const RELEASE_SNIPPET = /SENTRY_RELEASE\s*=\s*\{\s*id:\s*"1\.2\.3"\s*\}/; +const DEBUG_ID_MARKER = /sentry-dbid-[0-9a-f-]{36}/; + +function build(config: Configuration): Promise { + return new Promise((resolve, reject) => { + webpack(config, (err, stats) => { + if (err) { + return reject(err); + } + if (!stats || stats.hasErrors()) { + return reject(new Error(stats?.toString() ?? 'no stats')); + } + resolve(stats); + }); + }); +} + +describe('release injection', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-webpack-release-injection-')); + fs.writeFileSync( + path.join(tmpDir, 'entry.js'), + 'import { shared } from "./shared.js";\nconsole.log("entry", shared);\nimport(/* webpackChunkName: "route" */ "./route.js");\n', + ); + fs.writeFileSync(path.join(tmpDir, 'shared.js'), 'export const shared = "shared";\nconsole.log(shared);\n'); + fs.writeFileSync( + path.join(tmpDir, 'route.js'), + 'import { shared } from "./shared.js";\nconsole.log("route", shared);\n', + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('injects the release only into initial chunks', async () => { + const outDir = path.join(tmpDir, 'dist'); + + await build({ + mode: 'production', + context: tmpDir, + entry: './entry.js', + output: { path: outDir, filename: '[name].js', chunkFilename: '[name].js' }, + plugins: [sentryWebpackPlugin({ telemetry: false, release: { name: '1.2.3', create: false } })], + }); + + const mainChunk = fs.readFileSync(path.join(outDir, 'main.js'), 'utf8'); + const routeChunk = fs.readFileSync(path.join(outDir, 'route.js'), 'utf8'); + + expect(mainChunk).toMatch(RELEASE_SNIPPET); + expect(routeChunk).not.toMatch(RELEASE_SNIPPET); + + expect(mainChunk).toMatch(DEBUG_ID_MARKER); + expect(routeChunk).toMatch(DEBUG_ID_MARKER); + }); +}); diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 751d3d5dac63..c070a104c272 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -478,6 +478,35 @@ interface BaseCloudflareOptions { * @default true */ cacheClient?: boolean; + + /** + * A function that receives the response of a `fetch` handler and the request it answers, and + * returns whether the response body is still being produced after the handler returns. + * + * For `true`, the SDK keeps the `http.server` span open until the body has been sent, so spans + * created while the body streams stay inside the request. It does this by passing the body through + * a `TransformStream`. For `false`, the SDK ends the span when the handler returns and sends the + * response unchanged. For `undefined` or any other value, the SDK decides: Server-Sent Events, + * NDJSON and `application/stream+json` responses, and `text/plain` and `text/x-component` + * responses without a `Content-Length` header count as streamed. HTML does not, so return `true` + * for pages that are rendered with streamed SSR. + * + * The function must be synchronous and must not read the response body. Use the status, the + * headers and the request URL. + * + * @example + * ```ts + * // src/instrument.server.ts + * import { defineCloudflareOptions } from '@sentry/cloudflare'; + * + * export default defineCloudflareOptions(env => ({ + * dsn: env.SENTRY_DSN, + * isStreamingResponse: response => + * response.headers.get('content-type')?.startsWith('text/html') ? true : undefined, + * })); + * ``` + */ + isStreamingResponse?: (response: Response, request: Request) => boolean | undefined; } /** diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index c6ff06393d6a..fe1927833e7e 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -217,9 +217,9 @@ export function wrapMethodWithSentry( if (storedContext) { span.addLinks(buildSpanLinks(storedContext)); - // TODO(v12): Remove this once the Sentry trace view finds linked traces via span links. - // EAP stores span links, but the trace view still reads this attribute to navigate to the - // previous/next trace. + + // TODO(v12): Remove once EAP can filter span links by type. The Sentry trace view searches this attribute to find the NEXT trace. + // Sentry 26.9 and older also read it to find the PREVIOUS trace. Newer versions read the span link. const sampledFlag = storedContext.sampled ? '1' : '0'; span.setAttribute( 'sentry.previous_trace', diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 3599b88f4243..71444c9a87e3 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -14,11 +14,13 @@ import { httpHeadersToSpanAttributes, HTTP_SPAN_NAME_FALLBACK, parseStringToURLObject, + safeCallback, setHttpStatus, startSpanManual, winterCGHeadersToDict, } from '@sentry/core'; import { classifyResponseStreaming } from '@sentry/core/server'; +import { DEBUG_BUILD } from './debug-build'; import { captureIncomingRequestBody } from './integrations/httpServer'; import { flushDeferredChannelEvents } from './orchestrion-deferred-channels'; import type { CloudflareClient, CloudflareOptions } from './client'; @@ -180,12 +182,19 @@ export function wrapRequestHandlerWithInit( throw e; } - // Classify response to detect actual streaming - const classification = classifyResponseStreaming(res); + const userClassification = safeCallback( + DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', + () => options.isStreamingResponse?.(res, request), + () => undefined, + ); + // An async callback returns a promise, which would otherwise count as `true`. + const isStreaming = + typeof userClassification === 'boolean' ? userClassification : classifyResponseStreaming(res).isStreaming; - if (classification.isStreaming && res.body) { + if (isStreaming && res.body) { try { let ended = false; + let transformerUsed = false; const endSpanOnce = (): void => { if (ended) return; @@ -195,7 +204,14 @@ export function wrapRequestHandlerWithInit( waitUntil?.(flushAndDispose(client)); }; + // Workers with a compatibility date before 2022-11-30 and without the + // `transformstream_enable_standard_constructor` flag (for example under older mini-oxygen + // releases) ignore the transformer, so `flush` and `cancel` would never end the span. Only a + // used transformer runs `start`. const transform = new TransformStream({ + start() { + transformerUsed = true; + }, flush() { // Source stream completed normally. endSpanOnce(); @@ -208,15 +224,14 @@ export function wrapRequestHandlerWithInit( }, }); - return new Response(res.body.pipeThrough(transform), { - status: res.status, - statusText: res.statusText, - headers: res.headers, - }); + if (transformerUsed) { + // Passing the original response as the init keeps `encodeBody: 'manual'`, which a + // Response does not expose as a property. Without it, workerd compresses a + // pre-compressed body a second time. + return new Response(res.body.pipeThrough(transform), res); + } } catch { - span.end(); - waitUntil?.(flushAndDispose(client)); - return res; + // Falls back to ending the span at handler return. } } diff --git a/packages/cloudflare/test/options.test-d.ts b/packages/cloudflare/test/options.test-d.ts index f93a24bb7497..f47430a14ef7 100644 --- a/packages/cloudflare/test/options.test-d.ts +++ b/packages/cloudflare/test/options.test-d.ts @@ -107,6 +107,8 @@ describe('valid options keep compiling', () => { serverName: 'my-worker', rpcTracePropagationBindings: ['ORDERS', /^SVC_/], durableObjectSqlSpanAllowlist: ['cf_my_table', /^cf_reports_/], + isStreamingResponse: (response, request) => + request.url.endsWith('.data') ? true : response.headers.has('content-length') ? false : undefined, beforeSend: event => event, integrations: [], }), diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index ae30155c3c6a..a8c9a3f735f0 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -961,6 +961,250 @@ describe('flushAndDispose', () => { }); }); + describe('streaming response classification', () => { + const options: CloudflareOptions = { dsn: MOCK_OPTIONS.dsn, cacheClient: false }; + + test('defers teardown until a text/x-component body without Content-Length is consumed', async () => { + const waits: Promise[] = []; + const waitUntil = vi.fn((promise: Promise) => waits.push(promise)); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + let releaseLastChunk!: () => void; + const lastChunkGate = new Promise(resolve => { + releaseLastChunk = resolve; + }); + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode('
shell
')); + await lastChunkGate; + controller.enqueue(new TextEncoder().encode('
suspended
')); + controller.close(); + }, + }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => new Response(stream, { headers: { 'content-type': 'text/x-component;charset=utf-8' } }), + ); + + expect(waitUntil).not.toHaveBeenCalled(); + + releaseLastChunk(); + expect(await result.text()).toBe('
shell
suspended
'); + + await Promise.all(waits); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('tears down at handler return for a text/html body without Content-Length', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
page
', { headers: { 'content-type': 'text/html;charset=utf-8' } }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test.each(['text/x-component;charset=utf-8', 'text/html;charset=utf-8'])( + 'tears down at handler return for a %s body with a Content-Length', + async contentType => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
prerendered
', { + headers: { 'content-type': contentType, 'content-length': '22' }, + }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }, + ); + + test('tears down at handler return when TransformStream ignores the transformer', async () => { + // workerd without `transformstream_enable_standard_constructor` returns an identity stream. + vi.stubGlobal( + 'TransformStream', + class extends TransformStream { + constructor() { + super(); + } + }, + ); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
shell
', { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('tears down at handler return when isStreamingResponse returns false', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
buffered
', { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }); + + const result = await wrapRequestHandler( + { + options: { ...options, isStreamingResponse: () => false }, + request: new Request('https://example.com'), + context, + }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('defers teardown when isStreamingResponse returns true for the request route', async () => { + const waits: Promise[] = []; + const waitUntil = vi.fn((promise: Promise) => waits.push(promise)); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + let releaseLastChunk!: () => void; + const lastChunkGate = new Promise(resolve => { + releaseLastChunk = resolve; + }); + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode('first')); + await lastChunkGate; + controller.enqueue(new TextEncoder().encode('deferred')); + controller.close(); + }, + }); + + const result = await wrapRequestHandler( + { + options: { + ...options, + isStreamingResponse: (_response, request) => (request.url.endsWith('.data') ? true : undefined), + }, + request: new Request('https://example.com/products.data'), + context, + }, + () => new Response(stream, { headers: { 'content-type': 'text/x-script' } }), + ); + + expect(waitUntil).not.toHaveBeenCalled(); + + releaseLastChunk(); + expect(await result.text()).toBe('firstdeferred'); + + await Promise.all(waits); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('uses the default classification when isStreamingResponse throws', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
shell
', { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }); + + const result = await wrapRequestHandler( + { + options: { + ...options, + isStreamingResponse: () => { + throw new Error('isStreamingResponse failed'); + }, + }, + request: new Request('https://example.com'), + context, + }, + () => response, + ); + + expect(result).not.toBe(response); + expect(waitUntil).not.toHaveBeenCalled(); + expect(await result.text()).toBe('
shell
'); + + flushSpy.mockRestore(); + }); + + test('uses the default classification when isStreamingResponse returns a promise', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
page
', { headers: { 'content-type': 'text/html;charset=utf-8' } }); + + const result = await wrapRequestHandler( + { + options: { + ...options, + isStreamingResponse: (async () => true) as unknown as CloudflareOptions['isStreamingResponse'], + }, + request: new Request('https://example.com'), + context, + }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + }); + test('dispose is NOT called for protocol upgrade responses (status 101)', async () => { const context = createMockExecutionContext(); const waits: Promise[] = []; diff --git a/packages/core/src/integrations/http/record-request-session.ts b/packages/core/src/integrations/http/record-request-session.ts index 4ab0eccb88dd..fa608118a5a2 100644 --- a/packages/core/src/integrations/http/record-request-session.ts +++ b/packages/core/src/integrations/http/record-request-session.ts @@ -33,6 +33,29 @@ export function recordRequestSession( sessionFlushingDelayMS?: number; }, ): void { + response.once('close', startRequestSession(client, { requestIsolationScope, sessionFlushingDelayMS })); +} + +/** + * Starts a session and tracks it in the context of a given isolation scope. + * Returns a function to call when the request ends. It puts the session into + * a task, which aggregates it with other sessions in a certain time window + * (sessionFlushingDelayMs, 60 seconds by default). Calls after the first + * one do nothing. + * + * Use this when the server has no `node:http` response to pass to + * {@link recordRequestSession}. + */ +export function startRequestSession( + client: Client, + { + requestIsolationScope, + sessionFlushingDelayMS = 60_000, + }: { + requestIsolationScope: Scope; + sessionFlushingDelayMS?: number; + }, +): () => void { // Errors in this request are counted by the request session aggregate, so they must not also // update a process session inherited from the parent isolation scope. requestIsolationScope.setSession(); @@ -41,7 +64,12 @@ export function recordRequestSession( }); client.emit('startRequestSession'); - response.once('close', () => { + let ended = false; + return () => { + if (ended) { + return; + } + ended = true; const requestSession = requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession; if (client && requestSession) { @@ -89,5 +117,5 @@ export function recordRequestSession( safeUnref(timeout); } } - }); + }; } diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index e44bb9c2df8b..73f0c08e174f 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -22,7 +22,7 @@ export { DEFAULT_IGNORE_STATUS_CODES, processHttpServerTransactionEvent, } from './integrations/http/server-transaction-event'; -export { recordRequestSession } from './integrations/http/record-request-session'; +export { recordRequestSession, startRequestSession } from './integrations/http/record-request-session'; export { getClientIPAddress } from './utils/clientIPAddress'; export { addOutgoingRequestBreadcrumb } from './integrations/http/add-outgoing-request-breadcrumb'; export { diff --git a/packages/core/src/utils/responseStreaming.ts b/packages/core/src/utils/responseStreaming.ts index fee67cbb9f2a..c87f9142cd68 100644 --- a/packages/core/src/utils/responseStreaming.ts +++ b/packages/core/src/utils/responseStreaming.ts @@ -8,7 +8,7 @@ export type StreamingGuess = { * Heuristics: * - No body → not streaming * - Known streaming Content-Types → streaming (SSE, NDJSON, JSON streaming) - * - text/plain without Content-Length → streaming (some AI APIs) + * - text/plain or text/x-component without Content-Length → streaming (some AI APIs, streamed RSC) * - Otherwise → not streaming (conservative default, including HTML/SSR) * * We avoid probing the stream to prevent blocking on transform streams (like injectTraceMetaTags) @@ -27,11 +27,14 @@ export function classifyResponseStreaming(res: Response): StreamingGuess { // - application/x-ndjson, application/ndjson: Newline-delimited JSON // - application/stream+json: JSON streaming // - text/plain (without Content-Length): Some AI APIs use this for streaming text + // - text/x-component (without Content-Length): Streamed React Server Components payloads. + // Prerendered payloads are sent as one buffer with a Content-Length. + // HTML is left out: a buffered `new Response(html)` has no Content-Length header either. if ( /^text\/event-stream\b/i.test(contentType) || /^application\/(x-)?ndjson\b/i.test(contentType) || /^application\/stream\+json\b/i.test(contentType) || - (/^text\/plain\b/i.test(contentType) && !contentLength) + (/^text\/(plain|x-component)\b/i.test(contentType) && !contentLength) ) { return { isStreaming: true }; } diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 5645633ff45e..4831d6f48ea8 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -3,6 +3,9 @@ import { debug } from './debug-logger'; import { makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; +// Bounds the memory of each request. SDK envelopes stay far below it. +export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; + export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; @@ -25,6 +28,15 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Tunnel not configured', { status: 500 }); } + // The HTTP server ends the body at `Content-Length`, so the header bounds what `arrayBuffer()` reads. + const contentLength = Number(request.headers.get('content-length')); + if (!contentLength) { + return new Response('Length required', { status: 411 }); + } + if (contentLength > MAX_TUNNEL_BODY_SIZE) { + return new Response('Envelope too large', { status: 413 }); + } + const body = new Uint8Array(await request.arrayBuffer()); let envelopeHeader; diff --git a/packages/core/test/lib/integrations/http/record-request-session.test.ts b/packages/core/test/lib/integrations/http/record-request-session.test.ts new file mode 100644 index 000000000000..2aef541fa81f --- /dev/null +++ b/packages/core/test/lib/integrations/http/record-request-session.test.ts @@ -0,0 +1,129 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { recordRequestSession, startRequestSession } from '../../../../src/integrations/http/record-request-session'; +import { Scope } from '../../../../src/scope'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +function createTestClient(): TestClient { + return new TestClient(getDefaultTestClientOptions({ release: '1.0.0' })); +} + +describe('startRequestSession()', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('March 19, 1999 06:12:34 UTC')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('counts the session when end() is called', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + const requestIsolationScope = new Scope(); + + const end = startRequestSession(client, { requestIsolationScope }); + vi.runAllTimers(); + expect(sendSessionSpy).not.toHaveBeenCalled(); + + end(); + vi.runAllTimers(); + + expect(sendSessionSpy).toHaveBeenCalledWith({ + aggregates: [{ crashed: 0, errored: 0, exited: 1, started: '1999-03-19T06:12:00.000Z' }], + }); + }); + + it('counts the status set on the request isolation scope before end()', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + const requestIsolationScope = new Scope(); + + const end = startRequestSession(client, { requestIsolationScope }); + requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession!.status = 'crashed'; + end(); + vi.runAllTimers(); + + expect(sendSessionSpy).toHaveBeenCalledWith({ + aggregates: [{ crashed: 1, errored: 0, exited: 0, started: '1999-03-19T06:12:00.000Z' }], + }); + }); + + it('counts a session once when end() is called more than once', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + const requestIsolationScope = new Scope(); + + const end = startRequestSession(client, { requestIsolationScope }); + end(); + end(); + vi.runAllTimers(); + + expect(sendSessionSpy).toHaveBeenCalledTimes(1); + expect(sendSessionSpy).toHaveBeenCalledWith({ + aggregates: [{ crashed: 0, errored: 0, exited: 1, started: '1999-03-19T06:12:00.000Z' }], + }); + }); + + it('emits the "startRequestSession" hook when the session starts', () => { + const client = createTestClient(); + const onStartRequestSession = vi.fn(); + client.on('startRequestSession', onStartRequestSession); + + startRequestSession(client, { requestIsolationScope: new Scope() }); + + expect(onStartRequestSession).toHaveBeenCalledTimes(1); + }); + + it('uses sessionFlushingDelayMS to schedule the aggregate', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + + startRequestSession(client, { requestIsolationScope: new Scope(), sessionFlushingDelayMS: 1_000 })(); + + vi.advanceTimersByTime(999); + expect(sendSessionSpy).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(sendSessionSpy).toHaveBeenCalledTimes(1); + }); + it('sends the aggregate after 60 seconds by default', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + + startRequestSession(client, { requestIsolationScope: new Scope() })(); + + vi.advanceTimersByTime(59_999); + expect(sendSessionSpy).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(sendSessionSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('recordRequestSession()', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('March 19, 1999 06:12:34 UTC')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('counts the session when the response emits close', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + const response = Object.assign(new EventEmitter(), { statusCode: 200, headers: {} }); + + recordRequestSession(client, { requestIsolationScope: new Scope(), response }); + vi.runAllTimers(); + expect(sendSessionSpy).not.toHaveBeenCalled(); + + response.emit('close'); + vi.runAllTimers(); + + expect(sendSessionSpy).toHaveBeenCalledWith({ + aggregates: [{ crashed: 0, errored: 0, exited: 1, started: '1999-03-19T06:12:00.000Z' }], + }); + }); +}); diff --git a/packages/core/test/lib/utils/responseStreaming.test.ts b/packages/core/test/lib/utils/responseStreaming.test.ts index 5ec7779bd7e8..40a23fbfe910 100644 --- a/packages/core/test/lib/utils/responseStreaming.test.ts +++ b/packages/core/test/lib/utils/responseStreaming.test.ts @@ -9,6 +9,7 @@ describe('classifyResponseStreaming', () => { ['application/stream+json', true], ['TEXT/EVENT-STREAM', true], ['text/plain', true], + ['text/x-component;charset=utf-8', true], ['text/html', false], ['application/json', false], ['application/octet-stream', false], @@ -20,8 +21,8 @@ describe('classifyResponseStreaming', () => { expect(response.body?.locked).toBe(false); }); - test('treats plain text with a known length as non-streaming', () => { - const response = new Response('ready', { headers: { 'content-type': 'text/plain', 'content-length': '5' } }); + test.each(['text/plain', 'text/x-component'])('treats %s with a known length as non-streaming', contentType => { + const response = new Response('ready', { headers: { 'content-type': contentType, 'content-length': '5' } }); expect(classifyResponseStreaming(response)).toEqual({ isStreaming: false }); }); diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 2b499ce754ae..7e56a5badfca 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -2,14 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; -import { handleTunnelRequest } from '../../../src/utils/tunnel'; +import { handleTunnelRequest, MAX_TUNNEL_BODY_SIZE } from '../../../src/utils/tunnel'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; -function makeEnvelopeRequest(envelopeHeader: Record): Request { +function makeEnvelopeRequest(envelopeHeader: Record, contentLength?: number): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); - return new Request('http://localhost/tunnel', { method: 'POST', body }); + const byteLength = typeof body === 'string' ? new TextEncoder().encode(body).byteLength : body.byteLength; + return new Request('http://localhost/tunnel', { + method: 'POST', + body, + headers: { 'content-length': String(contentLength ?? byteLength) }, + }); } describe('handleTunnelRequest', () => { @@ -69,7 +74,11 @@ describe('handleTunnelRequest', () => { it('returns 400 when the envelope body contains malformed JSON', async () => { const result = await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', { method: 'POST', body: 'not valid envelope data{{{' }), + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: 'not valid envelope data{{{', + headers: { 'content-length': '26' }, + }), allowedDsns: [TEST_DSN], }); @@ -133,4 +142,48 @@ describe('handleTunnelRequest', () => { expect(result.status).toBe(500); expect(await result.text()).toBe('Failed to forward envelope to Sentry'); }); + + describe('body size limit', () => { + it('forwards the envelope when Content-Length equals the limit', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 })); + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(200); + }); + + it('returns 413 when Content-Length is above the limit', async () => { + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE + 1), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(413); + expect(await result.text()).toBe('Envelope too large'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 411 when the request has no Content-Length', async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`${JSON.stringify({ dsn: TEST_DSN })}\n`)); + controller.close(); + }, + }); + // Node.js requires `duplex` for a stream body, but the DOM types do not have it yet. + const init: RequestInit & { duplex: 'half' } = { method: 'POST', body, duplex: 'half' }; + + const result = await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', init), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(411); + expect(await result.text()).toBe('Length required'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index badb9ed5a8af..f55fe14de14f 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -26,6 +26,7 @@ export type { DenoOptions } from './types'; export { addEventProcessor, addBreadcrumb, + addIntegration, captureException, captureEvent, captureMessage, @@ -52,6 +53,7 @@ export { setAttribute, setAttributes, setUser, + setConversationId, getSpanStatusFromHttpCode, setHttpStatus, withScope, @@ -69,6 +71,8 @@ export { startNewTrace, bindScopeToEmitter, suppressTracing, + withActiveSpan, + getSpanDescendants, eventFiltersIntegration, linkedErrorsIntegration, functionToStringIntegration, @@ -78,6 +82,7 @@ export { dedupeIntegration, extraErrorDataIntegration, rewriteFramesIntegration, + moduleMetadataIntegration, supabaseIntegration, instrumentSupabaseClient, zodErrorsIntegration, @@ -94,7 +99,9 @@ export { spanToTraceHeader, spanToBaggageHeader, updateSpanName, + createConsolaReporter, featureFlagsIntegration, + growthbookIntegration, metrics, withStaticSpan, // oxlint-disable-next-line typescript/no-deprecated @@ -103,12 +110,30 @@ export { consoleLoggingIntegration, spanStreamingIntegration, } from '@sentry/core'; -export { wrapMcpServerWithSentry } from '@sentry/core/server'; -export { instrumentPostgresJsSql } from '@sentry/server-utils'; +export { trpcMiddleware, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { + instrumentPostgresJsSql, + prismaIntegration, + instrumentOpenAiClient, + instrumentAnthropicAiClient, + instrumentGoogleGenAIClient, + instrumentMistralAiClient, + instrumentTypeSafeClient, + createLangChainCallbackHandler, + instrumentLangChainEmbeddings, + instrumentStateGraph, + instrumentCreateReactAgent, +} from '@sentry/server-utils'; +export type { FlueOptions } from '@sentry/server-utils'; export { DenoClient } from './client'; -export { getDefaultIntegrations, init } from './sdk'; +export { + getDefaultIntegrations, + getDefaultIntegrationsWithoutPerformance, + init, + initWithoutDefaultIntegrations, +} from './sdk'; export { denoServeIntegration } from './integrations/deno-serve'; export type { DenoServeIntegrationOptions } from './integrations/deno-serve'; export { fetchIntegration } from './integrations/fetch'; diff --git a/packages/deno/src/integrations/contextlines.ts b/packages/deno/src/integrations/contextlines.ts index fe0a3708850a..e367003d8437 100644 --- a/packages/deno/src/integrations/contextlines.ts +++ b/packages/deno/src/integrations/contextlines.ts @@ -1,7 +1,10 @@ import type { Event, IntegrationFn, StackFrame } from '@sentry/core'; -import { addContextToFrame, defineIntegration, LRUMap } from '@sentry/core'; +import { addContextToFrame, defineIntegration, LRUMap, stripUrlQueryAndFragment } from '@sentry/core'; const INTEGRATION_NAME = 'ContextLines' as const; +// Frame filenames come from the parsed `error.stack` string, which application +// code can overwrite with external input, so only read source files. +const SOURCE_FILE_EXTENSION = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|mdx)$/i; const FILE_CONTENT_CACHE = new LRUMap(100); const DEFAULT_LINES_OF_CONTEXT = 5; @@ -91,7 +94,12 @@ async function addSourceContext(event: Event, contextLines: number): Promise { for (const frame of frames) { // Only add context if we have a filename and it hasn't already been added - if (frame.filename && frame.in_app && frame.context_line === undefined) { + if ( + frame.filename && + frame.in_app && + frame.context_line === undefined && + SOURCE_FILE_EXTENSION.test(stripUrlQueryAndFragment(frame.filename)) + ) { const permission = await Deno.permissions.query({ name: 'read', path: frame.filename, diff --git a/packages/deno/src/integrations/deno-serve.ts b/packages/deno/src/integrations/deno-serve.ts index 156fb9fd8559..9d20e9084848 100644 --- a/packages/deno/src/integrations/deno-serve.ts +++ b/packages/deno/src/integrations/deno-serve.ts @@ -1,4 +1,4 @@ -import type { Event, IntegrationFn, MaxRequestBodySize } from '@sentry/core'; +import type { Event, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core'; import { debug, defineIntegration } from '@sentry/core'; import { DEFAULT_IGNORE_STATUS_CODES, processHttpServerTransactionEvent } from '@sentry/core/server'; import type { RequestHandlerWrapperOptions } from '../wrap-deno-request-handler'; @@ -17,6 +17,67 @@ export type DenoServeIntegrationOptions = { */ maxRequestBodySize?: MaxRequestBodySize; + /** + * Do not capture the request body for incoming requests where the given callback returns `true`. + * + * `url` is the full URL of the request, including the query string. `request` is the incoming + * {@link Request}. The callback runs before the SDK reads the body. + */ + ignoreRequestBody?: (url: string, request: Request) => boolean; + + /** + * Do not capture server spans for incoming requests whose URL path makes the given callback return `true`. + * + * `urlPath` is the path of the request, including the query string. `request` is the incoming + * {@link Request}. Errors thrown by the handler are still captured. + */ + ignoreIncomingRequests?: (urlPath: string, request: Request) => boolean; + + /** + * Whether to automatically ignore common static asset requests (favicon.ico, robots.txt, etc.) + * when creating server spans. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * Whether to create spans for incoming requests. + * Errors are still captured and request data is still attached when this is `false`. + * Defaults to the client's tracing configuration (`hasSpansEnabled`). + */ + spans?: boolean; + + /** + * A hook that can be used to mutate the span for incoming requests. + * This is triggered after the span is created, before the request handler runs. + */ + onSpanCreated?: (span: Span, request: Request) => void; + + /** + * A hook that can be used to mutate the span one last time before it ends. + * For a streamed response, this runs when the stream finishes. + * It is not called when the request handler throws. + */ + onSpanEnd?: (span: Span, request: Request, response: Response) => void; + + /** + * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for + * incoming requests to track the health and crash-free rate of your releases in Sentry. + * + * For a streamed response, the session ends when the stream finishes. + * + * @default `true` + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions are flushed as a session aggregate. + * + * @default `60000` (60s) + */ + sessionFlushingDelayMS?: number; + /** * Do not capture spans for incoming `Deno.serve` requests with the given status codes. * By default, some 3xx and 4xx status codes are dropped (see @default). @@ -99,6 +160,14 @@ const _denoServeIntegration = ((options: DenoServeIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, maxRequestBodySize: options.maxRequestBodySize, + ignoreRequestBody: options.ignoreRequestBody, + ignoreIncomingRequests: options.ignoreIncomingRequests, + ignoreStaticAssets: options.ignoreStaticAssets ?? true, + spans: options.spans, + onSpanCreated: options.onSpanCreated, + onSpanEnd: options.onSpanEnd, + sessions: options.sessions ?? true, + sessionFlushingDelayMS: options.sessionFlushingDelayMS, processEvent(event: Event): Event | null { // Gated on this integration's own span origin so it does not filter `node:http` // transactions, which `denoHttpIntegration` owns via its own `ignoreStatusCodes`. diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 93869d8face1..8b277fb32453 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -29,8 +29,8 @@ import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; import { makeFetchTransport } from './transports'; import type { DenoOptions } from './types'; -/** Get the default integrations for the Deno SDK. */ -export function getDefaultIntegrations(options: Options): Integration[] { +/** Get the default integrations for the Deno SDK, excluding performance integrations. */ +export function getDefaultIntegrationsWithoutPerformance(): Integration[] { // We return a copy of the defaultIntegrations here to avoid mutating this return [ // Common @@ -52,10 +52,14 @@ export function getDefaultIntegrations(options: Options): Integration[] { globalHandlersIntegration(), // server-utils integrations ...getErrorIntegrations(), - ...(hasSpansEnabled(options) ? getTracingIntegrations() : []), ]; } +/** Get the default integrations for the Deno SDK. */ +export function getDefaultIntegrations(options: Options): Integration[] { + return [...getDefaultIntegrationsWithoutPerformance(), ...(hasSpansEnabled(options) ? getTracingIntegrations() : [])]; +} + const defaultStackParser: StackParser = createStackParser(nodeStackLineParser()); /** @@ -103,10 +107,21 @@ const defaultStackParser: StackParser = createStackParser(nodeStackLineParser()) * @see {@link DenoOptions} for documentation on configuration options. */ export function init(options: DenoOptions = {}): Client { + return _init(options, getDefaultIntegrations); +} + +/** + * Initialize Sentry for Deno, without any integrations added by default. + */ +export function initWithoutDefaultIntegrations(options: DenoOptions = {}): Client { + return _init(options, () => []); +} + +function _init(options: DenoOptions, getDefaultIntegrationsImpl: (options: Options) => Integration[]): Client { // Computed into a local rather than written back onto `options`: the default set // depends on the tracing options, so caching it on the caller's object would pin the // result of the first `init` for any reused options object. - const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrations(options); + const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(options); const clientOptions: ServerRuntimeClientOptions = { ...options, diff --git a/packages/deno/src/utils/streaming.ts b/packages/deno/src/utils/streaming.ts index ca6f778b749e..0b82c16e4a47 100644 --- a/packages/deno/src/utils/streaming.ts +++ b/packages/deno/src/utils/streaming.ts @@ -2,32 +2,35 @@ import type { Span } from '@sentry/core'; import { classifyResponseStreaming } from '@sentry/core/server'; /** - * Tee a stream, and end the provided span when the stream ends. + * Tee a stream, and end the provided span (if any) when the stream ends. * Returns the other side of the tee, which can be used to send the * response to a client. + * + * `onEnd` runs right before the span ends. */ -export async function streamResponse(span: Span, res: Response): Promise { +export async function streamResponse(span: Span | undefined, res: Response, onEnd?: () => void): Promise { const classification = classifyResponseStreaming(res); + const endSpan = (): void => { + onEnd?.(); + span?.end(); + }; // not streaming, just end the span and return the response if (!classification.isStreaming || !res.body) { - span.end(); + endSpan(); return res; } // Streaming response detected - monitor consumption to keep span alive try { - return new Response( - monitorStream(res.body, () => span.end()), - { - status: res.status, - statusText: res.statusText, - headers: res.headers, - }, - ); + return new Response(monitorStream(res.body, endSpan), { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }); } catch { // tee() failed - handle without streaming - span.end(); + endSpan(); return res; } } diff --git a/packages/deno/src/wrap-deno-request-handler.ts b/packages/deno/src/wrap-deno-request-handler.ts index 1ab390d3790b..ed8d6ae2b77e 100644 --- a/packages/deno/src/wrap-deno-request-handler.ts +++ b/packages/deno/src/wrap-deno-request-handler.ts @@ -6,7 +6,7 @@ import { SENTRY_SEGMENT_NAME_SOURCE, } from '@sentry/conventions/attributes'; import { HTTP_SERVER } from '@sentry/conventions/op'; -import type { Integration, MaxRequestBodySize } from '@sentry/core'; +import type { Client, Integration } from '@sentry/core'; import { captureBodyFromWinterCGRequest, captureException, @@ -14,6 +14,7 @@ import { debug, getClient, getHttpSpanDetailsFromUrlObject, + hasSpansEnabled, hasSpanStreamingEnabled, httpHeadersToSpanAttributes, HTTP_SPAN_NAME_FALLBACK, @@ -24,7 +25,8 @@ import { winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; -import { getClientIPAddress } from '@sentry/core/server'; +import { getClientIPAddress, isStaticAssetRequest, startRequestSession } from '@sentry/core/server'; +import type { DenoServeIntegrationOptions } from './integrations/deno-serve'; import { streamResponse } from './utils/streaming'; export type RequestHandlerWrapperOptions = { @@ -33,6 +35,45 @@ export type RequestHandlerWrapperOptions = { serveOptions?: Deno.ServeOptions; }; +function captureServeError(e: unknown): void { + captureException(e, { + mechanism: { + handled: false, + type: 'auto.http.deno', + data: { function: 'serve' }, + }, + }); +} + +// A user callback that throws must not fail the request or leave the span open. +function callUserCallback(name: string, callback: () => T, fallback: T): T { + try { + return callback(); + } catch (e) { + debug.error(`Error in the Deno.serve ${name} callback.`, e); + return fallback; + } +} + +function shouldCreateSpan( + client: Client, + integration: DenoServeIntegrationOptions | undefined, + request: Request, + urlPath: string, +): boolean { + if (!(integration?.spans ?? hasSpansEnabled(client.getOptions()))) { + return false; + } + if (integration?.ignoreStaticAssets && request.method === 'GET' && isStaticAssetRequest(urlPath)) { + return false; + } + const ignoreIncomingRequests = integration?.ignoreIncomingRequests; + return !( + ignoreIncomingRequests && + callUserCallback('ignoreIncomingRequests', () => ignoreIncomingRequests(urlPath, request), false) + ); +} + const assignIfSet = , K extends keyof T>( obj: T, key: K, @@ -59,18 +100,22 @@ export const wrapDenoRequestHandler = ( } isolationScope.setClient(client); + const integration = client.getIntegrationByName('DenoServe'); + const endSession = integration?.sessions + ? startRequestSession(client, { + requestIsolationScope: isolationScope, + sessionFlushingDelayMS: integration.sessionFlushingDelayMS, + }) + : undefined; + if (request.method === 'OPTIONS' || request.method === 'HEAD') { try { return await handler(); } catch (e) { - captureException(e, { - mechanism: { - handled: false, - type: 'auto.http.deno', - data: { function: 'serve' }, - }, - }); + captureServeError(e); throw e; + } finally { + endSession?.(); } } @@ -122,22 +167,47 @@ export const wrapDenoRequestHandler = ( ipAddress: socketIp, }); - const configuredBodySize = client.getIntegrationByName( - 'DenoServe', - )?.maxRequestBodySize; const effectiveBodySize = - configuredBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); - if (request.method !== 'GET' && effectiveBodySize !== 'none') { + integration?.maxRequestBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); + const ignoreRequestBody = integration?.ignoreRequestBody; + if ( + request.method !== 'GET' && + effectiveBodySize !== 'none' && + // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. + !(ignoreRequestBody && callUserCallback('ignoreRequestBody', () => ignoreRequestBody(request.url, request), true)) + ) { await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); } + const urlPath = urlObject ? `${urlObject.pathname}${urlObject.search}` : '/'; + const createSpan = shouldCreateSpan(client, integration, request, urlPath); + return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage'), }, - () => { + async () => { + if (!createSpan) { + let res: Response; + try { + res = await handler(); + isolationScope.setContext('response', { + status_code: res.status, + }); + } catch (e) { + captureServeError(e); + endSession?.(); + throw e; + } + return endSession ? streamResponse(undefined, res, endSession) : res; + } + return startSpanManual({ name, attributes }, async span => { + const { onSpanCreated, onSpanEnd } = integration ?? {}; + if (onSpanCreated) { + callUserCallback('onSpanCreated', () => onSpanCreated(span, request), undefined); + } let res: Response; try { @@ -151,17 +221,17 @@ export const wrapDenoRequestHandler = ( ); } catch (e) { span.end(); - captureException(e, { - mechanism: { - handled: false, - type: 'auto.http.deno', - data: { function: 'serve' }, - }, - }); + captureServeError(e); + endSession?.(); throw e; } - return streamResponse(span, res); + return streamResponse(span, res, () => { + if (onSpanEnd) { + callUserCallback('onSpanEnd', () => onSpanEnd(span, request, res), undefined); + } + endSession?.(); + }); }); }, ); diff --git a/packages/deno/test/deno-serve.test.ts b/packages/deno/test/deno-serve.test.ts index f85e596e631a..90719660ea43 100644 --- a/packages/deno/test/deno-serve.test.ts +++ b/packages/deno/test/deno-serve.test.ts @@ -1,11 +1,13 @@ // -import type { ErrorEvent, TransactionEvent } from '@sentry/core'; -import { getMainCarrier } from '@sentry/core'; +import type { AggregationCounts, Envelope, ErrorEvent, Span, TransactionEvent } from '@sentry/core'; +import { forEachEnvelopeItem, getMainCarrier } from '@sentry/core'; import { assertEquals, assertExists, assertMatch, assertNotEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; -import type { DenoClient } from '../build/esm/index.js'; +import type { DenoClient, DenoOptions } from '../build/esm/index.js'; import { captureException, captureMessage, init, denoServeIntegration, setTag, setUser } from '../build/esm/index.js'; +import { makeTestTransport } from './transport.ts'; + const LOOPBACK_ADDRESS = /^(127\.0\.0\.1|::1|::ffff:127\.0\.0\.1)$/; function resetGlobals(): void { @@ -919,3 +921,671 @@ Deno.test('Deno.serve should work when manually capturing exceptions within hand // (unless explicitly set otherwise) assertEquals(errorEvent?.exception?.values?.[0]?.mechanism?.handled, true); }); + +async function serveForTest( + handler: (request: Request) => Response | Promise, +): Promise<{ url: string; close: () => Promise }> { + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve( + { + port: 0, + signal: abortController.signal, + onListen, + onError: error => new Response((error as Error).message, { status: 500 }), + }, + handler, + ); + await p; + return { + url: `http://localhost:${server.addr.port}`, + close: async () => { + abortController.abort(); + await server.finished; + }, + }; +} + +Deno.test('Deno.serve should not capture request bodies that ignoreRequestBody matches', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const ignoreRequestBodyCalls: [string, string][] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + ignoreRequestBody: (url, request) => { + ignoreRequestBodyCalls.push([url, request.method]); + return url.includes('/upload'); + }, + }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const handlerBodies: string[] = []; + const server = await serveForTest(async request => { + handlerBodies.push(await request.text()); + return new Response('OK'); + }); + + await (await fetch(`${server.url}/upload?id=1`, { method: 'POST', body: 'secret' })).text(); + await (await fetch(`${server.url}/other`, { method: 'POST', body: 'public' })).text(); + await server.close(); + + assertEquals(handlerBodies, ['secret', 'public']); + assertEquals(ignoreRequestBodyCalls, [ + [`${server.url}/upload?id=1`, 'POST'], + [`${server.url}/other`, 'POST'], + ]); + assertEquals(transactionEvents.length, 2); + const upload = transactionEvents.find(event => event.request?.url?.includes('/upload')); + const other = transactionEvents.find(event => event.request?.url?.includes('/other')); + assertEquals(upload?.request?.data, undefined); + assertEquals(other?.request?.data, 'public'); +}); + +Deno.test('Deno.serve should not create spans for requests that ignoreIncomingRequests matches', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const errorEvents: ErrorEvent[] = []; + const urlPaths: string[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + ignoreIncomingRequests: (urlPath, request) => { + urlPaths.push(urlPath); + return request.method === 'GET' && urlPath.startsWith('/health'); + }, + }), + ], + beforeSend: (event: ErrorEvent) => { + errorEvents.push(event); + return null; + }, + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(request => { + if (request.url.includes('fail')) { + throw new Error('health check failed'); + } + return new Response('OK'); + }); + + await (await fetch(`${server.url}/health?ok=1`)).text(); + await (await fetch(`${server.url}/health/fail`)).text(); + await (await fetch(`${server.url}/users`)).text(); + await server.close(); + await client.flush(); + + assertEquals(urlPaths, ['/health?ok=1', '/health/fail', '/users']); + assertEquals( + transactionEvents.map(event => event.transaction), + ['GET /users'], + ); + assertEquals(errorEvents.length, 1); + assertEquals(errorEvents[0]?.exception?.values?.[0]?.value, 'health check failed'); + assertEquals(errorEvents[0]?.exception?.values?.[0]?.mechanism?.type, 'auto.http.deno'); + assertEquals(errorEvents[0]?.request?.url?.includes('/health/fail'), true); +}); + +Deno.test('Deno.serve should not create spans for static assets by default', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/favicon.ico`)).text(); + await (await fetch(`${server.url}/robots.txt`)).text(); + await (await fetch(`${server.url}/assets/app.js?v=2`)).text(); + await (await fetch(`${server.url}/assets/app.js`, { method: 'POST' })).text(); + await (await fetch(`${server.url}/page`)).text(); + await server.close(); + + assertEquals(transactionEvents.map(event => event.transaction).sort(), ['GET /page', 'POST /assets/app.js']); +}); + +Deno.test('Deno.serve should create spans for static assets when ignoreStaticAssets is false', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ ignoreStaticAssets: false }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/favicon.ico`)).text(); + await server.close(); + + assertEquals( + transactionEvents.map(event => event.transaction), + ['GET /favicon.ico'], + ); +}); + +Deno.test('Deno.serve should not create spans when spans is false, but still capture errors', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const errorEvents: ErrorEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ spans: false }), + ], + beforeSend: (event: ErrorEvent) => { + errorEvents.push(event); + return null; + }, + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(async request => { + if (request.url.includes('error')) { + throw new Error(`failed with ${await request.text()}`); + } + return new Response('OK'); + }); + + await (await fetch(`${server.url}/ok`)).text(); + await (await fetch(`${server.url}/error`, { method: 'POST', body: 'payload' })).text(); + await server.close(); + await client.flush(); + + assertEquals(transactionEvents.length, 0); + assertEquals(errorEvents.length, 1); + assertEquals(errorEvents[0]?.exception?.values?.[0]?.value, 'failed with payload'); + assertEquals(errorEvents[0]?.exception?.values?.[0]?.mechanism?.handled, false); + assertEquals(errorEvents[0]?.request?.url?.includes('/error'), true); + assertEquals(errorEvents[0]?.request?.data, 'payload'); +}); + +Deno.test('Deno.serve should not create spans when tracing is disabled', async () => { + resetGlobals(); + let spanCreatedCalls = 0; + + init({ + dsn: 'https://username@domain/123', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + onSpanCreated: () => { + spanCreatedCalls++; + }, + }), + ], + }) as DenoClient; + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/page`)).text(); + await server.close(); + + assertEquals(spanCreatedCalls, 0); +}); + +Deno.test('Deno.serve should call onSpanCreated and onSpanEnd', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const hookCalls: string[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + onSpanCreated: (span: Span, request: Request) => { + hookCalls.push(`created ${new URL(request.url).pathname}`); + span.setAttribute('test.created', true); + }, + onSpanEnd: (span: Span, request: Request, response: Response) => { + hookCalls.push(`end ${new URL(request.url).pathname} ${response.status}`); + span.setAttribute('test.response_header', response.headers.get('x-test') ?? undefined); + }, + }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(() => { + hookCalls.push('handler'); + return new Response('OK', { status: 201, headers: { 'x-test': 'yes' } }); + }); + await (await fetch(`${server.url}/hooks`)).text(); + await server.close(); + + assertEquals(hookCalls, ['created /hooks', 'handler', 'end /hooks 201']); + assertEquals(transactionEvents.length, 1); + assertEquals(transactionEvents[0]?.contexts?.trace?.data?.['test.created'], true); + assertEquals(transactionEvents[0]?.contexts?.trace?.data?.['test.response_header'], 'yes'); +}); + +Deno.test('Deno.serve should call onSpanEnd when a streamed response finishes', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const hookCalls: string[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + onSpanEnd: (span: Span) => { + hookCalls.push('end'); + span.setAttribute('test.ended', true); + }, + }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(() => { + const stream = new ReadableStream({ + async pull(controller) { + hookCalls.push('chunk'); + controller.enqueue(new TextEncoder().encode('chunk\n')); + controller.close(); + }, + }); + return new Response(stream, { headers: { 'content-type': 'text/event-stream' } }); + }); + assertEquals(await (await fetch(`${server.url}/stream`)).text(), 'chunk\n'); + await server.close(); + + assertEquals(hookCalls, ['chunk', 'end']); + assertEquals(transactionEvents.length, 1); + assertEquals(transactionEvents[0]?.contexts?.trace?.data?.['test.ended'], true); +}); + +Deno.test('Deno.serve should still respond and end the span when onSpanCreated throws', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + onSpanCreated: () => { + throw new Error('onSpanCreated failed'); + }, + }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(() => new Response('OK')); + const response = await fetch(`${server.url}/created`); + assertEquals(response.status, 200); + assertEquals(await response.text(), 'OK'); + await server.close(); + + assertEquals( + transactionEvents.map(event => event.transaction), + ['GET /created'], + ); +}); + +for (const [label, makeResponse] of [ + ['a response', () => new Response('OK')], + [ + 'a streamed response', + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('OK')); + controller.close(); + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ], +] as const) { + Deno.test(`Deno.serve should still respond and end the span when onSpanEnd throws for ${label}`, async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + onSpanEnd: () => { + throw new Error('onSpanEnd failed'); + }, + }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(makeResponse); + const response = await fetch(`${server.url}/ended`); + assertEquals(response.status, 200); + assertEquals(await response.text(), 'OK'); + await server.close(); + + assertEquals( + transactionEvents.map(event => event.transaction), + ['GET /ended'], + ); + }); +} + +Deno.test('Deno.serve should not capture the body and still respond when ignoreRequestBody throws', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const errorEvents: ErrorEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + ignoreRequestBody: () => { + throw new Error('ignoreRequestBody failed'); + }, + }), + ], + beforeSend: (event: ErrorEvent) => { + errorEvents.push(event); + return null; + }, + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(async request => new Response(await request.text())); + const response = await fetch(`${server.url}/upload`, { method: 'POST', body: 'secret' }); + assertEquals(response.status, 200); + assertEquals(await response.text(), 'secret'); + await server.close(); + await client.flush(); + + assertEquals(errorEvents.length, 0); + assertEquals(transactionEvents.length, 1); + assertEquals(transactionEvents[0]?.request?.data, undefined); +}); + +Deno.test('Deno.serve should create the span and still respond when ignoreIncomingRequests throws', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + const errorEvents: ErrorEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ + ignoreIncomingRequests: () => { + throw new Error('ignoreIncomingRequests failed'); + }, + }), + ], + beforeSend: (event: ErrorEvent) => { + errorEvents.push(event); + return null; + }, + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const server = await serveForTest(() => new Response('OK')); + const response = await fetch(`${server.url}/page`); + assertEquals(response.status, 200); + assertEquals(await response.text(), 'OK'); + await server.close(); + await client.flush(); + + assertEquals(errorEvents.length, 0); + assertEquals( + transactionEvents.map(event => event.transaction), + ['GET /page'], + ); +}); + +type SessionCounts = Omit; + +function initWithSessionTransport(options: DenoOptions = {}): { + client: DenoClient; + sessions: SessionCounts[]; + firstSession: Promise; +} { + const sessions: SessionCounts[] = []; + let resolveFirstSession: () => void; + const firstSession = new Promise(resolve => (resolveFirstSession = resolve)); + const client = init({ + dsn: 'https://username@domain/123', + release: '1.0.0', + transport: makeTestTransport((envelope: Envelope) => { + forEachEnvelopeItem(envelope, (item, type) => { + if (type === 'sessions') { + for (const { started: _started, ...counts } of (item[1] as { aggregates: AggregationCounts[] }).aggregates) { + sessions.push(counts); + } + resolveFirstSession(); + } + }); + }), + ...options, + }) as DenoClient; + return { client, sessions, firstSession }; +} + +Deno.test('Deno.serve should record an exited session for a successful request', async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport(); + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/ok`)).text(); + await server.close(); + await client.flush(); + + assertEquals(sessions, [{ exited: 1, errored: 0, crashed: 0 }]); +}); + +Deno.test('Deno.serve should record an errored session for a handled exception', async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport(); + + const server = await serveForTest(() => { + captureException(new Error('handled')); + return new Response('OK'); + }); + await (await fetch(`${server.url}/handled`)).text(); + await server.close(); + await client.flush(); + + assertEquals(sessions, [{ exited: 0, errored: 1, crashed: 0 }]); +}); + +Deno.test('Deno.serve should record a crashed session when the handler throws', async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport(); + + const server = await serveForTest(() => { + throw new Error('unhandled'); + }); + await (await fetch(`${server.url}/throws`)).text(); + await server.close(); + await client.flush(); + + assertEquals(sessions, [{ exited: 0, errored: 0, crashed: 1 }]); +}); + +Deno.test('Deno.serve should record sessions for OPTIONS and HEAD requests', async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport(); + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/cors`, { method: 'OPTIONS' })).text(); + await (await fetch(`${server.url}/cors`, { method: 'HEAD' })).text(); + await server.close(); + await client.flush(); + + assertEquals(sessions, [{ exited: 2, errored: 0, crashed: 0 }]); +}); + +Deno.test('Deno.serve should not record sessions when sessions is false', async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport({ + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ sessions: false }), + ], + }); + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/ok`)).text(); + await server.close(); + await client.flush(); + + assertEquals(sessions, []); +}); + +Deno.test('Deno.serve should send the session aggregate after sessionFlushingDelayMS', async () => { + resetGlobals(); + const { sessions, firstSession } = initWithSessionTransport({ + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ sessionFlushingDelayMS: 50 }), + ], + }); + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/ok`)).text(); + await server.close(); + + // The timeout only bounds a failure. With the default 60s delay, no + // session arrives within it. + let timeoutId: ReturnType | undefined; + const timedOut = new Promise<'timed out'>(resolve => { + timeoutId = setTimeout(() => resolve('timed out'), 10_000); + }); + const result = await Promise.race([firstSession.then(() => 'sent' as const), timedOut]); + clearTimeout(timeoutId); + + assertEquals(result, 'sent'); + assertEquals(sessions, [{ exited: 1, errored: 0, crashed: 0 }]); +}); + +for (const [label, options] of [ + ['when tracing is enabled', { tracesSampleRate: 1, traceLifecycle: 'static' }], + ['when tracing is disabled', {}], +] as const) { + Deno.test(`Deno.serve should record a streamed response session when the stream finishes ${label}`, async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport(options); + + let streamController: ReadableStreamDefaultController | undefined; + const server = await serveForTest(() => { + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + controller.enqueue(new TextEncoder().encode('chunk\n')); + }, + }); + return new Response(stream, { headers: { 'content-type': 'text/event-stream' } }); + }); + + const response = await fetch(`${server.url}/stream`); + await client.flush(); + assertEquals(sessions, []); + + streamController!.close(); + assertEquals(await response.text(), 'chunk\n'); + await server.close(); + await client.flush(); + + assertEquals(sessions, [{ exited: 1, errored: 0, crashed: 0 }]); + }); +} + +Deno.test('Deno.serve should record sessions for requests without a span', async () => { + resetGlobals(); + const { client, sessions } = initWithSessionTransport({ + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ ignoreIncomingRequests: urlPath => urlPath === '/health' }), + ], + }); + + const server = await serveForTest(() => new Response('OK')); + await (await fetch(`${server.url}/health`)).text(); + await (await fetch(`${server.url}/favicon.ico`)).text(); + await server.close(); + await client.flush(); + + assertEquals(sessions, [{ exited: 2, errored: 0, crashed: 0 }]); +}); diff --git a/packages/deno/test/exports.test.ts b/packages/deno/test/exports.test.ts new file mode 100644 index 000000000000..c1468653c566 --- /dev/null +++ b/packages/deno/test/exports.test.ts @@ -0,0 +1,42 @@ +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import * as Sentry from '../build/esm/index.js'; +import type { FlueOptions } from '../build/esm/index.js'; + +// Exports that Cloudflare ships and that need no Node APIs. +const EXPECTED_FUNCTION_EXPORTS = [ + // @sentry/core + 'addIntegration', + 'withActiveSpan', + 'getSpanDescendants', + 'setConversationId', + 'createConsolaReporter', + 'growthbookIntegration', + 'moduleMetadataIntegration', + // @sentry/core/server + 'trpcMiddleware', + // @sentry/server-utils + 'prismaIntegration', + 'instrumentOpenAiClient', + 'instrumentAnthropicAiClient', + 'instrumentGoogleGenAIClient', + 'instrumentMistralAiClient', + 'instrumentTypeSafeClient', + 'createLangChainCallbackHandler', + 'instrumentLangChainEmbeddings', + 'instrumentStateGraph', + 'instrumentCreateReactAgent', + // ./sdk + 'initWithoutDefaultIntegrations', + 'getDefaultIntegrationsWithoutPerformance', +]; + +for (const name of EXPECTED_FUNCTION_EXPORTS) { + Deno.test(`exports ${name}`, () => { + assertEquals(typeof (Sentry as Record)[name], 'function'); + }); +} + +Deno.test('exports the FlueOptions type', () => { + const options: FlueOptions = {}; + assertEquals(typeof options, 'object'); +}); diff --git a/packages/deno/test/sdk.test.ts b/packages/deno/test/sdk.test.ts index 032442ece005..9eebad5e6482 100644 --- a/packages/deno/test/sdk.test.ts +++ b/packages/deno/test/sdk.test.ts @@ -3,7 +3,9 @@ import { assertArrayIncludes } from 'https://deno.land/std@0.212.0/assert/assert import { getCurrentScope, getDefaultIntegrations, + getDefaultIntegrationsWithoutPerformance, init as initSdk, + initWithoutDefaultIntegrations, spanStreamingIntegration, } from '../build/esm/index.js'; import type { DenoOptions } from '../build/esm/index.js'; @@ -82,3 +84,39 @@ Deno.test("init doesn't write the computed default integrations back onto the op const names = second.getOptions().integrations.map(i => i.name); assertArrayIncludes(names, ['Graphql']); }); + +Deno.test('initWithoutDefaultIntegrations sets up only the integrations the user passes', () => { + getCurrentScope().setClient(undefined); + const client = initWithoutDefaultIntegrations({ + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: [spanStreamingIntegration()], + }); + const names = client.getOptions().integrations.map(i => i.name); + assertEquals(names, ['SpanStreaming']); +}); + +Deno.test('initWithoutDefaultIntegrations keeps defaultIntegrations the user passes', () => { + getCurrentScope().setClient(undefined); + const client = initWithoutDefaultIntegrations({ + traceLifecycle: 'static', + defaultIntegrations: getDefaultIntegrationsWithoutPerformance(), + }); + const names = client.getOptions().integrations.map(i => i.name); + assertArrayIncludes(names, ['Breadcrumbs', 'DenoServe', 'Express']); +}); + +Deno.test('getDefaultIntegrationsWithoutPerformance leaves out the tracing set', () => { + const names = getDefaultIntegrationsWithoutPerformance().map(i => i.name); + assertArrayIncludes(names, ['Breadcrumbs', 'DenoServe', 'Express', 'Fastify']); + assert(!names.includes('Graphql')); + assert(!names.includes('Postgres')); + assert(!names.includes('OpenAI')); +}); + +Deno.test('getDefaultIntegrations is getDefaultIntegrationsWithoutPerformance plus the tracing set', () => { + const base = getDefaultIntegrationsWithoutPerformance().map(i => i.name); + const full = getDefaultIntegrations({ tracesSampleRate: 1 }).map(i => i.name); + assertEquals(full.slice(0, base.length), base); + assertArrayIncludes(full.slice(base.length), ['Graphql', 'Postgres', 'OpenAI']); +}); diff --git a/packages/deno/test/streaming.test.ts b/packages/deno/test/streaming.test.ts index cfa040ac01cf..05951459e786 100644 --- a/packages/deno/test/streaming.test.ts +++ b/packages/deno/test/streaming.test.ts @@ -82,3 +82,46 @@ Deno.test('reader.closed.then(f, f) suppresses rejection when releaseLock is cal globalThis.removeEventListener('unhandledrejection', handler); } }); + +Deno.test('calls onEnd before ending the span for a non-streamed response', async () => { + const calls: string[] = []; + const span = { end: () => calls.push('end') } as unknown as Parameters[0]; + + await streamResponse(span, new Response('{}', { headers: { 'content-type': 'application/json' } }), () => + calls.push('onEnd'), + ); + + assertEquals(calls, ['onEnd', 'end']); +}); + +Deno.test('calls onEnd before ending the span when a streamed response finishes', async () => { + const calls: string[] = []; + let resolveEnded: () => void; + const ended = new Promise(resolve => (resolveEnded = resolve)); + const span = { + end: () => { + calls.push('end'); + resolveEnded(); + }, + } as unknown as Parameters[0]; + + let sourceController: ReadableStreamDefaultController | undefined; + const source = new ReadableStream({ + start(controller) { + sourceController = controller; + controller.enqueue(new TextEncoder().encode('chunk')); + }, + }); + const response = await streamResponse( + span, + new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + () => calls.push('onEnd'), + ); + + await new Promise(resolve => setTimeout(resolve, 0)); + assertEquals(calls, []); + sourceController!.close(); + assertEquals(await response.text(), 'chunk'); + await ended; + assertEquals(calls, ['onEnd', 'end']); +}); diff --git a/packages/hono/package.json b/packages/hono/package.json index 8a6b4abe0874..ce561d1101ac 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -1,7 +1,7 @@ { "name": "@sentry/hono", "version": "11.8.0", - "description": "Official Sentry SDK for Hono (ALPHA)", + "description": "Official Sentry SDK for Hono", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/hono", "author": "Sentry", diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 7e7b4154d885..7cea31e746e3 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -55,6 +55,11 @@ "types": "./build/types/config/index.d.ts", "default": "./build/cjs/config/index.js" }, + "./tunnel": { + "types": "./build/types/tunnel/index.d.ts", + "import": "./build/esm/tunnel/index.js", + "require": "./build/cjs/tunnel/index.js" + }, "./async-storage-shim": { "import": { "default": "./build/esm/config/templates/requestAsyncStorageShim.js" diff --git a/packages/nextjs/rollup.npm.config.mjs b/packages/nextjs/rollup.npm.config.mjs index bef1bd0f92d3..925a49507c0f 100644 --- a/packages/nextjs/rollup.npm.config.mjs +++ b/packages/nextjs/rollup.npm.config.mjs @@ -13,6 +13,7 @@ export default [ 'src/edge/index.ts', 'src/cloudflare/index.ts', 'src/config/index.ts', + 'src/tunnel/index.ts', ], // prevent this internal nextjs code from ending up in our built package (this doesn't happen automatically because diff --git a/packages/nextjs/src/client/browserTracingIntegration.ts b/packages/nextjs/src/client/browserTracingIntegration.ts index cd957d1d62b5..acd961a77c8e 100644 --- a/packages/nextjs/src/client/browserTracingIntegration.ts +++ b/packages/nextjs/src/client/browserTracingIntegration.ts @@ -1,5 +1,6 @@ import type { Integration } from '@sentry/core'; import { browserTracingIntegration as originalBrowserTracingIntegration, isBotUserAgent } from '@sentry/react'; +import { settlePendingPageloadWait } from './routing/appRouterRoutingInstrumentation'; import { nextRouterInstrumentNavigation, nextRouterInstrumentPageLoad } from './routing/nextRoutingInstrumentation'; /** @@ -41,6 +42,13 @@ export function browserTracingIntegration( nextRouterInstrumentNavigation(client); } + // A pageload that is still waiting for its trace meta tag has to exist before the navigation span, + // so the navigation ends it like any pageload it interrupts. Started later, the pageload would end + // the navigation span instead. + if (instrumentPageLoad) { + client.on('beforeStartNavigationSpan', settlePendingPageloadWait); + } + browserTracingIntegrationInstance.afterAllSetup(client); if (instrumentPageLoad) { diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index d5c7e11a1d06..9f1ed4e4f768 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -11,7 +11,6 @@ import { getClientVercelEnv } from '../common/getVercelEnv'; import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; -import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; import { createNextRouteProvider } from './routing/routeProvider'; import { applyTunnelRouteOption } from './tunnelRoute'; @@ -45,12 +44,6 @@ export function init(options: BrowserOptions): Client | undefined { }); } - // Remove cached trace meta tags for ISR/SSG pages before initializing - // This prevents the browser tracing integration from using stale trace IDs - if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { - removeIsrSsgTraceMetaTags(); - } - const opts = { environment: options.environment || process.env.SENTRY_ENVIRONMENT || getClientVercelEnv() || process.env.NODE_ENV, defaultIntegrations: getDefaultIntegrations(options), diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 4b7e9b1c7ae5..108637e5de2e 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -1,5 +1,6 @@ -import type { Client, Span } from '@sentry/core'; +import type { Client, Span, StartSpanOptions } from '@sentry/core'; import { + getCurrentScope, GLOBAL_OBJ, hasSpanStreamingEnabled, isObjectLike, @@ -17,6 +18,8 @@ import { resolveRoute, } from '@sentry/react'; import { stripTrailingSlash } from './parameterization'; +import type { TraceMetaTagValues, TraceMetaTagWait } from './traceMetaTags'; +import { addTraceMetaTagLink, canWaitForTraceMetaTag, readTraceMetaTags, waitForTraceMetaTag } from './traceMetaTags'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, @@ -95,11 +98,48 @@ let navigationRoutingMode: 'router-patch' | 'transition-start-hook' = 'router-pa const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: undefined }; +/** + * How long the pageload start may wait for the resumed part of a Cache Components document. The wait + * normally ends with `DOMContentLoaded`; the cap only matters for a response the server keeps open. + */ +const PAGELOAD_TRACE_META_TAG_TIMEOUT_MS = 10_000; + +let pendingPageloadWait: TraceMetaTagWait | undefined; + +/** + * Starts a pageload that is still waiting for its trace meta tag with a fresh trace. Called when a + * navigation starts, before the navigation span is created, so the navigation ends the pageload like + * any pageload it interrupts, and the pageload is still recorded. + */ +export function settlePendingPageloadWait(): void { + pendingPageloadWait?.giveUp(); +} + +/** + * Starts the pageload span with the request data of the document. The span may start after a + * navigation already changed the location, so the browser SDK would otherwise read the new one. + */ +function startPageloadSpan( + client: Client, + spanOptions: StartSpanOptions, + documentUrl: string, + traceMetaTags?: TraceMetaTagValues, +): Span | undefined { + const span = startBrowserTracingPageLoadSpan(client, spanOptions, traceMetaTags); + const scope = getCurrentScope(); + const { normalizedRequest } = scope.getScopeData().sdkProcessingMetadata; + if (normalizedRequest) { + scope.setSDKProcessingMetadata({ normalizedRequest: { ...normalizedRequest, url: documentUrl } }); + } + return span; +} + /** Instruments the Next.js app router for pageloads. */ export function appRouterInstrumentPageLoad(client: Client): void { + const documentUrl = filterCollectedUrl(WINDOW.location.href, client); const pathname = stripTrailingSlash(WINDOW.location.pathname); const parameterizedPathname = resolveCurrentRoute(client); - startBrowserTracingPageLoadSpan(client, { + const spanOptions: StartSpanOptions = { // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. name: parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : pathname), // pageload should always start at timeOrigin (and needs to be in s, not ms) @@ -107,9 +147,41 @@ export function appRouterInstrumentPageLoad(client: Client): void { [SENTRY_OP]: PAGELOAD, [SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation', [SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url', + [URL_PATH]: WINDOW.location.pathname, + [URL_FULL]: documentUrl, ...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }), }, - }); + }; + + // A tag that is already in the document was rendered for this request and can be continued. A + // Cache Components document that resumes a prerendered shell gets its tag with the resumed part, + // which usually arrives after the SDK started. Waiting for it lets the pageload continue the server + // trace instead of linking it; the span's start is backdated to the time origin either way. + const traceMetaTags = readTraceMetaTags(); + if (traceMetaTags || !canWaitForTraceMetaTag()) { + startPageloadSpan(client, spanOptions, documentUrl, traceMetaTags); + return; + } + + pendingPageloadWait = waitForTraceMetaTag( + values => { + pendingPageloadWait = undefined; + startPageloadSpan(client, spanOptions, documentUrl, values); + }, + () => { + pendingPageloadWait = undefined; + const span = startPageloadSpan(client, spanOptions, documentUrl); + // Only reachable through the cap or a navigation while the document still streams: a tag that + // shows up after this can no longer be continued, but the server request can still be linked. + if (span) { + waitForTraceMetaTag( + values => span.isRecording() && addTraceMetaTagLink(span, values), + () => undefined, + ); + } + }, + PAGELOAD_TRACE_META_TAG_TIMEOUT_MS, + ); } interface NavigationSpanRef { diff --git a/packages/nextjs/src/client/routing/isrRoutingTracing.ts b/packages/nextjs/src/client/routing/isrRoutingTracing.ts deleted file mode 100644 index de5308cbb7ef..000000000000 --- a/packages/nextjs/src/client/routing/isrRoutingTracing.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { LRUMap } from '@sentry/core'; -import { WINDOW } from '@sentry/react'; -import { getManifest, maybeParameterizeRoute } from './parameterization'; - -/** - * Cache for ISR/SSG route checks. Exported for testing purposes. - * @internal - */ -export const IS_ISR_SSG_ROUTE_CACHE = new LRUMap(100); - -/** - * Check if the current page is an ISR/SSG route by checking the route manifest. - * @internal Exported for testing purposes. - */ -export function isIsrSsgRoute(pathname: string): boolean { - // Early parameterization to get the cache key - const parameterizedPath = maybeParameterizeRoute(pathname); - const pathToCheck = parameterizedPath || pathname; - - // Check cache using the parameterized path as the key - const cachedResult = IS_ISR_SSG_ROUTE_CACHE.get(pathToCheck); - if (cachedResult !== undefined) { - return cachedResult; - } - - // Cache miss get the manifest - const manifest = getManifest(); - if (!manifest?.isrRoutes || !Array.isArray(manifest.isrRoutes) || manifest.isrRoutes.length === 0) { - IS_ISR_SSG_ROUTE_CACHE.set(pathToCheck, false); - return false; - } - - const isIsrSsgRoute = manifest.isrRoutes.includes(pathToCheck); - IS_ISR_SSG_ROUTE_CACHE.set(pathToCheck, isIsrSsgRoute); - - return isIsrSsgRoute; -} - -/** - * Remove sentry-trace and baggage meta tags from the DOM if this is an ISR/SSG page. - * This prevents the browser tracing integration from using stale/cached trace IDs. - */ -export function removeIsrSsgTraceMetaTags(): void { - if (!WINDOW.document || !isIsrSsgRoute(WINDOW.location.pathname)) { - return; - } - - // Helper function to remove a meta tag - function removeMetaTag(metaName: string): void { - try { - const meta = WINDOW.document.querySelector(`meta[name="${metaName}"]`); - if (meta) { - meta.remove(); - } - } catch { - // ignore errors when removing the meta tag - } - } - - // Remove the meta tags so browserTracingIntegration won't pick them up - removeMetaTag('sentry-trace'); - removeMetaTag('baggage'); -} diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index 9b366d705637..619b6d7f8069 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -147,7 +147,6 @@ export function getManifest(): RouteManifest | null { let manifest: RouteManifest = { staticRoutes: [], dynamicRoutes: [], - isrRoutes: [], }; // Shallow check if the manifest is actually what we expect it to be diff --git a/packages/nextjs/src/client/routing/traceMetaTags.ts b/packages/nextjs/src/client/routing/traceMetaTags.ts new file mode 100644 index 000000000000..8e1138000ec5 --- /dev/null +++ b/packages/nextjs/src/client/routing/traceMetaTags.ts @@ -0,0 +1,152 @@ +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; +import type { Span } from '@sentry/core'; +import { extractTraceparentData } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; +import { WINDOW } from '@sentry/react'; + +// `@opentelemetry/api` `TraceFlags`, inlined because core does not export its copies. +const TRACE_FLAG_SAMPLED = 0x1; +const TRACE_FLAG_NONE = 0x0; + +const SERVER_REQUEST_LINK_TYPE = 'server_request'; + +export interface TraceMetaTagValues { + sentryTrace: string; + baggage: string | undefined; +} + +function getDocument(): Document | undefined { + return WINDOW.document; +} + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryCacheComponents?: string }; + +/** Whether the build enabled `cacheComponents`, so a document can resume a prerendered shell. */ +function hasCacheComponents(): boolean { + return (process.env._sentryCacheComponents ?? globalWithInjectedValues._sentryCacheComponents) === 'true'; +} + +function lastMetaContent(document: Document, name: string): string | undefined { + const tags = document.querySelectorAll(`meta[name="${name}"]`); + return tags[tags.length - 1]?.getAttribute('content') || undefined; +} + +/** + * Reads the `sentry-trace` and `baggage` meta tags of the document. The server only writes them for + * a render that happened for this request (see `nextSentryPropagator.ts`), so whichever tag is + * present may be continued. The last one wins: a document stitched from a shell and a resumed part + * carries the resumed part's tag last. + */ +export function readTraceMetaTags(): TraceMetaTagValues | undefined { + const document = getDocument(); + const sentryTrace = document && lastMetaContent(document, 'sentry-trace'); + return document && sentryTrace ? { sentryTrace, baggage: lastMetaContent(document, 'baggage') } : undefined; +} + +/** + * Whether the response carried the trace in a `Server-Timing` header. The generic browser tracing + * integration continues it when it starts the pageload, so there is nothing to wait for. + */ +export function hasServerTimingTraceContext(): boolean { + // The cast is required for the declaration build, which resolves `getEntriesByType('navigation')` + // to `PerformanceEntry[]` without `serverTiming`. + const navigation = WINDOW.performance?.getEntriesByType?.('navigation')[0] as PerformanceNavigationTiming | undefined; + return !!navigation?.serverTiming?.some(entry => entry.name === 'sentry-trace'); +} + +/** + * Whether the SDK can still expect a `sentry-trace` meta tag to arrive: the app has `cacheComponents` + * enabled, so a document can resume a prerendered shell and get its tag with the resumed part, the + * document is still streaming, and nothing else already carries the trace. Without `cacheComponents` + * a tag is either in `` from the start or never comes, so waiting would only hold the pageload + * back. + */ +export function canWaitForTraceMetaTag(): boolean { + return ( + hasCacheComponents() && + getDocument()?.readyState === 'loading' && + typeof MutationObserver !== 'undefined' && + !hasServerTimingTraceContext() + ); +} + +export interface TraceMetaTagWait { + /** Stops waiting and reports that no tag will be used, unless the wait already settled. */ + giveUp(): void; +} + +/** + * Waits for a `sentry-trace` meta tag to show up while the document is still streaming. The resumed + * part of a Cache Components page arrives after the SDK started whenever the dynamic hole takes + * longer than loading the SDK, which is the common case. + * + * Calls `onTag` once with the tag, or `onGiveUp` once when the document finished parsing without + * one, `timeoutMs` passed, or `giveUp()` was called, whichever comes first. Settles with `onGiveUp` + * right away when `canWaitForTraceMetaTag()` is false. + */ +export function waitForTraceMetaTag( + onTag: (values: TraceMetaTagValues) => void, + onGiveUp: () => void, + timeoutMs?: number, +): TraceMetaTagWait { + const document = getDocument(); + if (!document || !canWaitForTraceMetaTag()) { + onGiveUp(); + return { giveUp: () => undefined }; + } + + let settled = false; + const settle = (callback: () => void): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + observer.disconnect(); + document.removeEventListener('DOMContentLoaded', onDocumentParsed); + callback(); + }; + + const checkForTag = (): boolean => { + const values = readTraceMetaTags(); + if (values) { + settle(() => onTag(values)); + } + return !!values; + }; + + // The tag can sit in the last chunk, whose mutation records may not have been delivered yet. + const onDocumentParsed = (): void => { + if (!checkForTag()) { + settle(onGiveUp); + } + }; + + const observer = new MutationObserver(checkForTag); + const timeout = timeoutMs === undefined ? undefined : setTimeout(() => settle(onGiveUp), timeoutMs); + + observer.observe(document.documentElement, { childList: true, subtree: true }); + document.addEventListener('DOMContentLoaded', onDocumentParsed); + + return { giveUp: () => settle(onGiveUp) }; +} + +/** + * Adds a span link to the span described by a `sentry-trace` value. Used when the tag arrives after + * the pageload span exists, which can only link the server request, not continue it. + */ +export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagValues): void { + const traceparent = extractTraceparentData(sentryTrace); + if (!traceparent?.traceId || !traceparent.parentSpanId) { + return; + } + + span.addLink({ + context: { + traceId: traceparent.traceId, + spanId: traceparent.parentSpanId, + traceFlags: traceparent.parentSampled ? TRACE_FLAG_SAMPLED : TRACE_FLAG_NONE, + }, + attributes: { [SENTRY_LINK_TYPE]: SERVER_REQUEST_LINK_TYPE }, + }); +} diff --git a/packages/nextjs/src/client/tunnelRoute.ts b/packages/nextjs/src/client/tunnelRoute.ts index 64025fdd5f9d..72f2855e32ae 100644 --- a/packages/nextjs/src/client/tunnelRoute.ts +++ b/packages/nextjs/src/client/tunnelRoute.ts @@ -1,9 +1,11 @@ import { debug, dsnFromString, GLOBAL_OBJ } from '@sentry/core'; import type { BrowserOptions } from '@sentry/react'; import { DEBUG_BUILD } from '../common/debug-build'; +import type { TunnelRouteMode } from '../common/utils/tunnelPathnameMatch'; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string; + _sentryTunnelRouteMode?: TunnelRouteMode; }; /** @@ -16,6 +18,15 @@ export function applyTunnelRouteOption(options: BrowserOptions): void { if (!dsnComponents) { return; } + + // The handler reads the DSN from the envelope, so it needs no query and no SaaS DSN. + const tunnelRouteMode = process.env._sentryTunnelRouteMode || globalWithInjectedValues._sentryTunnelRouteMode; + if (tunnelRouteMode === 'handler') { + options.tunnel = tunnelRouteOption; + DEBUG_BUILD && debug.log(`Tunneling events to "${tunnelRouteOption}"`); + return; + } + const sentrySaasDsnMatch = dsnComponents.host.match(/^o(\d+)\.ingest(?:\.([a-z]{2}))?\.sentry\.io$/); if (sentrySaasDsnMatch) { const orgId = sentrySaasDsnMatch[1]; @@ -25,6 +36,12 @@ export function applyTunnelRouteOption(options: BrowserOptions): void { tunnelPath += `&r=${regionCode}`; } options.tunnel = tunnelPath; + // The tunnel route is same-origin, so fetch would include credentials (cookies, HTTP authentication) that the + // tunnel does not need. + options.transportOptions = { + ...options.transportOptions, + fetchOptions: { credentials: 'omit', ...options.transportOptions?.fetchOptions }, + }; DEBUG_BUILD && debug.log(`Tunneling events to "${tunnelPath}"`); } else { DEBUG_BUILD && debug.warn('Provided DSN is not a Sentry SaaS DSN. Will not tunnel events.'); diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts index c97b7298a143..9c43cff4b5a3 100644 --- a/packages/nextjs/src/cloudflare/index.ts +++ b/packages/nextjs/src/cloudflare/index.ts @@ -5,21 +5,19 @@ import type { CloudflareOptions } from '@sentry/cloudflare'; import { withSentry as withSentryCloudflare } from '@sentry/cloudflare'; import type { Integration } from '@sentry/core'; import { applySdkMetadata, extractTraceparentData, getRootSpan, GLOBAL_OBJ, spanToJSON } from '@sentry/core'; -import { - registerPrepareSpanScope, - SentryPropagator, - setOpenTelemetryContextAsyncContextStrategy, -} from '@sentry/opentelemetry'; +import { registerPrepareSpanScope, setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd'; +import { NextSentryPropagator } from '../server/nextSentryPropagator'; +import { getGlobalTracerProvider, markPrerendersOnTracerProvider } from '../server/prerenderContext'; import { addNextjsServerSpanHooks, NEXTJS_SERVER_IGNORE_SPANS } from '../server/serverSpanHooks'; import { nextjsUseCacheIntegration } from '../server/useCacheInstrumentation'; export * from '@sentry/cloudflare'; -class NextjsCloudflarePropagator extends SentryPropagator { +class NextjsCloudflarePropagator extends NextSentryPropagator { /** @inheritDoc */ public extract(ctx: Context, carrier: unknown, getter: TextMapGetter): Context { // Next.js extracts the request headers again from the root context when it misses its router server context, e.g. @@ -43,6 +41,9 @@ const nextjsIntegration = (): Integration => ({ name: 'Nextjs', setup: client => { if ((client.getOptions() as CloudflareOptions).enableOpenTelemetrySetup) { + // ISR pages revalidate inside a request of the Worker, so the prerender guard has to cover the tracer + // provider `@sentry/cloudflare` registered as well. + markPrerendersOnTracerProvider(getGlobalTracerProvider()); propagation.setGlobalPropagator(new NextjsCloudflarePropagator()); } client.getOptions().release ??= (GLOBAL_OBJ as { _sentryRelease?: string })._sentryRelease; diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index 31064377e4ba..f2d4774f3c54 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -1,12 +1,18 @@ import { HTTP_TARGET, URL_FULL, URL_PATH, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; -import type { RawAttributes } from '@sentry/core'; -import { getClient, GLOBAL_OBJ, isSentryRequestUrl, type Span } from '@sentry/core'; -import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; -import { isPathnameUnderSentryTunnelRoute } from './tunnelPathnameMatch'; +import type { ClientOptions, RawAttributes } from '@sentry/core'; +import { escapeStringForRegex, getClient, GLOBAL_OBJ, isSentryRequestUrl, type Span } from '@sentry/core'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; +import { + getTunnelRouteRequestPattern, + isPathnameUnderSentryTunnelRoute, + TUNNEL_ROUTE_HANDLER_PATH, + type TunnelRouteMode, +} from './tunnelPathnameMatch'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string; + _sentryTunnelRouteMode?: TunnelRouteMode; }; /** @@ -82,3 +88,42 @@ export function isTunnelRouteSpan(spanAttributes: Record): bool return false; } + +/** + * Returns `ignoreSpans` entries for the spans of incoming tunnel requests. + * + * Span streaming checks `ignoreSpans` at span start, before `dropMiddlewareTunnelRequests` runs. The entries match only + * Next.js request spans, so other spans with the same path are kept. + */ +export function getTunnelRouteIgnoreSpans(): NonNullable { + const tunnelPath = globalWithInjectedValues._sentryRewritesTunnelPath || process.env._sentryRewritesTunnelPath; + if (!tunnelPath) { + return []; + } + + const tunnelRouteMode = + (globalWithInjectedValues._sentryTunnelRouteMode || process.env._sentryTunnelRouteMode) === 'handler' + ? 'handler' + : 'rewrite'; + const tunnelRequestIgnoreSpan = { + attributes: { + [ATTR_NEXT_SPAN_TYPE]: /^(?:BaseServer\.handleRequest|Middleware\.execute)$/, + // Next.js sets only `http.target` at span start. + // eslint-disable-next-line typescript/no-deprecated + [HTTP_TARGET]: getTunnelRouteRequestPattern(tunnelPath, tunnelRouteMode), + }, + }; + + if (tunnelRouteMode !== 'handler') { + return [tunnelRequestIgnoreSpan]; + } + + // On edge, the root span of a route handler has only `next.route`, with a `/route` suffix before Next.js 16. + const tunnelRouteHandlerIgnoreSpan = { + attributes: { + [ATTR_NEXT_SPAN_TYPE]: /^AppRouteRouteHandlers\.runHandler$/, + [ATTR_NEXT_ROUTE]: new RegExp(`^${escapeStringForRegex(TUNNEL_ROUTE_HANDLER_PATH)}(?:/route)?$`), + }, + }; + return [tunnelRequestIgnoreSpan, tunnelRouteHandlerIgnoreSpan]; +} diff --git a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts index 9f107d33636c..2c5dc8bb1dd1 100644 --- a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts +++ b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts @@ -1,3 +1,17 @@ +import { escapeStringForRegex } from '@sentry/core'; + +/** + * The path of the tunnel route handler (`app/sentry-tunnel/route.ts`). + * Users create the file at this path, so the value is a public contract. + */ +export const TUNNEL_ROUTE_HANDLER_PATH = '/sentry-tunnel'; + +/** + * `rewrite`: the tunnel route proxies requests to Sentry with all their headers. + * `handler`: the tunnel route goes to the tunnel route handler, which forwards only the envelope. + */ +export type TunnelRouteMode = 'rewrite' | 'handler'; + /** * Returns true when `pathname` is exactly the Sentry tunnel route or a sub-path * (`tunnelPath` + `/...`). A plain `startsWith(tunnelPath)` is unsafe: e.g. tunnel @@ -6,3 +20,18 @@ export function isPathnameUnderSentryTunnelRoute(pathname: string, tunnelPath: string): boolean { return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`); } + +/** + * Returns a pattern for the request targets (path and query) that the tunnel rewrite serves. + * It must never match more than the rewrite, see GHSA-mchm-gjx6-7h55. + * + * - `handler`: the path, with an optional trailing slash and any query. + * - `rewrite`: also exactly the query that `applyTunnelRouteOption` sends. A looser query match is unsafe, because + * Next.js reads the last value of a repeated param. + */ +export function getTunnelRouteRequestPattern(tunnelPath: string, tunnelRouteMode: TunnelRouteMode): RegExp { + const pathPattern = `^${escapeStringForRegex(tunnelPath)}/?`; + return tunnelRouteMode === 'handler' + ? new RegExp(`${pathPattern}(?:\\?|$)`) + : new RegExp(`${pathPattern}\\?o=\\d+&p=\\d+(?:&r=[a-z]{2})?$`); +} diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index bf1765bfce4f..33f91585b4ad 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -135,20 +135,6 @@ function hasOptionalPrefix(paramNames: string[], localeParamNames: string[]): bo return localeParamNames.includes(firstParam); } -/** - * Check if a page file exports generateStaticParams (ISR/SSG indicator) - */ -function checkForGenerateStaticParams(pageFilePath: string): boolean { - try { - const content = fs.readFileSync(pageFilePath, 'utf8'); - // check for generateStaticParams export - // the regex covers `export function generateStaticParams`, `export async function generateStaticParams`, `export const generateStaticParams` - return /export\s+(async\s+)?function\s+generateStaticParams|export\s+const\s+generateStaticParams/.test(content); - } catch { - return false; - } -} - function scanAppDirectory( dir: string, basePath: string = '', @@ -158,7 +144,6 @@ function scanAppDirectory( ): RouteManifest { const dynamicRoutes: RouteInfo[] = []; const staticRoutes: RouteInfo[] = []; - const isrRoutes: string[] = []; try { const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -169,14 +154,6 @@ function scanAppDirectory( const routePath = includeRouteGroups ? basePath || '/' : normalizeRouteGroupPath(basePath || '/'); const isDynamic = routePath.includes(':'); - // Check if this page has generateStaticParams (ISR/SSG indicator) - const pageFilePath = path.join(dir, pageFile.name); - const hasGenerateStaticParams = checkForGenerateStaticParams(pageFilePath); - - if (hasGenerateStaticParams) { - isrRoutes.push(routePath); - } - if (isDynamic) { const { regex, paramNames, hasOptionalPrefix } = buildRegexForDynamicRoute(routePath, localeParamNames); dynamicRoutes.push({ @@ -217,14 +194,13 @@ function scanAppDirectory( dynamicRoutes.push(...subRoutes.dynamicRoutes); staticRoutes.push(...subRoutes.staticRoutes); - isrRoutes.push(...subRoutes.isrRoutes); } } } catch (error) { getBuildLogger(silent).warn('[@sentry/nextjs] Error building route manifest:', error); } - return { dynamicRoutes, staticRoutes, isrRoutes }; + return { dynamicRoutes, staticRoutes }; } /** @@ -249,7 +225,6 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route if (!targetDir) { return { - isrRoutes: [], dynamicRoutes: [], staticRoutes: [], }; @@ -267,7 +242,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route return manifestCache; } - const { dynamicRoutes, staticRoutes, isrRoutes } = scanAppDirectory( + const { dynamicRoutes, staticRoutes } = scanAppDirectory( targetDir, options?.basePath, options?.includeRouteGroups, @@ -278,7 +253,6 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route const manifest: RouteManifest = { dynamicRoutes, staticRoutes, - isrRoutes, }; // set cache diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts index 99fc42fb27d7..0a0946be70f7 100644 --- a/packages/nextjs/src/config/manifest/types.ts +++ b/packages/nextjs/src/config/manifest/types.ts @@ -34,9 +34,4 @@ export type RouteManifest = { * List of all static routes */ staticRoutes: RouteInfo[]; - - /** - * List of ISR/SSG routes (routes with generateStaticParams) - */ - isrRoutes: string[]; }; diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 743f4abc35a8..3eb22ab2263d 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -18,6 +18,7 @@ import type { TurbopackOptions, } from '../types'; import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util'; +import { getTunnelRouteInjectedValues } from '../withSentryConfig/tunnel'; import { generateValueInjectionRules } from './generateValueInjectionRules'; /** @@ -53,17 +54,10 @@ export function constructTurbopackConfig({ ...(shouldEnableNativeDebugIds ? { debugIds: true } : {}), }; - const tunnelPath = - userSentryOptions?.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}` - : undefined; - const valueInjectionRules = generateValueInjectionRules({ routeManifest, nextJsVersion, - tunnelPath, + tunnelRouteValues: getTunnelRouteInjectedValues(userNextConfig, userSentryOptions), vercelCronsConfig, }); diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 8bca01cb2ce4..22c81f00102b 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; import type { JSONValue, TurbopackMatcherWithRule } from '../types'; +import type { TunnelRouteInjectedValues } from '../withSentryConfig/tunnel'; import { getPackageModules, supportsTurbopackRuleCondition } from '../util'; /** @@ -10,16 +11,16 @@ import { getPackageModules, supportsTurbopackRuleCondition } from '../util'; export function generateValueInjectionRules({ routeManifest, nextJsVersion, - tunnelPath, + tunnelRouteValues, vercelCronsConfig, }: { routeManifest?: RouteManifest; nextJsVersion?: string; - tunnelPath?: string; + tunnelRouteValues?: TunnelRouteInjectedValues; vercelCronsConfig?: VercelCronsConfig; }): TurbopackMatcherWithRule[] { const rules: TurbopackMatcherWithRule[] = []; - const isomorphicValues: Record = {}; + const isomorphicValues: Record = { ...tunnelRouteValues }; let clientValues: Record = {}; let serverValues: Record = {}; @@ -32,11 +33,6 @@ export function generateValueInjectionRules({ clientValues._sentryRouteManifest = JSON.stringify(routeManifest); } - // Inject tunnel route path for both client and server - if (tunnelPath) { - isomorphicValues._sentryRewritesTunnelPath = tunnelPath; - } - // Inject Vercel crons config for server-side cron auto-instrumentation if (vercelCronsConfig) { serverValues._sentryVercelCronsConfig = JSON.stringify(vercelCronsConfig); diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index f1d3a8441bcb..4e1e6bc2d9e3 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -272,10 +272,25 @@ export type SentryBuildOptions = Omit< * - Pass `true` to auto-generate a random, ad-blocker-resistant route for each build * - Pass a string path (e.g., '/monitoring') to use a custom route * + * By default, the tunnel route proxies each request to Sentry with its headers. To forward only the event payload, + * create `app/sentry-tunnel/route.ts` (or `src/app/sentry-tunnel/route.ts`, not in a route + * group) with: + * + * ```ts + * export { POST } from '@sentry/nextjs/tunnel'; + * ``` + * + * This handler accepts the DSN of the server SDK, also a self-hosted one. For other DSNs, use + * `createSentryTunnelRoute` from the same import. + * * Tunnel requests go through your middleware (`proxy.ts` / `middleware.ts`) like any other request. If your * middleware redirects or blocks unauthenticated requests, exclude the tunnel route in its `matcher` so events * can reach Sentry. Matchers have to be static, so use a fixed string route in that case rather than `true`. * + * Without the route handler, the browser sends tunnel requests with `credentials: 'omit'`, so without cookies or HTTP + * authentication. If a layer in front of your app needs them (for example Vercel Deployment Protection or HTTP Basic authentication), set + * `transportOptions: { fetchOptions: { credentials: 'same-origin' } }` in `Sentry.init` of `instrumentation-client.ts`. + * * NOTE: This feature only works with Next.js 11+ */ tunnelRoute?: string | boolean; diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 5f0990ac6829..1473b86bd745 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -3,6 +3,9 @@ import * as fs from 'fs'; import { createRequire } from 'module'; import * as path from 'path'; +// Default page extensions per https://github.com/vercel/next.js/blob/f1dbc9260d48c7995f6c52f8fbcc65f08e627992/packages/next/server/config-shared.ts#L161 +export const DEFAULT_PAGE_EXTENSIONS = ['tsx', 'ts', 'jsx', 'js']; + /** * Returns the version of Next.js installed in the project, or undefined if it cannot be determined. */ diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 833965b69518..32bd6bee28e7 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -26,8 +26,9 @@ import type { WebpackPluginInstance, } from './types'; import { sentryOrchestrionWebpackPlugin } from '@sentry/server-utils/orchestrion/webpack'; -import { getNextjsVersion, getPackageModules } from './util'; +import { DEFAULT_PAGE_EXTENSIONS, getNextjsVersion, getPackageModules } from './util'; import type { VercelCronsConfigResult } from './withSentryConfig/getFinalConfigObjectUtils'; +import { getTunnelRouteInjectedValues } from './withSentryConfig/tunnel'; // Next.js runs webpack 3 times, once for the client, the server, and for edge. Because we don't want to print certain // warnings 3 times, we keep track of them here. @@ -72,8 +73,7 @@ export function constructWebpackConfigFunction({ const { isServer, dev: isDev, dir: projectDir } = buildContext; const logger = getBuildLogger(userSentryOptions.silent); const runtime = isServer ? (buildContext.nextRuntime === 'edge' ? 'edge' : 'server') : 'client'; - // Default page extensions per https://github.com/vercel/next.js/blob/f1dbc9260d48c7995f6c52f8fbcc65f08e627992/packages/next/server/config-shared.ts#L161 - const pageExtensions = userNextConfig.pageExtensions || ['tsx', 'ts', 'jsx', 'js']; + const pageExtensions = userNextConfig.pageExtensions || DEFAULT_PAGE_EXTENSIONS; const dotPrefixedPageExtensions = pageExtensions.map(ext => `.${ext}`); const pageExtensionRegex = pageExtensions.map(escapeStringForRegex).join('|'); const nextVersion = nextJsVersion || getNextjsVersion(); @@ -719,13 +719,7 @@ function addValueInjectionLoader({ const releaseToInject = releaseName && shouldCreateRelease ? releaseName : undefined; const isomorphicValues = { - // `rewritesTunnel` set by the user in Next.js config - _sentryRewritesTunnelPath: - userSentryOptions.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}` - : undefined, + ...getTunnelRouteInjectedValues(userNextConfig, userSentryOptions), // The webpack plugin's release injection breaks the `app` directory so we inject the release manually here instead. // Having a release defined in dev-mode spams releases in Sentry so we only set one in non-dev mode diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index b93d9ea2fc9c..836a3829ea5d 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { hasOnlyAppRouterPages } from './pagesRouterDetection'; +import { getTunnelRouteInjectedValues } from './tunnel'; /** * Adds Sentry-related build-time variables to `nextConfig.env`. @@ -23,13 +24,6 @@ export function setUpBuildTimeVariables( const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || ''; const basePath = userNextConfig.basePath ?? ''; - const rewritesTunnelPath = - userSentryOptions.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${basePath}${userSentryOptions.tunnelRoute}` - : undefined; - const buildTimeVariables: Record = { // Make sure that if we have a windows path, the backslashes are interpreted as such (rather than as escape // characters) @@ -39,6 +33,7 @@ export function setUpBuildTimeVariables( _sentryRewriteFramesAssetPrefixPath: assetPrefix ? new URL(assetPrefix, 'http://dogs.are.great').pathname.replace(/\/$/, '') : '', + ...getTunnelRouteInjectedValues(userNextConfig, userSentryOptions, projectDir), }; if (userNextConfig.assetPrefix) { @@ -49,10 +44,6 @@ export function setUpBuildTimeVariables( buildTimeVariables._experimentalThirdPartyOriginStackFrames = 'true'; } - if (rewritesTunnelPath) { - buildTimeVariables._sentryRewritesTunnelPath = rewritesTunnelPath; - } - if (basePath) { buildTimeVariables._sentryBasePath = basePath; } @@ -69,6 +60,11 @@ export function setUpBuildTimeVariables( buildTimeVariables._sentryRelease = releaseName; } + // The client only waits for a late trace meta tag when a document can resume a prerendered shell. + if (userNextConfig.cacheComponents) { + buildTimeVariables._sentryCacheComponents = 'true'; + } + // See `client/routing/nextRoutingInstrumentation.ts`. if (hasOnlyAppRouterPages(projectDir)) { buildTimeVariables._sentryHasPagesRouter = 'false'; diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index c457cb75aaf0..68520bc1e16d 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -9,9 +9,16 @@ import type { RouteManifest } from '../manifest/types'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { requiresInstrumentationHook } from '../util'; import { getGitRevision, getInstrumentationClientFileContents } from './buildTime'; -import { resolveTunnelRoute, setUpTunnelRewriteRules } from './tunnel'; +import { + findTunnelRouteHandlerFile, + resolveTunnelRoute, + setUpTunnelHandlerRewrite, + setUpTunnelRewriteRules, +} from './tunnel'; let showedExportModeTunnelWarning = false; +let showedUnusedTunnelRouteHandlerWarning = false; +let showedTunnelRouteHandlerModeLog = false; let showedExperimentalBuildModeWarning = false; /** @@ -27,7 +34,7 @@ export function resolveReleaseName(userSentryOptions: SentryBuildOptions): strin } /** - * Applies tunnel-route rewrites, if configured. + * Applies tunnel-route rewrites, if configured. Uses handler mode if the project has a tunnel route handler file. * * Note: this mutates `userSentryOptions` (to store the resolved tunnel route) and `incomingUserNextConfigObject`. */ @@ -35,6 +42,11 @@ export function maybeSetUpTunnelRouteRewriteRules( incomingUserNextConfigObject: NextConfigObject, userSentryOptions: SentryBuildOptions, ): void { + if (userSentryOptions.tunnelRoute === undefined) { + maybeWarnAboutUnusedTunnelRouteHandler(incomingUserNextConfigObject, userSentryOptions.silent); + return; + } + if (!userSentryOptions.tunnelRoute) { return; } @@ -53,7 +65,39 @@ export function maybeSetUpTunnelRouteRewriteRules( const resolvedTunnelRoute = resolveTunnelRoute(userSentryOptions.tunnelRoute); userSentryOptions.tunnelRoute = resolvedTunnelRoute || undefined; - setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); + const tunnelRouteHandlerFile = findTunnelRouteHandlerFile(incomingUserNextConfigObject.pageExtensions); + if (!tunnelRouteHandlerFile) { + setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); + return; + } + + setUpTunnelHandlerRewrite(incomingUserNextConfigObject, resolvedTunnelRoute); + + if (!showedTunnelRouteHandlerModeLog) { + showedTunnelRouteHandlerModeLog = true; + getBuildLogger(userSentryOptions.silent).log( + `[@sentry/nextjs] The tunnel route sends events through ${tunnelRouteHandlerFile}. Only the event payload goes to Sentry.`, + ); + } +} + +function maybeWarnAboutUnusedTunnelRouteHandler( + incomingUserNextConfigObject: NextConfigObject, + silent: boolean | undefined, +): void { + if (showedUnusedTunnelRouteHandlerWarning || incomingUserNextConfigObject.output === 'export') { + return; + } + + const tunnelRouteHandlerFile = findTunnelRouteHandlerFile(incomingUserNextConfigObject.pageExtensions); + if (!tunnelRouteHandlerFile) { + return; + } + + showedUnusedTunnelRouteHandlerWarning = true; + getBuildLogger(silent).warn( + `[@sentry/nextjs] Found ${tunnelRouteHandlerFile}, but the \`tunnelRoute\` option is not set. Set \`tunnelRoute\` in \`withSentryConfig\` to send events through this route.`, + ); } /** @@ -128,7 +172,6 @@ export function filterRouteManifest(manifest: RouteManifest, excludeFilter: Excl return { staticRoutes: manifest.staticRoutes.filter(r => !shouldExclude(r.path)), dynamicRoutes: manifest.dynamicRoutes.filter(r => !shouldExclude(r.path)), - isrRoutes: manifest.isrRoutes.filter(r => !shouldExclude(r)), }; } @@ -140,16 +183,6 @@ export function maybeSetClientTraceMetadataOption( nextJsVersion: string | undefined, silent?: boolean, ): void { - // With Cache Components enabled, the page shell — and therefore the document's `sentry-trace`/ - // `baggage` meta tags — can be prerendered and rendered in an async context detached from the - // runtime `http.server` request. The trace captured into those meta tags is therefore stale and - // unrelated to the actual request. If we enabled `clientTraceMetadata`, the browser pageload would - // continue that stale trace, producing a misleading distributed trace. So we don't enable the meta - // tags here; the browser pageload starts a fresh trace instead. - if (incomingUserNextConfigObject.cacheComponents) { - return; - } - // Add the `clientTraceMetadata` experimental option based on Next.js version. The option got introduced in Next.js version 15.0.0 (actually 14.3.0-canary.64). // Adding the option on lower versions will cause Next.js to print nasty warnings we wouldn't confront our users with. if (nextJsVersion) { diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index 78b050a525bd..d0b45bd6ec8c 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -1,5 +1,14 @@ import { _INTERNAL_safeMathRandom } from '@sentry/core'; -import type { NextConfigObject } from '../types'; +import * as fs from 'fs'; +import * as path from 'path'; +import { TUNNEL_ROUTE_HANDLER_PATH, type TunnelRouteMode } from '../../common/utils/tunnelPathnameMatch'; +import type { NextConfigObject, SentryBuildOptions } from '../types'; +import { DEFAULT_PAGE_EXTENSIONS } from '../util'; + +/** The values that the client, server and edge bundles read to find the tunnel route. */ +export type TunnelRouteInjectedValues = + | { _sentryRewritesTunnelPath: string; _sentryTunnelRouteMode: TunnelRouteMode } + | Record; /** * Generates a random tunnel route path that's less likely to be blocked by ad-blockers @@ -115,3 +124,79 @@ export function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnel } }; } + +/** + * Injects a rewrite rule that sends requests from the `tunnelPath` to the tunnel route handler. + */ +export function setUpTunnelHandlerRewrite(userNextConfig: NextConfigObject, tunnelPath: string): void { + const originalRewrites = userNextConfig.rewrites; + // Next.js adds the `basePath` and an optional trailing slash. + const handlerRewrite = { source: tunnelPath, destination: TUNNEL_ROUTE_HANDLER_PATH }; + + // `beforeFiles`: a `public/` file or a page at the same path wins over an `afterFiles` rewrite. + userNextConfig.rewrites = async () => { + const originalRewritesResult = typeof originalRewrites === 'function' ? await originalRewrites() : []; + + if (Array.isArray(originalRewritesResult)) { + return { beforeFiles: [handlerRewrite], afterFiles: originalRewritesResult }; + } + + return { + ...originalRewritesResult, + beforeFiles: [handlerRewrite, ...(originalRewritesResult.beforeFiles || [])], + }; + }; +} + +/** + * Returns the tunnel route handler file relative to `projectDir` (for example `app/sentry-tunnel/route.ts`), or + * `undefined` if the project has none. + * + * Route groups are not searched: the file must be directly in `app/sentry-tunnel/` or `src/app/sentry-tunnel/`. + */ +export function findTunnelRouteHandlerFile( + pageExtensions: string[] = DEFAULT_PAGE_EXTENSIONS, + projectDir: string = process.cwd(), +): string | undefined { + // Like Next.js, ignore `src/app` if there is a root `app` directory. + const appDir = ['app', 'src/app'].find(dir => getStats(path.join(projectDir, dir))?.isDirectory()); + if (!appDir) { + return undefined; + } + + return pageExtensions + .map(extension => `${appDir}${TUNNEL_ROUTE_HANDLER_PATH}/route.${extension}`) + .find(file => getStats(path.join(projectDir, file))?.isFile()); +} + +function getStats(filePath: string): fs.Stats | undefined { + try { + return fs.statSync(filePath); + } catch { + return undefined; + } +} + +/** + * Returns the tunnel path that the client sends envelopes to, and the tunnel route mode. + * Returns no values if there is no tunnel route, or if it cannot work (static export). + * + * Call it after `maybeSetUpTunnelRouteRewriteRules`, which resolves `tunnelRoute: true` to a path. + */ +export function getTunnelRouteInjectedValues( + userNextConfig: NextConfigObject, + userSentryOptions: SentryBuildOptions | undefined, + projectDir: string = process.cwd(), +): TunnelRouteInjectedValues { + const tunnelRoute = userSentryOptions?.tunnelRoute; + if (typeof tunnelRoute !== 'string' || !tunnelRoute || userNextConfig.output === 'export') { + return {}; + } + + return { + _sentryRewritesTunnelPath: `${userNextConfig.basePath ?? ''}${tunnelRoute}`, + _sentryTunnelRouteMode: findTunnelRouteHandlerFile(userNextConfig.pageExtensions, projectDir) + ? 'handler' + : 'rewrite', + }; +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index b073b4822453..e479fa45bd47 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -25,7 +25,7 @@ import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { backfillHttpResponseStatusCode } from '../common/utils/backfillHttpResponseStatusCode'; import { backfillHttpServerStatus } from '../common/utils/backfillHttpServerStatus'; import { createLiveRootSpanAdapter } from '../common/utils/liveRootSpanAdapter'; -import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; +import { dropMiddlewareTunnelRequests, getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; import { maybeForkIsolationScopeForRootSpan } from '../common/utils/forkIsolationScopeForRootSpan'; import { getNormalizedRequestFromAttributes } from '../common/utils/getNormalizedRequestFromAttributes'; import { isBuild } from '../common/utils/isBuild'; @@ -110,6 +110,7 @@ export function init(options: VercelEdgeOptions = {}): void { const nextjsIgnoreSpans: NonNullable = [ // (set in `dropMiddlewareTunnelRequests` during `spanStart`) { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, + ...getTunnelRouteIgnoreSpans(), ]; opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 6dea488a7d9d..3a1bf1ab9250 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -15,6 +15,7 @@ import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sent import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; +import { getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; import { isBuild } from '../common/utils/isBuild'; import { isAsyncContextOwnedByCloudflare, @@ -23,6 +24,7 @@ import { setCloudflareWorkerRelease, } from '../common/utils/responseEnd'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; +import { registerNextSentryPropagator } from './nextSentryPropagator'; import { addNextjsServerSpanHooks, NEXTJS_SERVER_IGNORE_SPANS } from './serverSpanHooks'; import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; import { nextjsUseCacheIntegration } from './useCacheInstrumentation'; @@ -155,7 +157,7 @@ export function init(options: NodeOptions): NodeClient | undefined { ...cloudflareConfig, }; - opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS]; + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, ...getTunnelRouteIgnoreSpans()]; if (DEBUG_BUILD && opts.debug) { debug.enable(); @@ -183,6 +185,14 @@ export function init(options: NodeOptions): NodeClient | undefined { setCloudflareWorkerRelease(opts.release); } + // Next.js reads the trace meta tags of a document through the global propagator. Only the setup the + // Node SDK owns gets the Next.js-aware one here; `withSentry` of `@sentry/nextjs/cloudflare` applies it + // to its own setup, and another OpenTelemetry setup (for example `@vercel/otel`) keeps its own tracer + // provider and propagator. + if (client?.traceProvider) { + registerNextSentryPropagator(client.traceProvider); + } + // On the client, not the global scope, so a later `init()` after // `close()` does not stack another copy. In a request of `withSentry` on // Workers, `init` creates no client, so the global scope gets them, which @@ -230,3 +240,8 @@ export function init(options: NodeOptions): NodeClient | undefined { export * from '../common'; export { wrapApiHandlerWithSentry } from '../common/pages-router-instrumentation/wrapApiHandlerWithSentry'; + +// For apps that set up OpenTelemetry themselves (`enableOpenTelemetrySetup: false`), which otherwise +// write the trace meta tags of a prerendered shell with the propagator they registered. +export { NextSentryPropagator } from './nextSentryPropagator'; +export { markPrerendersOnTracerProvider } from './prerenderContext'; diff --git a/packages/nextjs/src/server/nextSentryPropagator.ts b/packages/nextjs/src/server/nextSentryPropagator.ts new file mode 100644 index 000000000000..1fd3c3438307 --- /dev/null +++ b/packages/nextjs/src/server/nextSentryPropagator.ts @@ -0,0 +1,38 @@ +import type { Context, TextMapSetter, TracerProvider } from '@opentelemetry/api'; +import { propagation } from '@opentelemetry/api'; +import { SentryPropagator } from '@sentry/opentelemetry'; +import { isPrerenderContext, markPrerendersOnTracerProvider } from './prerenderContext'; + +/** + * A `SentryPropagator` that does not hand out the trace meta tags of a document while Next.js + * prerenders it. Trace context baked into a prerendered shell would be replayed to every visitor the + * shell is served to, so the browser could continue a trace that never handled its request. + * + * The SDK registers it when it sets up OpenTelemetry. An app that sets up OpenTelemetry itself + * registers it in place of `SentryPropagator` and calls `markPrerendersOnTracerProvider` with its + * tracer provider, which this propagator relies on to recognize a prerender. + */ +export class NextSentryPropagator extends SentryPropagator { + /** @inheritDoc */ + public inject(ctx: Context, carrier: unknown, setter: TextMapSetter): void { + // Next.js collects the meta tag values into an array; every other caller, such as the outgoing + // request instrumentations, passes a headers object and keeps propagating during a prerender. + if (Array.isArray(carrier) && isPrerenderContext(ctx)) { + return; + } + super.inject(ctx, carrier, setter); + } +} + +/** + * Makes the tracer provider the Node SDK registered mark prerenders on the OpenTelemetry context, and + * replaces the propagator it registered with one that hands out no meta tags on a marked context. + */ +export function registerNextSentryPropagator(provider: TracerProvider): void { + markPrerendersOnTracerProvider(provider); + + // The OpenTelemetry API refuses a second global propagator, so the one from the Node SDK setup has + // to be unregistered first. + propagation.disable(); + propagation.setGlobalPropagator(new NextSentryPropagator()); +} diff --git a/packages/nextjs/src/server/prerenderContext.ts b/packages/nextjs/src/server/prerenderContext.ts new file mode 100644 index 000000000000..e5de40bed829 --- /dev/null +++ b/packages/nextjs/src/server/prerenderContext.ts @@ -0,0 +1,109 @@ +import type { Context, Span, SpanOptions, Tracer, TracerProvider } from '@opentelemetry/api'; +import { context, createContextKey, trace } from '@opentelemetry/api'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; + +const APP_RENDER_BODY_SPAN_TYPE = 'AppRender.getBodyResult'; +const PRERENDER_ROUTE_SPAN_NAME_PREFIX = 'prerender route'; + +const PRERENDER_CONTEXT_KEY = createContextKey('sentry.nextjs.prerender'); + +// Prerender spans started with `startSpan`, which the caller activates on a context of its own. +const prerenderSpans = new WeakSet(); + +/** + * Whether these are the start arguments of the span Next.js wraps a prerender in. Next.js computes the + * `sentry-trace` and `baggage` meta tags of a document inside that span, and a prerender produces a + * shell that is cached and served to later visitors. A render for a request, including the resume of + * a prerendered shell, computes the tags outside of it. + * + * @internal + */ +export function isPrerenderSpanStart(name: string, options: SpanOptions | undefined): boolean { + const attributes = options?.attributes; + const spanName = attributes?.[ATTR_NEXT_SPAN_NAME] ?? name; + return ( + attributes?.[ATTR_NEXT_SPAN_TYPE] === APP_RENDER_BODY_SPAN_TYPE && + typeof spanName === 'string' && + spanName.startsWith(PRERENDER_ROUTE_SPAN_NAME_PREFIX) + ); +} + +/** + * Whether the context belongs to a prerender started through a tracer of a marked provider. + * + * @internal + */ +export function isPrerenderContext(ctx: Context): boolean { + if (ctx.getValue(PRERENDER_CONTEXT_KEY) === true) { + return true; + } + const span = trace.getSpan(ctx); + return !!span && prerenderSpans.has(span); +} + +/** + * Marks a prerender on the OpenTelemetry context rather than on the span. The context is inherited by + * everything that runs inside the prerender, and it exists even when the request is not sampled, in + * which case the prerender span is non-recording and carries no attributes to recognize it by. + */ +function wrapTracer(tracer: Tracer): Tracer { + return { + startSpan: (name, options, ctx) => { + const span = tracer.startSpan(name, options, ctx); + if (isPrerenderSpanStart(name, options)) { + prerenderSpans.add(span); + } + return span; + }, + startActiveSpan: (name: string, ...args: unknown[]) => { + const startActiveSpan = tracer.startActiveSpan.bind(tracer) as (name: string, ...args: unknown[]) => unknown; + const options = typeof args[0] === 'function' ? undefined : (args[0] as SpanOptions | undefined); + if (!isPrerenderSpanStart(name, options)) { + return startActiveSpan(name, ...args); + } + + const explicitContext = args.length === 3 ? (args[1] as Context) : undefined; + if (explicitContext) { + return startActiveSpan(name, args[0], explicitContext.setValue(PRERENDER_CONTEXT_KEY, true), args[2]); + } + return context.with(context.active().setValue(PRERENDER_CONTEXT_KEY, true), () => startActiveSpan(name, ...args)); + }, + }; +} + +const markedProviders = new WeakSet(); + +/** + * Makes a tracer provider mark prerenders of Next.js on the OpenTelemetry context, by wrapping the + * tracers it hands out. `NextSentryPropagator` relies on the mark. The SDK calls this for the provider it + * registers; an app that sets up OpenTelemetry itself calls it with its own provider. Safe to call + * repeatedly with the same provider, which the per-request `init` on Cloudflare does. + */ +export function markPrerendersOnTracerProvider(provider: TracerProvider): void { + if (markedProviders.has(provider)) { + return; + } + markedProviders.add(provider); + + const wrappedTracers = new WeakMap(); + const getTracer = provider.getTracer.bind(provider); + provider.getTracer = (...args) => { + const tracer = getTracer(...args); + let wrapped = wrappedTracers.get(tracer); + if (!wrapped) { + wrapped = wrapTracer(tracer); + wrappedTracers.set(tracer, wrapped); + } + return wrapped; + }; +} + +/** + * The tracer provider behind the global OpenTelemetry API, which is a proxy around the registered one. + * + * @internal + */ +export function getGlobalTracerProvider(): TracerProvider { + const provider = trace.getTracerProvider(); + return 'getDelegate' in provider ? (provider as { getDelegate(): TracerProvider }).getDelegate() : provider; +} diff --git a/packages/nextjs/src/server/useCacheInstrumentation.ts b/packages/nextjs/src/server/useCacheInstrumentation.ts index e51e5e148e05..41901805affa 100644 --- a/packages/nextjs/src/server/useCacheInstrumentation.ts +++ b/packages/nextjs/src/server/useCacheInstrumentation.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import { TraceFlags } from '@opentelemetry/api'; import { CACHE_HIT, CACHE_ITEM_AGE, @@ -16,6 +17,7 @@ import { CACHE_OPERATION_NAMES, debug, defineIntegration, + extractTraceparentData, fill, getActiveSpan, getClient, @@ -23,6 +25,7 @@ import { hasSpansEnabled, LRUMap, spanIsSampled, + spanToTraceHeader, startSpan, timestampInSeconds, } from '@sentry/core'; @@ -70,6 +73,11 @@ interface UseCacheEntry { expire?: number; /** `cacheTag()` tags, excluding implicit soft tags */ tags?: unknown[]; + /** + * Set by Sentry, not by Next.js. Holds the `sentry-trace` header of the fill's `cache.put` span. + * Handlers that keep unknown entry properties carry it to hits on other instances. + */ + __sentryCacheOrigin?: unknown; } type GlobalWithCacheHandlers = typeof globalThis & { @@ -171,8 +179,8 @@ function recordCacheEntry(span: Span, entry: unknown): boolean { return hit; } -// Cache origins are scoped per-process (known limitation: no links across server instances — that would require storing metadata on `CacheEntry`). -// Hits on entries filled by another instance get no link. Lives on `globalThis` like the rest of the instrumentation state. +// Fallback for handlers that drop unknown entry properties. Only a hit on the instance that did the +// fill can use it. Lives on `globalThis`, like the rest of the instrumentation state. function getCacheOrigins(): LRUMap { const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers; if (!globalWithCacheHandlers[SENTRY_CACHE_ORIGINS]) { @@ -182,19 +190,58 @@ function getCacheOrigins(): LRUMap { } /** - * Links a cache hit to the fill that wrote this entry revision (matched by timestamp). - * A hit with no matching origin (unsampled or dropped refill, another instance, process restart) - * gets no link instead of a stale one. + * Links a cache hit to the fill that wrote this entry revision. A hit whose origin is unknown gets + * no link, never a stale one. */ function linkCacheOrigin(span: Span, originKey: string, entry: unknown): void { + const context = getPatchedCacheOrigin(entry) ?? getRememberedCacheOrigin(originKey, entry); + if (context) { + span.addLink({ context, attributes: { [SENTRY_LINK_TYPE]: CACHE_ORIGIN_LINK_TYPE } }); + } +} + +/** + * Next.js writes an entry only after it runs the cached function, and it never writes back a hit. + * So a patched origin always belongs to the entry revision that carries it. Links point only at + * sampled traces. + */ +function getPatchedCacheOrigin(entry: unknown): SpanContextData | undefined { + const { __sentryCacheOrigin } = (entry ?? {}) as UseCacheEntry; + const origin = typeof __sentryCacheOrigin === 'string' ? extractTraceparentData(__sentryCacheOrigin) : undefined; + return origin?.traceId && origin.parentSpanId && origin.parentSampled + ? { traceId: origin.traceId, spanId: origin.parentSpanId, traceFlags: TraceFlags.SAMPLED } + : undefined; +} + +/** The table can outlive the entry revision it remembers, so the fill timestamp must match. */ +function getRememberedCacheOrigin(originKey: string, entry: unknown): SpanContextData | undefined { const origin = getCacheOrigins().get(originKey); const { timestamp } = (entry ?? {}) as UseCacheEntry; - if (origin && origin.entryTimestamp === timestamp) { - span.addLink({ - context: origin.context, - attributes: { [SENTRY_LINK_TYPE]: CACHE_ORIGIN_LINK_TYPE }, - }); - } + return origin && origin.entryTimestamp === timestamp ? origin.context : undefined; +} + +/** + * Handlers get the patched promise, not the original one. Some handlers copy the entry in their own + * `then`, which can run before a callback that is added later. + */ +function patchCacheOrigin(pendingEntry: Promise, origin: string): Promise { + const patchedEntry = Promise.resolve(pendingEntry).then(entry => { + if (typeof entry === 'object' && entry !== null) { + try { + // Patch in place, not on a copy. Handlers can replace `entry.value` on the object they get, + // and Next.js keeps a reference to that object. + (entry as UseCacheEntry).__sentryCacheOrigin = origin; + } catch (error) { + // Frozen or proxied entries reject new properties. Hits then fall back to the table. + DEBUG_BUILD && debug.warn('Failed to patch a Next.js cache fill origin', error); + } + } + return entry; + }); + // A handler can fail the write without reading the entry. A failed entry then has no other + // rejection handler, and Sentry must not cause an unhandled rejection. + patchedEntry.catch(() => undefined); + return patchedEntry; } /** @@ -285,17 +332,21 @@ function instrumentHandler(handler: unknown): void { // The handler drains `pendingEntry` (the still-streaming entry) before storing, so this // span covers producing and storing the entry, not just the write. - return startCacheSpan(CACHE_PUT, digest, sourceFile ? { [CODE_FILE_PATH]: sourceFile } : {}, span => - // Only a successful write becomes a fill origin: a failed write leaves no entry or the - // previous one (whose origin still stands). A dropped span (`ignoreSpans`) never - // reaches Sentry, so a link to it would be broken. - Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { - if (span.isRecording()) { - rememberCacheOrigin(originKeyPrefix + digest, span, pendingEntry); - } + return startCacheSpan(CACHE_PUT, digest, sourceFile ? { [CODE_FILE_PATH]: sourceFile } : {}, span => { + // Read `isRecording()` before the write. The span ends with the write, and a handler can + // resolve the write before it reads the entry. A span dropped by `ignoreSpans` never reaches Sentry, so + // a link to it would be broken. + if (!span.isRecording()) { + return originalSet.call(this, cacheKey, pendingEntry); + } + const patchedEntry = patchCacheOrigin(pendingEntry, spanToTraceHeader(span)); + return Promise.resolve(originalSet.call(this, cacheKey, patchedEntry)).then(result => { + // Only a successful write becomes a fill origin. A failed write leaves no entry or the + // previous one, whose origin still applies. + rememberCacheOrigin(originKeyPrefix + digest, span, patchedEntry); return result; - }), - ); + }); + }); }; }); } catch (error) { diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts new file mode 100644 index 000000000000..09a2c7cdcc43 --- /dev/null +++ b/packages/nextjs/src/tunnel/index.ts @@ -0,0 +1,76 @@ +import { dsnToString, getClient, handleTunnelRequest, makeDsn, suppressTracing } from '@sentry/core'; + +export interface CreateSentryTunnelRouteOptions { + /** + * The DSNs that the route accepts. It rejects envelopes for other DSNs with a 403. + * Defaults to the DSN of the server SDK. + */ + allowedDsns?: string[]; +} + +type TunnelRouteHandler = (request: Request) => Promise; + +/** + * Creates a route handler for the `tunnelRoute`, like `POST`, but with your own DSN allowlist. + * + * In most cases, use `export { POST } from '@sentry/nextjs/tunnel'` instead. Use this function only if the route must + * accept DSNs other than the one of the server SDK, for example a different browser DSN. + * + * @example + * ```ts + * // app/sentry-tunnel/route.ts + * import { createSentryTunnelRoute } from '@sentry/nextjs/tunnel'; + * + * export const { POST } = createSentryTunnelRoute({ + * allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + * }); + * ``` + */ +export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions = {}): { POST: TunnelRouteHandler } { + const allowedDsnsFromOptions = options.allowedDsns?.length ? normalizeDsns(options.allowedDsns) : undefined; + + return { + POST: async request => { + // Read on each request, because the client can be set up after this module loads. + const allowedDsns = allowedDsnsFromOptions ?? getServerDsns(); + if (!allowedDsns.length) { + return new Response( + allowedDsnsFromOptions + ? 'Tunnel route has no valid DSN in allowedDsns.' + : 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.', + { status: 500 }, + ); + } + + // Like the SDK transports, so that the forward creates no spans, breadcrumbs or trace headers. + return suppressTracing(() => handleTunnelRequest({ request, allowedDsns })); + }, + }; +} + +/** + * Route handler for the `tunnelRoute` of `withSentryConfig`. It forwards Sentry envelopes to Sentry, but not the + * request headers (such as cookies). + * + * It accepts envelopes only for the DSN of the server SDK. For other DSNs, use `createSentryTunnelRoute`. + * + * @example + * ```ts + * // app/sentry-tunnel/route.ts + * export { POST } from '@sentry/nextjs/tunnel'; + * ``` + */ +export const POST: TunnelRouteHandler = createSentryTunnelRoute().POST; + +function getServerDsns(): string[] { + const dsn = getClient()?.getDsn(); + return dsn ? [dsnToString(dsn)] : []; +} + +// The same form as the DSN in the envelope header, because `handleTunnelRequest` compares exact strings. +function normalizeDsns(dsns: string[]): string[] { + return dsns.flatMap(dsn => { + const dsnComponents = makeDsn(dsn.trim()); + return dsnComponents ? [dsnToString(dsnComponents)] : []; + }); +} diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index 0f8a49816253..0d8bdded0e0d 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -1,4 +1,6 @@ // @vitest-environment jsdom +import { SENTRY_OP, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +import { PAGELOAD } from '@sentry/conventions/op'; import type { Client } from '@sentry/core'; import type * as SentryCore from '@sentry/core'; import type * as SentryReact from '@sentry/react'; @@ -48,7 +50,6 @@ const manifest: RouteManifest = { hasOptionalPrefix: false, }, ], - isrRoutes: [], }; function sleep(ms: number): Promise { @@ -265,3 +266,73 @@ describe('appRouterInstrumentNavigation with basePath', () => { expect(core.spanToJSON(span!).name).toBe('/my-app/navigation/:param/router-push'); }); }); + +describe('appRouterInstrumentPageLoad', () => { + const originalReadyState = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState'); + + beforeEach(() => { + globalWithNext._sentryRouteManifest = JSON.stringify(manifest); + window.history.replaceState({}, '', '/navigation?from=document'); + Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + vi.stubEnv('_sentryCacheComponents', 'true'); + }); + + afterEach(() => { + delete globalWithNext.next; + delete globalWithNext._sentryRouteManifest; + // @ts-expect-error deleting the instance override restores the prototype getter + delete document.readyState; + if (originalReadyState) { + Object.defineProperty(Document.prototype, 'readyState', originalReadyState); + } + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('records the URL of the document when the pageload starts after a navigation changed it', async () => { + const { core, instrumentation } = await setup('static'); + + instrumentation.appRouterInstrumentPageLoad(core.getClient()!); + const spanBeforeSettling = core.getActiveSpan(); + + window.history.replaceState({}, '', '/navigation/42/router-back'); + instrumentation.settlePendingPageloadWait(); + + const span = core.getActiveSpan(); + expect(span).toBeDefined(); + expect(span).not.toBe(spanBeforeSettling); + const spanJson = core.spanToJSON(span!); + expect(spanJson.name).toBe('/navigation'); + expect(spanJson.attributes?.[SENTRY_OP]).toBe(PAGELOAD); + expect(spanJson.attributes).toEqual( + expect.objectContaining({ + [URL_PATH]: '/navigation', + [URL_FULL]: 'http://localhost:3000/navigation?from=document', + }), + ); + expect(core.getCurrentScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.url).toBe( + 'http://localhost:3000/navigation?from=document', + ); + }); + + // `settlePendingPageloadWait` is a no-op when the pageload did not wait, so both cases run the same steps. + it.each([ + { start: 'at once', cacheComponents: 'false' }, + { start: 'after the wait', cacheComponents: 'true' }, + ])( + 'filters sensitive query parameters from the document URL of a pageload that starts $start', + async ({ cacheComponents }) => { + vi.stubEnv('_sentryCacheComponents', cacheComponents); + window.history.replaceState({}, '', '/navigation?token=2c4f9e1b&from=document'); + const { core, instrumentation } = await setup('static'); + + instrumentation.appRouterInstrumentPageLoad(core.getClient()!); + instrumentation.settlePendingPageloadWait(); + + const filteredUrl = 'http://localhost:3000/navigation?token=[Filtered]&from=document'; + expect(core.spanToJSON(core.getActiveSpan()!).attributes?.[URL_FULL]).toBe(filteredUrl); + expect(core.getCurrentScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.url).toBe(filteredUrl); + }, + ); +}); diff --git a/packages/nextjs/test/client/browserTracingIntegration.test.ts b/packages/nextjs/test/client/browserTracingIntegration.test.ts new file mode 100644 index 000000000000..28e523b2a580 --- /dev/null +++ b/packages/nextjs/test/client/browserTracingIntegration.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { NAVIGATION, PAGELOAD } from '@sentry/conventions/op'; +import type { Span } from '@sentry/core'; +import { + createTransport, + getActiveSpan, + getCurrentScope, + resolvedSyncPromise, + setCurrentClient, + spanToJSON, +} from '@sentry/core'; +import { BrowserClient, startBrowserTracingNavigationSpan } from '@sentry/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { browserTracingIntegration } from '../../src/client/browserTracingIntegration'; + +describe('browserTracingIntegration', () => { + const originalReadyState = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState'); + + beforeEach(() => { + window.history.replaceState({}, '', '/products'); + Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + vi.stubEnv('_sentryCacheComponents', 'true'); + }); + + afterEach(() => { + getCurrentScope().setClient(undefined); + // @ts-expect-error deleting the instance override restores the prototype getter + delete document.readyState; + if (originalReadyState) { + Object.defineProperty(Document.prototype, 'readyState', originalReadyState); + } + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('ends a pageload that waits for its trace meta tag when a navigation starts, and keeps the navigation', () => { + const client = new BrowserClient({ + dsn: 'http://examplePublicKey@localhost/0', + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + stackParser: () => [], + tracesSampleRate: 1, + integrations: [browserTracingIntegration({ instrumentNavigation: false })], + }); + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + setCurrentClient(client); + client.init(); + + const navigationSpan = startBrowserTracingNavigationSpan(client, { name: '/products/42' }); + + expect(endedSpans).toHaveLength(1); + expect(spanToJSON(endedSpans[0]!).attributes?.[SENTRY_OP]).toBe(PAGELOAD); + expect(getActiveSpan()).toBe(navigationSpan); + expect(navigationSpan!.isRecording()).toBe(true); + expect(spanToJSON(navigationSpan!).attributes?.[SENTRY_OP]).toBe(NAVIGATION); + }); +}); diff --git a/packages/nextjs/test/client/isrRoutingTracing.test.ts b/packages/nextjs/test/client/isrRoutingTracing.test.ts deleted file mode 100644 index cdca740727dd..000000000000 --- a/packages/nextjs/test/client/isrRoutingTracing.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { WINDOW } from '@sentry/react'; -import { JSDOM } from 'jsdom'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - IS_ISR_SSG_ROUTE_CACHE, - isIsrSsgRoute, - removeIsrSsgTraceMetaTags, -} from '../../src/client/routing/isrRoutingTracing'; -import type { RouteManifest } from '../../src/config/manifest/types'; - -const globalWithInjectedValues = WINDOW as typeof WINDOW & { - _sentryRouteManifest?: string; -}; - -describe('isrRoutingTracing', () => { - let dom: JSDOM; - - beforeEach(() => { - // Set up a fresh DOM environment for each test - dom = new JSDOM('', { - url: 'https://example.com/', - }); - Object.defineProperty(global, 'document', { value: dom.window.document, writable: true }); - Object.defineProperty(global, 'location', { value: dom.window.location, writable: true }); - - // Clear the injected manifest - delete globalWithInjectedValues._sentryRouteManifest; - }); - - afterEach(() => { - // Clean up - vi.clearAllMocks(); - }); - - describe('removeIsrSsgTraceMetaTags', () => { - const mockManifest: RouteManifest = { - staticRoutes: [{ path: '/' }, { path: '/blog' }], - dynamicRoutes: [ - { - path: '/products/:id', - regex: '^/products/([^/]+?)(?:/)?$', - paramNames: ['id'], - hasOptionalPrefix: false, - }, - { - path: '/posts/:slug', - regex: '^/posts/([^/]+?)(?:/)?$', - paramNames: ['slug'], - hasOptionalPrefix: false, - }, - ], - isrRoutes: ['/', '/blog', '/products/:id', '/posts/:slug'], - }; - - it('should remove meta tags when on a static ISR route', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - const baggageMeta = dom.window.document.createElement('meta'); - baggageMeta.setAttribute('name', 'baggage'); - baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); - dom.window.document.head.appendChild(baggageMeta); - - // Set up route manifest (as stringified JSON, which is how it's injected in production) - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to an ISR route - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/blog' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were removed - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); - }); - - it('should remove meta tags when on a dynamic ISR route', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - const baggageMeta = dom.window.document.createElement('meta'); - baggageMeta.setAttribute('name', 'baggage'); - baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); - dom.window.document.head.appendChild(baggageMeta); - - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to a dynamic ISR route - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/products/123' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were removed - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); - }); - - it('should NOT remove meta tags when on a non-ISR route', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - const baggageMeta = dom.window.document.createElement('meta'); - baggageMeta.setAttribute('name', 'baggage'); - baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); - dom.window.document.head.appendChild(baggageMeta); - - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to a non-ISR route - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/regular-page' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were NOT removed - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).not.toBeNull(); - }); - - it('should handle missing manifest gracefully', () => { - // Clear cache to ensure fresh state - IS_ISR_SSG_ROUTE_CACHE.clear(); - - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - // No manifest set - // globalWithInjectedValues._sentryRouteManifest is undefined - - // Set location - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/blog' }, - writable: true, - }); - - // Call the function (should not throw) - expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); - - // Verify meta tags were NOT removed (no manifest means no ISR detection) - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); - }); - - it('should handle invalid JSON manifest gracefully', () => { - // Clear cache to ensure fresh state - IS_ISR_SSG_ROUTE_CACHE.clear(); - - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - // Set up invalid manifest - globalWithInjectedValues._sentryRouteManifest = 'invalid json {'; - - // Set location - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/blog' }, - writable: true, - }); - - // Call the function (should not throw) - expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); - - // Verify meta tags were NOT removed (invalid manifest means no ISR detection) - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); - }); - - it('should handle manifest with no ISR routes', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - // Set up manifest with no ISR routes - const manifestWithNoISR: RouteManifest = { - staticRoutes: [{ path: '/' }], - dynamicRoutes: [], - isrRoutes: [], - }; - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(manifestWithNoISR); - - // Set location - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were NOT removed (no ISR routes in manifest) - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); - }); - - it('should handle missing meta tags gracefully', () => { - // Set up DOM without meta tags - - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to an ISR route - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/blog' }, - writable: true, - }); - - // Call the function (should not throw) - expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); - - // Verify no errors and still no meta tags - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); - }); - - it('should work with parameterized dynamic routes', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - const baggageMeta = dom.window.document.createElement('meta'); - baggageMeta.setAttribute('name', 'baggage'); - baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); - dom.window.document.head.appendChild(baggageMeta); - - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to a different dynamic ISR route value - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/posts/my-awesome-post' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were removed (should match /posts/:slug) - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); - }); - }); - - describe('isIsrSsgRoute caching', () => { - const mockManifest: RouteManifest = { - staticRoutes: [{ path: '/' }, { path: '/blog' }], - dynamicRoutes: [ - { - path: '/products/:id', - regex: '^/products/([^/]+?)(?:/)?$', - paramNames: ['id'], - hasOptionalPrefix: false, - }, - { - path: '/posts/:slug', - regex: '^/posts/([^/]+?)(?:/)?$', - paramNames: ['slug'], - hasOptionalPrefix: false, - }, - ], - isrRoutes: ['/', '/blog', '/products/:id', '/posts/:slug'], - }; - - beforeEach(() => { - // Clear cache before each test - IS_ISR_SSG_ROUTE_CACHE.clear(); - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - }); - - it('should cache results by parameterized route, not concrete pathname', () => { - // First call with /products/123 - const result1 = isIsrSsgRoute('/products/123'); - expect(result1).toBe(true); - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - - // Second call with different concrete path /products/456 - const result2 = isIsrSsgRoute('/products/456'); - expect(result2).toBe(true); - // Cache size should still be 1 - both paths map to same parameterized route - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - - // Third call with yet another path /products/999 - const result3 = isIsrSsgRoute('/products/999'); - expect(result3).toBe(true); - // Still just 1 cache entry - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - }); - - it('should use cached results on subsequent calls with same route pattern', () => { - // Clear cache - IS_ISR_SSG_ROUTE_CACHE.clear(); - - // First call - cache miss, will populate cache - isIsrSsgRoute('/products/1'); - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - - // Second call with different concrete path - cache hit - const result2 = isIsrSsgRoute('/products/2'); - expect(result2).toBe(true); - // Cache size unchanged - using cached result - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - - // Third call - still cache hit - const result3 = isIsrSsgRoute('/products/3'); - expect(result3).toBe(true); - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - }); - - it('should cache false results for non-ISR routes', () => { - const result1 = isIsrSsgRoute('/not-an-isr-route'); - expect(result1).toBe(false); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/not-an-isr-route')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/not-an-isr-route')).toBe(false); - - // Second call should use cache - const result2 = isIsrSsgRoute('/not-an-isr-route'); - expect(result2).toBe(false); - }); - - it('should cache false results when manifest is invalid', () => { - IS_ISR_SSG_ROUTE_CACHE.clear(); - globalWithInjectedValues._sentryRouteManifest = 'invalid json'; - - const result = isIsrSsgRoute('/any-route'); - expect(result).toBe(false); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/any-route')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/any-route')).toBe(false); - }); - - it('should cache static routes without parameterization', () => { - const result1 = isIsrSsgRoute('/blog'); - expect(result1).toBe(true); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/blog')).toBeDefined(); - - // Second call should use cache - const result2 = isIsrSsgRoute('/blog'); - expect(result2).toBe(true); - }); - - it('should maintain separate cache entries for different route patterns', () => { - // Check multiple different routes - isIsrSsgRoute('/products/1'); - isIsrSsgRoute('/posts/hello'); - isIsrSsgRoute('/blog'); - isIsrSsgRoute('/'); - - // Should have 4 cache entries (one for each unique route pattern) - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(4); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/posts/:slug')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/blog')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/')).toBeDefined(); - }); - - it('should efficiently handle multiple calls to same dynamic route with different params', () => { - // Simulate real-world scenario with many different product IDs - for (let i = 1; i <= 100; i++) { - isIsrSsgRoute(`/products/${i}`); - } - - // Should only have 1 cache entry despite 100 calls - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - }); - }); -}); diff --git a/packages/nextjs/test/client/routeProvider.test.ts b/packages/nextjs/test/client/routeProvider.test.ts index 90721c503867..91ecce723198 100644 --- a/packages/nextjs/test/client/routeProvider.test.ts +++ b/packages/nextjs/test/client/routeProvider.test.ts @@ -14,7 +14,6 @@ let originalDocument: unknown; const MANIFEST = JSON.stringify({ staticRoutes: [{ path: '/about' }], dynamicRoutes: [{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] }], - isrRoutes: [], }); function makeClient(): BrowserClient { diff --git a/packages/nextjs/test/client/traceMetaTags.test.ts b/packages/nextjs/test/client/traceMetaTags.test.ts new file mode 100644 index 000000000000..4b9fde700e92 --- /dev/null +++ b/packages/nextjs/test/client/traceMetaTags.test.ts @@ -0,0 +1,221 @@ +// @vitest-environment jsdom +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; +import type { Span } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + addTraceMetaTagLink, + canWaitForTraceMetaTag, + readTraceMetaTags, + waitForTraceMetaTag, +} from '../../src/client/routing/traceMetaTags'; + +const HEAD_TRACE = '11111111111111111111111111111111-1111111111111111-1'; +const BODY_TRACE = '22222222222222222222222222222222-2222222222222222-1'; + +function appendMeta(parent: HTMLElement, name: string, content: string): HTMLMetaElement { + const meta = document.createElement('meta'); + meta.setAttribute('name', name); + meta.setAttribute('content', content); + parent.appendChild(meta); + return meta; +} + +const originalReadyState = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState'); + +function setReadyState(value: DocumentReadyState): void { + Object.defineProperty(document, 'readyState', { value, configurable: true }); +} + +beforeEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + vi.stubEnv('_sentryCacheComponents', 'true'); +}); + +afterEach(() => { + // @ts-expect-error deleting the instance override restores the prototype getter + delete document.readyState; + if (originalReadyState) { + Object.defineProperty(Document.prototype, 'readyState', originalReadyState); + } + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.useRealTimers(); +}); + +describe('readTraceMetaTags', () => { + it('returns nothing for a document without trace meta tags', () => { + expect(readTraceMetaTags()).toBeUndefined(); + }); + + it('reads a tag from ', () => { + appendMeta(document.head, 'sentry-trace', HEAD_TRACE); + appendMeta(document.head, 'baggage', 'sentry-trace_id=1111'); + + expect(readTraceMetaTags()).toEqual({ sentryTrace: HEAD_TRACE, baggage: 'sentry-trace_id=1111' }); + expect(document.querySelectorAll('meta[name="sentry-trace"]')).toHaveLength(1); + }); + + it('reads a tag from ', () => { + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + + expect(readTraceMetaTags()).toEqual({ sentryTrace: BODY_TRACE, baggage: undefined }); + }); + + it('prefers the last tag in document order', () => { + appendMeta(document.head, 'sentry-trace', HEAD_TRACE); + appendMeta(document.head, 'baggage', 'sentry-trace_id=1111'); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + appendMeta(document.body, 'baggage', 'sentry-trace_id=2222'); + + expect(readTraceMetaTags()).toEqual({ sentryTrace: BODY_TRACE, baggage: 'sentry-trace_id=2222' }); + }); +}); + +describe('canWaitForTraceMetaTag', () => { + it('is true while the document streams and no Server-Timing trace exists', () => { + setReadyState('loading'); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + + expect(canWaitForTraceMetaTag()).toBe(true); + }); + + it('is false when the build did not enable cacheComponents', () => { + vi.stubEnv('_sentryCacheComponents', ''); + setReadyState('loading'); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + + expect(canWaitForTraceMetaTag()).toBe(false); + }); + + it('is false once the document finished parsing', () => { + setReadyState('interactive'); + + expect(canWaitForTraceMetaTag()).toBe(false); + }); + + it('is false when the response carried the trace in Server-Timing', () => { + setReadyState('loading'); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([ + { serverTiming: [{ name: 'sentry-trace', description: BODY_TRACE, duration: 0 }] } as unknown as PerformanceEntry, + ]); + + expect(canWaitForTraceMetaTag()).toBe(false); + }); +}); + +describe('waitForTraceMetaTag', () => { + beforeEach(() => { + setReadyState('loading'); + vi.useFakeTimers(); + }); + + it('reports a tag that is streamed in after the SDK started, once', async () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + appendMeta(document.body, 'baggage', 'sentry-trace_id=2222'); + await vi.advanceTimersByTimeAsync(0); + appendMeta(document.body, 'sentry-trace', HEAD_TRACE); + document.dispatchEvent(new Event('DOMContentLoaded')); + await vi.advanceTimersByTimeAsync(2000); + + expect(onTag).toHaveBeenCalledTimes(1); + expect(onTag).toHaveBeenCalledWith({ sentryTrace: BODY_TRACE, baggage: 'sentry-trace_id=2222' }); + expect(onGiveUp).not.toHaveBeenCalled(); + }); + + it('gives up when the document finished parsing without a tag', async () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + document.dispatchEvent(new Event('DOMContentLoaded')); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + await vi.advanceTimersByTimeAsync(2000); + + expect(onGiveUp).toHaveBeenCalledTimes(1); + expect(onTag).not.toHaveBeenCalled(); + }); + + it('still finds a tag from the last chunk when the document finished parsing', () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + document.dispatchEvent(new Event('DOMContentLoaded')); + + expect(onTag).toHaveBeenCalledWith({ sentryTrace: BODY_TRACE, baggage: undefined }); + expect(onGiveUp).not.toHaveBeenCalled(); + }); + + it('gives up after the timeout while the document is still streaming', async () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + await vi.advanceTimersByTimeAsync(1000); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + await vi.advanceTimersByTimeAsync(0); + + expect(onGiveUp).toHaveBeenCalledTimes(1); + expect(onTag).not.toHaveBeenCalled(); + }); + + it('gives up early when asked, once', async () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + const wait = waitForTraceMetaTag(onTag, onGiveUp, 1000); + wait.giveUp(); + wait.giveUp(); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + await vi.advanceTimersByTimeAsync(2000); + + expect(onGiveUp).toHaveBeenCalledTimes(1); + expect(onTag).not.toHaveBeenCalled(); + }); + + it('gives up right away when the document already finished parsing', async () => { + setReadyState('complete'); + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + + expect(onGiveUp).toHaveBeenCalledTimes(1); + expect(onTag).not.toHaveBeenCalled(); + }); +}); + +describe('addTraceMetaTagLink', () => { + it('links the server request described by the sentry-trace value and carries the sampled flag', () => { + const span = { addLink: vi.fn() } as unknown as Span; + + addTraceMetaTagLink(span, { sentryTrace: HEAD_TRACE, baggage: undefined }); + addTraceMetaTagLink(span, { + sentryTrace: '33333333333333333333333333333333-3333333333333333-0', + baggage: undefined, + }); + + expect(span.addLink).toHaveBeenNthCalledWith(1, { + context: { traceId: '11111111111111111111111111111111', spanId: '1111111111111111', traceFlags: 1 }, + attributes: { [SENTRY_LINK_TYPE]: 'server_request' }, + }); + expect(span.addLink).toHaveBeenNthCalledWith(2, { + context: { traceId: '33333333333333333333333333333333', spanId: '3333333333333333', traceFlags: 0 }, + attributes: { [SENTRY_LINK_TYPE]: 'server_request' }, + }); + }); + + it('ignores values that are not a sentry-trace header', () => { + const span = { addLink: vi.fn() } as unknown as Span; + + addTraceMetaTagLink(span, { sentryTrace: 'not-a-trace', baggage: undefined }); + + expect(span.addLink).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index 03cd83dbe8b6..9f16580546c7 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -193,7 +193,6 @@ describe('Client init()', () => { globalWithManifest._sentryRouteManifest = JSON.stringify({ staticRoutes: [{ path: '/' }], dynamicRoutes: [], - isrRoutes: [], }); init({ dsn: TEST_DSN, tracesSampleRate: 1.0 }); diff --git a/packages/nextjs/test/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts index ff3395f62b4e..d24bb75f3f61 100644 --- a/packages/nextjs/test/cloudflare/withSentry.test.ts +++ b/packages/nextjs/test/cloudflare/withSentry.test.ts @@ -16,7 +16,7 @@ import { } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { withSentry } from '../../src/cloudflare'; -import { ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; import { init as initEdge } from '../../src/edge'; import { init as initServer } from '../../src/server'; import { NEXTJS_SERVER_IGNORE_SPANS } from '../../src/server/serverSpanHooks'; @@ -257,6 +257,40 @@ describe('withSentry', () => { expect(carrier['sentry-trace']).toBe(`${activeSpan?.spanContext().traceId}-${activeSpan?.spanContext().spanId}-1`); }); + it('injects no meta tags while Next.js prerenders a page in a request', async () => { + // Next.js collects the meta tag values with an array carrier. + const carrier: Array<{ key: string; value: string }> = []; + const requestCarrier: Record = {}; + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: () => { + trace.getTracer('next.js').startActiveSpan( + 'prerender route (app) /isr', + { + attributes: { + [ATTR_NEXT_SPAN_TYPE]: 'AppRender.getBodyResult', + [ATTR_NEXT_SPAN_NAME]: 'prerender route (app) /isr', + }, + }, + span => { + propagation.inject(context.active(), carrier, { set: (c, key, value) => c.push({ key, value }) }); + span.end(); + }, + ); + propagation.inject(context.active(), requestCarrier); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(carrier).toEqual([]); + expect(requestCarrier['sentry-trace']).toMatch(/^[0-9a-f]{32}-[0-9a-f]{16}-1$/); + }); + it('does not set the global propagator when the callback turns off the OpenTelemetry setup', async () => { const setGlobalPropagator = vi.spyOn(propagation, 'setGlobalPropagator'); const handler = withSentry(() => ({ dsn: DSN, enableOpenTelemetrySetup: false }), { diff --git a/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts b/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts index a22af530b332..bf0255071375 100644 --- a/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts +++ b/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts @@ -17,7 +17,6 @@ describe('routeManifestInjection.exclude', () => { { path: '/admin/users/:id', regex: '^/admin/users/([^/]+)$', paramNames: ['id'] }, { path: '/secret-feature/:id', regex: '^/secret-feature/([^/]+)$', paramNames: ['id'] }, ], - isrRoutes: ['/blog', '/admin/reports', '/internal/stats'], }; describe('with no filter', () => { @@ -34,7 +33,6 @@ describe('routeManifestInjection.exclude', () => { // All routes containing '/admin' are excluded expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']); expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']); - expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']); }); it('should exclude routes matching multiple string patterns', () => { @@ -47,7 +45,6 @@ describe('routeManifestInjection.exclude', () => { '/internal/secret', '/public/page', ]); - expect(result.isrRoutes).toEqual(['/admin/reports', '/internal/stats']); }); it('should match substrings anywhere in the route', () => { @@ -71,14 +68,12 @@ describe('routeManifestInjection.exclude', () => { expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']); expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']); - expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']); }); it('should support multiple regex patterns', () => { const result = filterRouteManifest(mockManifest, [/^\/admin/, /^\/internal/]); expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']); - expect(result.isrRoutes).toEqual(['/blog']); }); it('should support partial regex matches', () => { @@ -116,7 +111,6 @@ describe('routeManifestInjection.exclude', () => { expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']); expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']); - expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']); }); it('should support complex filter logic', () => { @@ -127,7 +121,6 @@ describe('routeManifestInjection.exclude', () => { expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']); expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id']); - expect(result.isrRoutes).toEqual(['/blog']); }); }); @@ -136,7 +129,6 @@ describe('routeManifestInjection.exclude', () => { const emptyManifest: RouteManifest = { staticRoutes: [], dynamicRoutes: [], - isrRoutes: [], }; const result = filterRouteManifest(emptyManifest, [/admin/]); @@ -148,7 +140,6 @@ describe('routeManifestInjection.exclude', () => { expect(result.staticRoutes).toEqual([]); expect(result.dynamicRoutes).toEqual([]); - expect(result.isrRoutes).toEqual([]); }); it('should handle filter that excludes nothing', () => { diff --git a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts index 7a9a88033d85..097e3f603693 100644 --- a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts +++ b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts @@ -19,7 +19,6 @@ describe('basePath', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts index 4e8e62199592..8d78f24a0986 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts @@ -16,7 +16,6 @@ describe('catchall', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index 34b9334dba05..d259a1a38223 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -16,7 +16,6 @@ describe('catchall', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts index fb0111d941e5..2ea4b4aca5d8 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -34,7 +34,6 @@ describe('dynamic', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts index 79daf2d4d58c..2c898b1e8e96 100644 --- a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts @@ -16,7 +16,6 @@ describe('file-extensions', () => { { path: '/typescript' }, ], dynamicRoutes: [], - isrRoutes: [], }); }); }); diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx deleted file mode 100644 index 24b26184353a..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Nested dynamic ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx deleted file mode 100644 index 027e160f91d3..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Static ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx deleted file mode 100644 index f98be769d9c3..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Optional catchall ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx deleted file mode 100644 index b22caba78d2f..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Required catchall ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx deleted file mode 100644 index b4aef2560f50..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Static ISR page at root -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx deleted file mode 100644 index a68fb122c81d..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Dynamic ISR with async function -export const generateStaticParams = async (): Promise => {}; diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx deleted file mode 100644 index f18f341f1f43..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Dynamic ISR page with generateStaticParams -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx deleted file mode 100644 index b46b50a71d44..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Regular page without ISR (no generateStaticParams) -export {}; diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx deleted file mode 100644 index 3774c141885d..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Mixed static-dynamic ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts b/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts deleted file mode 100644 index 56d097df0180..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -import path from 'path'; -import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; - -describe('ISR route detection and matching', () => { - const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); - - describe('ISR detection', () => { - test('should detect static ISR pages with generateStaticParams', () => { - expect(manifest.isrRoutes).toContain('/'); - expect(manifest.isrRoutes).toContain('/blog'); - }); - - test('should detect dynamic ISR pages with generateStaticParams', () => { - expect(manifest.isrRoutes).toContain('/products/:id'); - expect(manifest.isrRoutes).toContain('/posts/:slug'); - }); - - test('should detect nested dynamic ISR pages', () => { - expect(manifest.isrRoutes).toContain('/articles/:category/:slug'); - }); - - test('should detect optional catchall ISR pages', () => { - expect(manifest.isrRoutes).toContain('/docs/:path*?'); - }); - - test('should detect required catchall ISR pages', () => { - expect(manifest.isrRoutes).toContain('/guides/:segments*'); - }); - - test('should detect mixed static-dynamic ISR pages', () => { - expect(manifest.isrRoutes).toContain('/users/:id/profile'); - }); - - test('should NOT detect pages without generateStaticParams as ISR', () => { - expect(manifest.isrRoutes).not.toContain('/regular'); - }); - - test('should detect both function and const generateStaticParams', () => { - // /blog uses function declaration - // /posts/[slug] uses const declaration - expect(manifest.isrRoutes).toContain('/blog'); - expect(manifest.isrRoutes).toContain('/posts/:slug'); - }); - - test('should detect async generateStaticParams', () => { - // Multiple pages use async - this should work - expect(manifest.isrRoutes).toContain('/products/:id'); - expect(manifest.isrRoutes).toContain('/posts/:slug'); - }); - }); - - describe('Route matching against pathnames', () => { - describe('single dynamic segment ISR routes', () => { - test('should match /products/:id against various product IDs', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - expect(regex.test('/products/1')).toBe(true); - expect(regex.test('/products/123')).toBe(true); - expect(regex.test('/products/abc-def')).toBe(true); - expect(regex.test('/products/product-with-dashes')).toBe(true); - expect(regex.test('/products/UPPERCASE')).toBe(true); - - // Should NOT match - expect(regex.test('/products')).toBe(false); - expect(regex.test('/products/')).toBe(false); - expect(regex.test('/products/123/extra')).toBe(false); - expect(regex.test('/product/123')).toBe(false); // typo - }); - - test('should match /posts/:slug against various slugs', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/posts/:slug'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - expect(regex.test('/posts/hello')).toBe(true); - expect(regex.test('/posts/world')).toBe(true); - expect(regex.test('/posts/my-awesome-post')).toBe(true); - expect(regex.test('/posts/post_with_underscores')).toBe(true); - - // Should NOT match - expect(regex.test('/posts')).toBe(false); - expect(regex.test('/posts/')).toBe(false); - expect(regex.test('/posts/hello/world')).toBe(false); - }); - }); - - describe('nested dynamic segments ISR routes', () => { - test('should match /articles/:category/:slug against various paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/articles/:category/:slug'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - expect(regex.test('/articles/tech/nextjs-guide')).toBe(true); - expect(regex.test('/articles/tech/react-tips')).toBe(true); - expect(regex.test('/articles/programming/typescript-advanced')).toBe(true); - expect(regex.test('/articles/news/breaking-news-2024')).toBe(true); - - // Should NOT match - expect(regex.test('/articles')).toBe(false); - expect(regex.test('/articles/tech')).toBe(false); - expect(regex.test('/articles/tech/nextjs-guide/extra')).toBe(false); - - // Extract parameters - const match = '/articles/tech/nextjs-guide'.match(regex); - expect(match).toBeTruthy(); - expect(match?.[1]).toBe('tech'); - expect(match?.[2]).toBe('nextjs-guide'); - }); - }); - - describe('mixed static-dynamic ISR routes', () => { - test('should match /users/:id/profile against user profile paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/users/:id/profile'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - expect(regex.test('/users/user1/profile')).toBe(true); - expect(regex.test('/users/user2/profile')).toBe(true); - expect(regex.test('/users/john-doe/profile')).toBe(true); - expect(regex.test('/users/123/profile')).toBe(true); - - // Should NOT match - expect(regex.test('/users/user1')).toBe(false); - expect(regex.test('/users/user1/profile/edit')).toBe(false); - expect(regex.test('/users/profile')).toBe(false); - expect(regex.test('/user/user1/profile')).toBe(false); // typo - - // Extract parameter - const match = '/users/john-doe/profile'.match(regex); - expect(match).toBeTruthy(); - expect(match?.[1]).toBe('john-doe'); - }); - }); - - describe('optional catchall ISR routes', () => { - test('should match /docs/:path*? against various documentation paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - with paths - expect(regex.test('/docs/getting-started')).toBe(true); - expect(regex.test('/docs/api/reference')).toBe(true); - expect(regex.test('/docs/guides/installation/quick-start')).toBe(true); - expect(regex.test('/docs/a')).toBe(true); - expect(regex.test('/docs/a/b/c/d/e')).toBe(true); - - // Should match - without path (optional catchall) - expect(regex.test('/docs')).toBe(true); - - // Should NOT match - expect(regex.test('/doc')).toBe(false); // typo - expect(regex.test('/')).toBe(false); - expect(regex.test('/documents/test')).toBe(false); - - // Extract parameters - const matchWithPath = '/docs/api/reference'.match(regex); - expect(matchWithPath).toBeTruthy(); - expect(matchWithPath?.[1]).toBe('api/reference'); - - const matchNoPath = '/docs'.match(regex); - expect(matchNoPath).toBeTruthy(); - // Optional catchall without path - expect(matchNoPath?.[1]).toBeUndefined(); - }); - }); - - describe('required catchall ISR routes', () => { - test('should match /guides/:segments* against guide paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/guides/:segments*'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - with paths (required) - expect(regex.test('/guides/intro')).toBe(true); - expect(regex.test('/guides/advanced/topics')).toBe(true); - expect(regex.test('/guides/getting-started/installation/setup')).toBe(true); - - // Should NOT match - without path (required catchall needs at least one segment) - expect(regex.test('/guides')).toBe(false); - expect(regex.test('/guides/')).toBe(false); - - // Should NOT match - wrong path - expect(regex.test('/guide/intro')).toBe(false); // typo - expect(regex.test('/')).toBe(false); - - // Extract parameters - const match = '/guides/advanced/topics'.match(regex); - expect(match).toBeTruthy(); - expect(match?.[1]).toBe('advanced/topics'); - }); - }); - - describe('real-world pathname simulations', () => { - test('should identify ISR pages from window.location.pathname examples', () => { - const testCases = [ - { pathname: '/', isISR: true, matchedRoute: '/' }, - { pathname: '/blog', isISR: true, matchedRoute: '/blog' }, - { pathname: '/products/123', isISR: true, matchedRoute: '/products/:id' }, - { pathname: '/products/gaming-laptop', isISR: true, matchedRoute: '/products/:id' }, - { pathname: '/posts/hello-world', isISR: true, matchedRoute: '/posts/:slug' }, - { pathname: '/articles/tech/nextjs-guide', isISR: true, matchedRoute: '/articles/:category/:slug' }, - { pathname: '/users/john/profile', isISR: true, matchedRoute: '/users/:id/profile' }, - { pathname: '/docs', isISR: true, matchedRoute: '/docs/:path*?' }, - { pathname: '/docs/getting-started', isISR: true, matchedRoute: '/docs/:path*?' }, - { pathname: '/docs/api/reference/advanced', isISR: true, matchedRoute: '/docs/:path*?' }, - { pathname: '/guides/intro', isISR: true, matchedRoute: '/guides/:segments*' }, - { pathname: '/guides/advanced/topics/performance', isISR: true, matchedRoute: '/guides/:segments*' }, - { pathname: '/regular', isISR: false, matchedRoute: null }, - ]; - - testCases.forEach(({ pathname, isISR, matchedRoute }) => { - // Check if pathname matches any ISR route - let foundMatch = false; - let foundRoute = null; - - // Check static ISR routes - if (manifest.isrRoutes.includes(pathname)) { - foundMatch = true; - foundRoute = pathname; - } - - // Check dynamic ISR routes - if (!foundMatch) { - for (const route of manifest.dynamicRoutes) { - if (manifest.isrRoutes.includes(route.path)) { - const regex = new RegExp(route.regex!); - if (regex.test(pathname)) { - foundMatch = true; - foundRoute = route.path; - break; - } - } - } - } - - expect(foundMatch).toBe(isISR); - if (matchedRoute) { - expect(foundRoute).toBe(matchedRoute); - } - }); - }); - }); - - describe('edge cases and special characters', () => { - test('should handle paths with special characters in dynamic segments', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); - const regex = new RegExp(route!.regex!); - - expect(regex.test('/products/product-123')).toBe(true); - expect(regex.test('/products/product_456')).toBe(true); - expect(regex.test('/products/PRODUCT-ABC')).toBe(true); - expect(regex.test('/products/2024-new-product')).toBe(true); - }); - - test('should handle deeply nested catchall paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); - const regex = new RegExp(route!.regex!); - - expect(regex.test('/docs/a/b/c/d/e/f/g/h/i/j')).toBe(true); - }); - - test('should not match paths with trailing slashes if route does not have them', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); - const regex = new RegExp(route!.regex!); - - // Most Next.js routes don't match trailing slashes - expect(regex.test('/products/123/')).toBe(false); - }); - }); - - describe('parameter extraction for ISR routes', () => { - test('should extract single parameter from ISR route', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); - const regex = new RegExp(route!.regex!); - - const match = '/products/gaming-laptop'.match(regex); - expect(match).toBeTruthy(); - expect(route?.paramNames).toEqual(['id']); - expect(match?.[1]).toBe('gaming-laptop'); - }); - - test('should extract multiple parameters from nested ISR route', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/articles/:category/:slug'); - const regex = new RegExp(route!.regex!); - - const match = '/articles/programming/typescript-advanced'.match(regex); - expect(match).toBeTruthy(); - expect(route?.paramNames).toEqual(['category', 'slug']); - expect(match?.[1]).toBe('programming'); - expect(match?.[2]).toBe('typescript-advanced'); - }); - - test('should extract catchall parameter from ISR route', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); - const regex = new RegExp(route!.regex!); - - const match = '/docs/api/reference/advanced'.match(regex); - expect(match).toBeTruthy(); - expect(route?.paramNames).toEqual(['path']); - expect(match?.[1]).toBe('api/reference/advanced'); - }); - }); - }); - - describe('complete manifest structure', () => { - test('should have correct structure with all route types', () => { - expect(manifest).toHaveProperty('staticRoutes'); - expect(manifest).toHaveProperty('dynamicRoutes'); - expect(manifest).toHaveProperty('isrRoutes'); - expect(Array.isArray(manifest.staticRoutes)).toBe(true); - expect(Array.isArray(manifest.dynamicRoutes)).toBe(true); - expect(Array.isArray(manifest.isrRoutes)).toBe(true); - }); - - test('should include both ISR and non-ISR routes in main route lists', () => { - // ISR static routes should be in staticRoutes - expect(manifest.staticRoutes.some(r => r.path === '/')).toBe(true); - expect(manifest.staticRoutes.some(r => r.path === '/blog')).toBe(true); - - // Non-ISR static routes should also be in staticRoutes - expect(manifest.staticRoutes.some(r => r.path === '/regular')).toBe(true); - - // ISR dynamic routes should be in dynamicRoutes - expect(manifest.dynamicRoutes.some(r => r.path === '/products/:id')).toBe(true); - }); - - test('should only include ISR routes in isrRoutes list', () => { - // ISR routes should be in the list - expect(manifest.isrRoutes).toContain('/'); - expect(manifest.isrRoutes).toContain('/blog'); - expect(manifest.isrRoutes).toContain('/products/:id'); - - // Non-ISR routes should NOT be in the list - expect(manifest.isrRoutes).not.toContain('/regular'); - }); - }); -}); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index 32ac315b3571..e34dcde80600 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -29,7 +29,6 @@ describe('route-groups', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); // Verify we have 9 static routes total (including duplicates from special chars) expect(manifest.staticRoutes).toHaveLength(9); @@ -79,7 +78,6 @@ describe('route-groups', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); expect(manifest.staticRoutes).toHaveLength(9); }); diff --git a/packages/nextjs/test/config/manifest/suites/static/static.test.ts b/packages/nextjs/test/config/manifest/suites/static/static.test.ts index 6701ef0875d4..a6f03f49b6fe 100644 --- a/packages/nextjs/test/config/manifest/suites/static/static.test.ts +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -8,7 +8,6 @@ describe('static', () => { expect(manifest).toEqual({ staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], dynamicRoutes: [], - isrRoutes: [], }); }); }); diff --git a/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts b/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts index 6d46c20b1e11..6de4a3a697fd 100644 --- a/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts +++ b/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts @@ -15,12 +15,10 @@ describe('maybeSetClientTraceMetadataOption', () => { expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace', 'my-custom-key']); }); - it('does NOT enable trace meta tags when Cache Components is enabled', () => { - // With Cache Components, the shell is prerendered/detached from the request, so the meta-tag - // trace would be stale. The SDK skips enabling `clientTraceMetadata` so the browser pageload - // starts a fresh trace instead of stitching onto a stale trace. + it('enables trace meta tags when Cache Components is enabled', () => { + // The server withholds the tags while it prerenders, see `nextSentryPropagator.ts`. const config: NextConfigObject = { cacheComponents: true }; maybeSetClientTraceMetadataOption(config, '16.0.0'); - expect(config.experimental?.clientTraceMetadata).toBeUndefined(); + expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace']); }); }); diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index d647c4d78ccf..844ac3f97644 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -32,7 +32,6 @@ describe('constructTurbopackConfig', () => { { path: '/users', regex: '/users' }, { path: '/api/health', regex: '/api/health' }, ], - isrRoutes: [], }; const mockSentryOptions = {}; @@ -285,7 +284,7 @@ describe('constructTurbopackConfig', () => { describe('with edge cases', () => { it('should handle empty route manifest', () => { const userNextConfig: NextConfigObject = {}; - const emptyManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [], isrRoutes: [] }; + const emptyManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [] }; const result = constructTurbopackConfig({ userNextConfig, @@ -325,7 +324,6 @@ describe('constructTurbopackConfig', () => { it('should handle complex route manifest', () => { const userNextConfig: NextConfigObject = {}; const complexManifest: RouteManifest = { - isrRoutes: [], dynamicRoutes: [ { path: '/users/[id]/posts/[postId]', regex: '/users/([^/]+)/posts/([^/]+)', paramNames: ['id', 'postId'] }, { path: '/api/[...params]', regex: '/api/(.+)', paramNames: ['params'] }, @@ -901,7 +899,7 @@ describe('condition field version gating', () => { const result = constructTurbopackConfig({ userNextConfig, - routeManifest: { dynamicRoutes: [], staticRoutes: [], isrRoutes: [] }, + routeManifest: { dynamicRoutes: [], staticRoutes: [] }, nextJsVersion: '17.0.0', }); @@ -1064,7 +1062,6 @@ describe('moduleMetadataInjection with applicationKey', () => { const mockRouteManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [{ path: '/', regex: '/' }], - isrRoutes: [], }; const result = constructTurbopackConfig({ @@ -1264,7 +1261,6 @@ describe('componentAnnotation with turbopackReactComponentAnnotation', () => { const mockRouteManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [{ path: '/', regex: '/' }], - isrRoutes: [], }; const result = constructTurbopackConfig({ diff --git a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts index d4fed2368101..d68c002f298b 100644 --- a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts +++ b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts @@ -305,6 +305,33 @@ describe('generateValueInjectionRules', () => { }); }); + describe('with tunnel route values', () => { + it('injects the tunnel path and mode into the client and server bundles', () => { + const result = generateValueInjectionRules({ + tunnelRouteValues: { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, + }); + + const injectedValues = result.map(({ matcher, rule }) => [ + matcher, + (rule as { loaders: Array<{ options: { values: Record } }> }).loaders[0]?.options.values, + ]); + expect(injectedValues).toEqual([ + [ + '**/instrumentation-client.*', + { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, + ], + [ + '**/instrumentation.*', + { + _sentryRewritesTunnelPath: '/monitoring', + _sentryTunnelRouteMode: 'handler', + __SENTRY_SERVER_MODULES__: expect.any(Object), + }, + ], + ]); + }); + }); + describe('path resolution', () => { it('should call path.resolve with correct arguments', () => { const pathResolveSpy = vi.spyOn(path, 'resolve'); diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index bab62624c86a..766a05f583c8 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -1,7 +1,10 @@ // mock helper functions not tested directly in this file import '../mocks'; import * as coreServer from '@sentry/core/server'; -import { describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import type * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import * as getBuildPluginOptionsModule from '../../../src/config/getBuildPluginOptions'; import { CLIENT_SDK_CONFIG_FILE, @@ -15,6 +18,9 @@ import { } from '../fixtures'; import { materializeFinalNextConfig, materializeFinalWebpackConfig } from '../testUtils'; +const realFs = await vi.importActual('fs'); +const realOs = await vi.importActual('os'); + // Only the plugin factory is stubbed — `resolveOrchestrionRuntimeRequest` must stay real because // the externals handler under test uses it. vi.mock('@sentry/server-utils/orchestrion/webpack', async importOriginal => ({ @@ -822,4 +828,39 @@ describe('constructWebpackConfigFunction()', () => { expect(finalWebpackConfig.externals).toBeUndefined(); }); }); + + describe('tunnel route value injection', () => { + let projectDir: string | undefined; + let cwdSpy: ReturnType | undefined; + + afterEach(() => { + vi.mocked(fs.statSync).mockReset(); + cwdSpy?.mockRestore(); + if (projectDir) { + realFs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('injects the tunnel path and the handler mode', async () => { + projectDir = realFs.mkdtempSync(path.join(realOs.tmpdir(), 'sentry-nextjs-webpack-tunnel-')); + realFs.mkdirSync(path.join(projectDir, 'app/sentry-tunnel'), { recursive: true }); + realFs.writeFileSync(path.join(projectDir, 'app/sentry-tunnel/route.ts'), ''); + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + vi.mocked(fs.statSync).mockImplementation(realFs.statSync); + + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig: userNextConfig, + incomingWebpackConfig: clientWebpackConfig, + incomingWebpackBuildContext: clientBuildContext, + sentryBuildTimeOptions: { tunnelRoute: '/monitoring' }, + }); + + const valueInjectionLoader = finalWebpackConfig.module.rules + .flatMap(rule => rule.use ?? []) + .find(loader => String(loader.loader).endsWith('valueInjectionLoader.js')); + expect(valueInjectionLoader?.options).toMatchObject({ + values: { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, + }); + }); + }); }); diff --git a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts index b81e3c48ee86..b30f6f708855 100644 --- a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts @@ -57,4 +57,39 @@ describe('setUpBuildTimeVariables', () => { expect(nextConfig.env._sentryHasPagesRouter).toBe('true'); }); }); + + describe('tunnel route', () => { + it('adds the tunnel path and the handler mode for a project with a tunnel route handler', () => { + const nextConfig = { basePath: '/docs' }; + + setUpBuildTimeVariables( + nextConfig, + { tunnelRoute: '/monitoring' }, + undefined, + makeProject(['app/sentry-tunnel/route.ts']), + ); + + expect(nextConfig).toEqual({ + basePath: '/docs', + env: expect.objectContaining({ + _sentryRewritesTunnelPath: '/docs/monitoring', + _sentryTunnelRouteMode: 'handler', + }), + }); + }); + }); +}); + +describe('_sentryCacheComponents', () => { + it('is set when the Next.js config enables cacheComponents', () => { + const config: NextConfigObject = { cacheComponents: true }; + setUpBuildTimeVariables(config, {}, undefined); + expect(config.env?._sentryCacheComponents).toBe('true'); + }); + + it('is left unset otherwise', () => { + const config: NextConfigObject = {}; + setUpBuildTimeVariables(config, {}, undefined); + expect(config.env?._sentryCacheComponents).toBeUndefined(); + }); }); diff --git a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts new file mode 100644 index 000000000000..cfda40eaa28f --- /dev/null +++ b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NextConfigObject } from '../../../src/config/types'; +import { makeProject, removeProjects } from './projectFixture'; + +const HANDLER_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; + +afterEach(() => { + removeProjects(); +}); + +describe('maybeSetUpTunnelRouteRewriteRules', () => { + beforeEach(() => { + vi.restoreAllMocks(); + // A fresh module per test, because the function logs each message only once. + vi.resetModules(); + delete process.env.__SENTRY_TUNNEL_ROUTE__; + }); + + async function setUp(files: string[]) { + vi.spyOn(process, 'cwd').mockReturnValue(makeProject(files)); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { maybeSetUpTunnelRouteRewriteRules } = + await import('../../../src/config/withSentryConfig/getFinalConfigObjectUtils'); + return { maybeSetUpTunnelRouteRewriteRules, logSpy, warnSpy }; + } + + it('sets up the handler rewrite when the project has a tunnel route handler', async () => { + const { maybeSetUpTunnelRouteRewriteRules } = await setUp(['app/sentry-tunnel/route.ts']); + const nextConfig: NextConfigObject = {}; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ beforeFiles: [HANDLER_REWRITE], afterFiles: [] }); + }); + + it('logs the tunnel route handler file once', async () => { + const { maybeSetUpTunnelRouteRewriteRules, logSpy } = await setUp(['app/sentry-tunnel/route.ts']); + + maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring' }); + maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring' }); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] The tunnel route sends events through app/sentry-tunnel/route.ts. Only the event payload goes to Sentry.', + ); + }); + + it('sets up the external rewrite when the project has no tunnel route handler', async () => { + const { maybeSetUpTunnelRouteRewriteRules, logSpy } = await setUp(['app/page.tsx']); + const nextConfig: NextConfigObject = {}; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); + + await expect(nextConfig.rewrites?.()).resolves.toEqual([ + { + source: '/monitoring(/?)', + has: [ + { type: 'query', key: 'o', value: '(?\\d*)' }, + { type: 'query', key: 'p', value: '(?\\d*)' }, + { type: 'query', key: 'r', value: '(?[a-z]{2})' }, + ], + destination: 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0', + }, + { + source: '/monitoring(/?)', + has: [ + { type: 'query', key: 'o', value: '(?\\d*)' }, + { type: 'query', key: 'p', value: '(?\\d*)' }, + ], + destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0', + }, + ]); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('does not log with `silent`', async () => { + const { maybeSetUpTunnelRouteRewriteRules, logSpy } = await setUp(['app/sentry-tunnel/route.ts']); + + maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring', silent: true }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('warns once when the project has a tunnel route handler but `tunnelRoute` is not set', async () => { + const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['src/app/sentry-tunnel/route.tsx']); + const nextConfig: NextConfigObject = {}; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, {}); + maybeSetUpTunnelRouteRewriteRules({}, {}); + + expect(nextConfig.rewrites).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] Found src/app/sentry-tunnel/route.tsx, but the `tunnelRoute` option is not set. Set `tunnelRoute` in `withSentryConfig` to send events through this route.', + ); + }); + + it('sets up no rewrite for a static export with a tunnel route handler', async () => { + const { maybeSetUpTunnelRouteRewriteRules, logSpy, warnSpy } = await setUp(['app/sentry-tunnel/route.ts']); + const nextConfig: NextConfigObject = { output: 'export' }; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); + + expect(nextConfig.rewrites).toBeUndefined(); + expect(logSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('will not work in combination with Next.js static exports'), + ); + }); + + it.each([ + ['`tunnelRoute` is `false`', {}, { tunnelRoute: false }], + ['`tunnelRoute` is not set in a static export', { output: 'export' }, {}], + ])('does not warn about the tunnel route handler when %s', async (_description, nextConfig, sentryOptions) => { + const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['app/sentry-tunnel/route.ts']); + + maybeSetUpTunnelRouteRewriteRules(nextConfig, sentryOptions); + + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/test/config/withSentryConfig/projectFixture.ts b/packages/nextjs/test/config/withSentryConfig/projectFixture.ts new file mode 100644 index 000000000000..ececc652755b --- /dev/null +++ b/packages/nextjs/test/config/withSentryConfig/projectFixture.ts @@ -0,0 +1,23 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const projectDirs: string[] = []; + +/** Creates a temporary project directory with empty `files`. `removeProjects` deletes it. */ +export function makeProject(files: string[]): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nextjs-project-')); + projectDirs.push(dir); + for (const file of files) { + const absolute = path.join(dir, file); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, ''); + } + return dir; +} + +export function removeProjects(): void { + for (const dir of projectDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts new file mode 100644 index 000000000000..cc39280aa7d0 --- /dev/null +++ b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { NextConfigObject } from '../../../src/config/types'; +import { + findTunnelRouteHandlerFile, + getTunnelRouteInjectedValues, + setUpTunnelHandlerRewrite, +} from '../../../src/config/withSentryConfig/tunnel'; +import { makeProject, removeProjects } from './projectFixture'; + +const HANDLER_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; + +afterEach(() => { + removeProjects(); +}); + +describe('findTunnelRouteHandlerFile', () => { + it.each([ + ['app/sentry-tunnel/route.ts', ['app/page.tsx', 'app/sentry-tunnel/route.ts']], + ['src/app/sentry-tunnel/route.js', ['src/app/page.tsx', 'src/app/sentry-tunnel/route.js']], + ])('finds %s', (expectedFile, files) => { + expect(findTunnelRouteHandlerFile(undefined, makeProject(files))).toBe(expectedFile); + }); + + // Next.js finds `pages` and `app` separately, so a root `pages` does not hide `src/app`, only `src/pages` + // Next.js 16 rejects this case at build time. + // https://github.com/vercel/next.js/blob/7a7d48862287fd5caecd99da41149ecbddf37320/packages/next/src/lib/find-pages-dir.ts#L4-L13 + it('finds the file in `src/app` next to a root `pages` directory', () => { + const projectDir = makeProject(['pages/index.tsx', 'src/app/sentry-tunnel/route.ts']); + + expect(findTunnelRouteHandlerFile(undefined, projectDir)).toBe('src/app/sentry-tunnel/route.ts'); + }); + + it('ignores `src/app` when there is a root `app` directory', () => { + const projectDir = makeProject(['app/page.tsx', 'src/app/sentry-tunnel/route.ts']); + + expect(findTunnelRouteHandlerFile(undefined, projectDir)).toBeUndefined(); + }); + + it('finds the file only with a whole `pageExtensions` entry', () => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts', 'app/sentry-tunnel/route.page.ts']); + + expect(findTunnelRouteHandlerFile(['page.ts'], projectDir)).toBe('app/sentry-tunnel/route.page.ts'); + }); + + it('does not find a file that does not match any `pageExtensions` entry', () => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + + expect(findTunnelRouteHandlerFile(['mdx', 'tsx'], projectDir)).toBeUndefined(); + }); + + it.each([ + ['a route group', ['app/(monitoring)/sentry-tunnel/route.ts']], + ['a Pages Router project', ['pages/sentry-tunnel/route.ts']], + ['a directory named like the route file', ['app/sentry-tunnel/route.ts/page.tsx']], + ])('does not find the file in %s', (_description, files) => { + expect(findTunnelRouteHandlerFile(undefined, makeProject(files))).toBeUndefined(); + }); +}); + +describe('getTunnelRouteInjectedValues', () => { + it('returns the handler mode when the project has a tunnel route handler', () => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + + expect(getTunnelRouteInjectedValues({}, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ + _sentryRewritesTunnelPath: '/monitoring', + _sentryTunnelRouteMode: 'handler', + }); + }); + + it('returns the rewrite mode when the project has no tunnel route handler', () => { + const projectDir = makeProject(['app/page.tsx']); + + expect(getTunnelRouteInjectedValues({}, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ + _sentryRewritesTunnelPath: '/monitoring', + _sentryTunnelRouteMode: 'rewrite', + }); + }); + + it('adds the basePath to the tunnel path', () => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + + expect(getTunnelRouteInjectedValues({ basePath: '/docs' }, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ + _sentryRewritesTunnelPath: '/docs/monitoring', + _sentryTunnelRouteMode: 'handler', + }); + }); + + it.each([ + ['`tunnelRoute` is not set', {}, {}], + ['`tunnelRoute` is an empty string', { basePath: '/docs' }, { tunnelRoute: '' }], + ['`tunnelRoute` is still `true`', {}, { tunnelRoute: true }], + ['the app is a static export', { output: 'export' }, { tunnelRoute: '/monitoring' }], + ])('returns no values when %s', (_description, nextConfig, sentryOptions) => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + + expect(getTunnelRouteInjectedValues(nextConfig, sentryOptions, projectDir)).toEqual({}); + }); +}); + +describe('setUpTunnelHandlerRewrite', () => { + it('adds the handler rewrite to `beforeFiles` when there are no user rewrites', async () => { + const nextConfig: NextConfigObject = {}; + + setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ beforeFiles: [HANDLER_REWRITE], afterFiles: [] }); + }); + + it('keeps user rewrites in array form as `afterFiles`', async () => { + const userRewrite = { source: '/blog/:slug', destination: '/news/:slug' }; + const nextConfig: NextConfigObject = { rewrites: async () => [userRewrite] }; + + setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ + beforeFiles: [HANDLER_REWRITE], + afterFiles: [userRewrite], + }); + }); + + it('puts the handler rewrite before the user `beforeFiles` rewrites', async () => { + const userBeforeFilesRewrite = { source: '/old', destination: '/new' }; + const userFallbackRewrite = { source: '/:path*', destination: 'https://legacy.example.com/:path*' }; + const nextConfig: NextConfigObject = { + rewrites: async () => ({ beforeFiles: [userBeforeFilesRewrite], fallback: [userFallbackRewrite] }), + }; + + setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ + beforeFiles: [HANDLER_REWRITE, userBeforeFilesRewrite], + fallback: [userFallbackRewrite], + }); + }); +}); diff --git a/packages/nextjs/test/server/nextSentryPropagator.test.ts b/packages/nextjs/test/server/nextSentryPropagator.test.ts new file mode 100644 index 000000000000..03c2e40e4e2d --- /dev/null +++ b/packages/nextjs/test/server/nextSentryPropagator.test.ts @@ -0,0 +1,162 @@ +import { context, propagation, trace } from '@opentelemetry/api'; +import { getCurrentScope, setCurrentClient, startSpan } from '@sentry/core'; +import { NodeClient, getDefaultIntegrations } from '@sentry/node'; +import { SentryPropagator, SentryTracerProvider } from '@sentry/opentelemetry'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; +import { NextSentryPropagator, registerNextSentryPropagator } from '../../src/server/nextSentryPropagator'; +import { isPrerenderContext, isPrerenderSpanStart } from '../../src/server/prerenderContext'; + +const PRERENDER_SPAN_OPTIONS = { + attributes: { + [ATTR_NEXT_SPAN_TYPE]: 'AppRender.getBodyResult', + [ATTR_NEXT_SPAN_NAME]: 'prerender route (app) /[id]', + }, +}; + +const RENDER_SPAN_OPTIONS = { + attributes: { + [ATTR_NEXT_SPAN_TYPE]: 'AppRender.getBodyResult', + [ATTR_NEXT_SPAN_NAME]: 'render route (app) /blocking', + }, +}; + +// Next.js collects the meta tag values with an array carrier, see `getTracePropagationData` of its tracer. +function inject(): Record { + const entries: Array<{ key: string; value: string }> = []; + propagation.inject(context.active(), entries, { + set: (c, key, value) => (c as typeof entries).push({ key, value }), + }); + return Object.fromEntries(entries.map(({ key, value }) => [key, value])); +} + +function injectHeaders(): Record { + const headers: Record = {}; + propagation.inject(context.active(), headers); + return headers; +} + +function setup(tracesSampleRate: number): void { + const client = new NodeClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate, + enableOpenTelemetrySetup: true, + integrations: getDefaultIntegrations({}), + stackParser: () => [], + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); + setCurrentClient(client); + client.init(); + + const provider = new SentryTracerProvider(); + trace.setGlobalTracerProvider(provider); + propagation.setGlobalPropagator(new SentryPropagator()); + registerNextSentryPropagator(provider); +} + +describe('NextSentryPropagator', () => { + beforeEach(() => setup(1)); + + afterEach(() => { + getCurrentScope().setClient(undefined); + trace.disable(); + context.disable(); + propagation.disable(); + vi.restoreAllMocks(); + }); + + it('replaces the registered global propagator', () => { + const injectSpy = vi.spyOn(NextSentryPropagator.prototype, 'inject'); + inject(); + expect(injectSpy).toHaveBeenCalledTimes(1); + }); + + it('hands out trace context while a route is rendered for a request', () => { + trace.getTracer('next.js').startActiveSpan('render route (app) /blocking', RENDER_SPAN_OPTIONS, span => { + expect(isPrerenderContext(context.active())).toBe(false); + expect(inject()).toEqual({ + 'sentry-trace': `${span.spanContext().traceId}-${span.spanContext().spanId}-1`, + baggage: expect.stringContaining('sentry-trace_id='), + }); + span.end(); + }); + }); + + it('hands out nothing while a route is prerendered', () => { + trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => { + expect(isPrerenderContext(context.active())).toBe(true); + expect(inject()).toEqual({}); + span.end(); + }); + }); + + it('keeps propagating to outgoing requests while a route is prerendered', () => { + trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => { + expect(inject()).toEqual({}); + expect(injectHeaders()['sentry-trace']).toBe(`${span.spanContext().traceId}-${span.spanContext().spanId}-1`); + span.end(); + }); + }); + + it('hands out nothing inside a span nested in a prerender', () => { + trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => { + startSpan({ name: 'server component' }, () => { + expect(inject()).toEqual({}); + }); + span.end(); + }); + }); + + it('hands out nothing while a route is prerendered in an unsampled trace', () => { + setup(0); + + trace.getTracer('next.js').startActiveSpan('GET /[id]', span => { + expect(span.isRecording()).toBe(false); + expect(inject()['sentry-trace']).toMatch(/-0$/); + + trace + .getTracer('next.js') + .startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, prerenderSpan => { + expect(prerenderSpan.isRecording()).toBe(false); + expect(inject()).toEqual({}); + prerenderSpan.end(); + }); + span.end(); + }); + }); + + it('recognizes a prerender span that was started inactive and activated by the caller', () => { + const span = trace.getTracer('next.js').startSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS); + context.with(trace.setSpan(context.active(), span), () => { + expect(isPrerenderContext(context.active())).toBe(true); + expect(inject()).toEqual({}); + }); + span.end(); + + const renderSpan = trace.getTracer('next.js').startSpan('render route (app) /blocking', RENDER_SPAN_OPTIONS); + context.with(trace.setSpan(context.active(), renderSpan), () => { + expect(inject()['sentry-trace']).toBeDefined(); + }); + renderSpan.end(); + }); + + it('marks an explicitly passed context', () => { + trace + .getTracer('next.js') + .startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, context.active(), span => { + expect(isPrerenderContext(context.active())).toBe(true); + expect(inject()).toEqual({}); + span.end(); + }); + }); + + it('does not mistake other Next.js spans for a prerender', () => { + expect( + isPrerenderSpanStart('prerender route (app) /x', { + attributes: { [ATTR_NEXT_SPAN_TYPE]: 'NextNodeServer.getLayoutOrPageModule' }, + }), + ).toBe(false); + expect(isPrerenderSpanStart('prerender route (app) /x', undefined)).toBe(false); + expect(isPrerenderSpanStart('prerender route (app) /x', PRERENDER_SPAN_OPTIONS)).toBe(true); + }); +}); diff --git a/packages/nextjs/test/server/useCacheInstrumentation.test.ts b/packages/nextjs/test/server/useCacheInstrumentation.test.ts index 587b57bf9820..325e28aef451 100644 --- a/packages/nextjs/test/server/useCacheInstrumentation.test.ts +++ b/packages/nextjs/test/server/useCacheInstrumentation.test.ts @@ -5,15 +5,34 @@ const mocks = vi.hoisted(() => { const setAttribute = vi.fn(); const addLink = vi.fn(); const state = { spanCount: 0, recording: true }; + // Valid hex ids, because the SDK parses the stored origin as a `sentry-trace` header. + const spanContextOf = (n: number) => ({ + traceId: `${n}`.padStart(32, 'a'), + spanId: `${n}`.padStart(16, 'b'), + traceFlags: 1, + }); return { setAttribute, addLink, state, + spanContextOf, startSpan: vi.fn((_options: unknown, callback: (span: unknown) => unknown) => { const n = ++state.spanCount; - const spanContext = { traceId: `trace-${n}`, spanId: `span-${n}`, traceFlags: 1 }; + const spanContext = spanContextOf(n); const recording = state.recording; - return callback({ setAttribute, addLink, spanContext: () => spanContext, isRecording: () => recording }); + let ended = false; + const result = callback({ + setAttribute, + addLink, + spanContext: () => spanContext, + isRecording: () => recording && !ended, + }); + // The span ends when the callback's promise settles, as with `startSpan`. + void Promise.resolve(result).then( + () => (ended = true), + () => (ended = true), + ); + return result; }), activeSpan: undefined as object | undefined, sampled: true, @@ -45,14 +64,53 @@ function createHandler(entry?: unknown) { }; } +/** Keeps the entry object it gets, like Next.js' built-in in-memory handler. */ +function createMemoryHandler() { + let stored: unknown; + return { + get: vi.fn((_key: string) => Promise.resolve(stored)), + set: vi.fn(async (_key: string, pendingEntry: Promise) => { + stored = await pendingEntry; + }), + }; +} + +/** Stores the entry as JSON in a store that all instances share. */ +function createSharedStoreHandler() { + let stored: string | undefined; + return { + get: vi.fn((_key: string) => Promise.resolve(stored === undefined ? undefined : JSON.parse(stored))), + set: vi.fn(async (_key: string, pendingEntry: Promise) => { + stored = JSON.stringify(await pendingEntry); + }), + }; +} + +/** Stores only the entry fields it knows, so the patched origin is lost. */ +function createFieldPickingHandler(entry: { timestamp?: number; expire?: number }) { + return { + get: vi.fn((_key: string) => Promise.resolve({ timestamp: entry.timestamp, expire: entry.expire })), + set: vi.fn((_key: string, pendingEntry: Promise) => pendingEntry.then(() => undefined)), + }; +} + +function installHandlers(handlers: Record): void { + setGlobal(NEXT_CACHE_HANDLERS_MAP, new Map(Object.entries(handlers))); + _instrumentUseCacheHandlers(); +} + +/** A hit served by another instance starts with an empty origin table. */ +function simulateNewServerInstance(): void { + Reflect.deleteProperty(globalThis, SENTRY_CACHE_ORIGINS); +} + function setGlobal(symbol: symbol, value: unknown): void { (globalThis as Record)[symbol] = value; } function installWithDefaultHandler(entry?: unknown) { const handler = createHandler(entry); - setGlobal(NEXT_CACHE_HANDLERS_MAP, new Map([['default', handler]])); - _instrumentUseCacheHandlers(); + installHandlers({ default: handler }); return handler; } @@ -332,6 +390,11 @@ describe('instrumentUseCacheHandlers', () => { }); describe('origin links', () => { + const originLink = (n: number) => ({ + context: mocks.spanContextOf(n), + attributes: { 'sentry.link.type': 'cache_origin' }, + }); + it('links a cache hit to the `cache.put` span of the fill', async () => { const entry = { timestamp: nowMs() }; const handler = installWithDefaultHandler(entry); @@ -339,11 +402,74 @@ describe('instrumentUseCacheHandlers', () => { await handler.set('cache-key', Promise.resolve(entry)); await handler.get('cache-key'); - expect(mocks.addLink).toHaveBeenCalledTimes(1); - expect(mocks.addLink).toHaveBeenCalledWith({ - context: { traceId: 'trace-1', spanId: 'span-1', traceFlags: 1 }, - attributes: { 'sentry.link.type': 'cache_origin' }, - }); + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(1)); + }); + + it('links a hit served by another instance from the origin stored on the entry', async () => { + const handler = createSharedStoreHandler(); + installHandlers({ remote: handler }); + + await handler.set('cache-key', Promise.resolve({ timestamp: nowMs() })); + simulateNewServerInstance(); + await handler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(1)); + }); + + it('does not link a hit served by another instance after an unsampled refill', async () => { + const handler = createSharedStoreHandler(); + installHandlers({ remote: handler }); + + await handler.set('cache-key', Promise.resolve({ timestamp: nowMs() - 1_000 })); + mocks.sampled = false; + await handler.set('cache-key', Promise.resolve({ timestamp: nowMs() })); + mocks.sampled = true; + simulateNewServerInstance(); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('patches an entry that resolves after the write resolved', async () => { + // The handler resolves the write at once and stores the entry later, after the + // `cache.put` span has ended. + let stored: object | undefined; + const handler = { + get: vi.fn((_key: string) => Promise.resolve(stored)), + set: vi.fn((_key: string, pendingEntry: Promise) => { + void pendingEntry.then(entry => (stored = { ...entry })); + return Promise.resolve(); + }), + }; + installHandlers({ remote: handler }); + let resolveEntry: (entry: object) => void = () => undefined; + + await handler.set('cache-key', new Promise(resolve => (resolveEntry = resolve))); + resolveEntry({}); + await new Promise(resolve => setImmediate(resolve)); + simulateNewServerInstance(); + await handler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(1)); + }); + + it('patches the entry before a handler that copies it in its own `then`', async () => { + let stored: object | undefined; + const handler = { + get: vi.fn((_key: string) => Promise.resolve(stored)), + set: vi.fn((_key: string, pendingEntry: Promise) => + pendingEntry.then(entry => { + stored = { ...entry }; + }), + ), + }; + installHandlers({ default: handler }); + + await handler.set('cache-key', Promise.resolve({})); + simulateNewServerInstance(); + await handler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(1)); }); it('links to the most recent fill', async () => { @@ -354,9 +480,7 @@ describe('instrumentUseCacheHandlers', () => { await handler.set('cache-key', Promise.resolve(entry)); await handler.get('cache-key'); - expect(mocks.addLink).toHaveBeenCalledWith( - expect.objectContaining({ context: { traceId: 'trace-2', spanId: 'span-2', traceFlags: 1 } }), - ); + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(2)); }); it('does not link a miss', async () => { @@ -376,34 +500,13 @@ describe('instrumentUseCacheHandlers', () => { expect(mocks.addLink).not.toHaveBeenCalled(); }); - it('does not link a hit to a fill of the same cache key in a different handler', async () => { - const entry = { timestamp: nowMs() }; - const defaultHandler = createHandler(entry); - const remoteHandler = createHandler(entry); - setGlobal( - NEXT_CACHE_HANDLERS_MAP, - new Map([ - ['default', defaultHandler], - ['remote', remoteHandler], - ]), - ); - _instrumentUseCacheHandlers(); - - await defaultHandler.set('cache-key', Promise.resolve(entry)); - await remoteHandler.get('cache-key'); - - expect(mocks.addLink).not.toHaveBeenCalled(); - - await defaultHandler.get('cache-key'); - - expect(mocks.addLink).toHaveBeenCalledTimes(1); - }); - - it('does not link a hit that was filled under a different cache key', async () => { - const entry = { timestamp: nowMs() }; - const handler = installWithDefaultHandler(entry); + it.each([ + { name: 'not a string', storedOrigin: 42 }, + { name: 'malformed', storedOrigin: 'not-a-sentry-trace-header' }, + { name: 'an unsampled fill', storedOrigin: `${'a'.repeat(32)}-${'b'.repeat(16)}-0` }, + ])('does not link a hit whose stored origin is $name', async ({ storedOrigin }) => { + const handler = installWithDefaultHandler({ timestamp: nowMs(), __sentryCacheOrigin: storedOrigin }); - await handler.set('other-key', Promise.resolve(entry)); await handler.get('cache-key'); expect(mocks.addLink).not.toHaveBeenCalled(); @@ -440,7 +543,7 @@ describe('instrumentUseCacheHandlers', () => { expect(mocks.addLink).not.toHaveBeenCalled(); }); - it('does not link a hit on an entry refilled by another server instance', async () => { + it('does not link a hit on an entry refilled by an uninstrumented server instance', async () => { const entryFromOtherInstance = { timestamp: nowMs() }; const handler = installWithDefaultHandler(entryFromOtherInstance); @@ -450,39 +553,107 @@ describe('instrumentUseCacheHandlers', () => { expect(mocks.addLink).not.toHaveBeenCalled(); }); - it('does not link hits on entries that carry no fill timestamp', async () => { - const entry = { expire: 3_600 }; - const handler = installWithDefaultHandler(entry); + it('does not cause an unhandled rejection when the handler fails without reading a failed entry', async () => { + const handler = { get: vi.fn(), set: vi.fn(() => Promise.reject(new Error('store unavailable'))) }; + installHandlers({ default: handler }); + const onUnhandledRejection = vi.fn(); + process.on('unhandledRejection', onUnhandledRejection); - await handler.set('cache-key', Promise.resolve(entry)); - await handler.get('cache-key'); + try { + const failedEntry = Promise.reject(new Error('cached function failed')); + failedEntry.catch(() => undefined); + await expect(handler.set('cache-key', failedEntry)).rejects.toThrow('store unavailable'); + await new Promise(resolve => setImmediate(resolve)); + } finally { + process.off('unhandledRejection', onUnhandledRejection); + } - expect(mocks.addLink).not.toHaveBeenCalled(); + expect(onUnhandledRejection).not.toHaveBeenCalled(); }); - it('does not remember fills whose entry rejected even though the write resolved', async () => { - // Custom handlers can swallow a failed entry and resolve the write anyway. - const handler = installWithDefaultHandler({ timestamp: nowMs() }); + describe('origin table fallback', () => { + it('links a hit on the filling instance when the handler drops the stored origin', async () => { + const entry = { timestamp: nowMs() }; + const handler = createFieldPickingHandler(entry); + installHandlers({ default: handler }); - await handler.set('cache-key', Promise.reject(new Error('entry failed'))); - await handler.get('cache-key'); + await handler.set('cache-key', Promise.resolve(entry)); + await handler.get('cache-key'); - expect(mocks.addLink).not.toHaveBeenCalled(); - }); + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(1)); + }); - it('does not remember fills whose write failed', async () => { - const entry = { timestamp: nowMs() }; - const handler = { - get: vi.fn(() => Promise.resolve(entry)), - set: vi.fn(() => Promise.reject(new Error('write failed'))), - }; - setGlobal(NEXT_CACHE_HANDLERS_MAP, new Map([['default', handler]])); - _instrumentUseCacheHandlers(); + it('still writes and links an entry that cannot be patched', async () => { + const handler = createMemoryHandler(); + installHandlers({ default: handler }); - await expect(handler.set('cache-key', Promise.resolve(entry))).rejects.toThrow('write failed'); - await handler.get('cache-key'); + await handler.set('cache-key', Promise.resolve(Object.freeze({ timestamp: nowMs() }))); + await handler.get('cache-key'); - expect(mocks.addLink).not.toHaveBeenCalled(); + expect(mocks.addLink).toHaveBeenCalledExactlyOnceWith(originLink(1)); + }); + + it('does not link a hit to a fill of the same cache key in a different handler', async () => { + const entry = { timestamp: nowMs() }; + const defaultHandler = createFieldPickingHandler(entry); + const remoteHandler = createFieldPickingHandler(entry); + installHandlers({ default: defaultHandler, remote: remoteHandler }); + + await defaultHandler.set('cache-key', Promise.resolve(entry)); + await remoteHandler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + + await defaultHandler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledTimes(1); + }); + + it('does not link a hit that was filled under a different cache key', async () => { + const entry = { timestamp: nowMs() }; + const handler = createFieldPickingHandler(entry); + installHandlers({ default: handler }); + + await handler.set('other-key', Promise.resolve(entry)); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not remember fills whose entry carries no fill timestamp', async () => { + const entry = { expire: 3_600 }; + const handler = createFieldPickingHandler(entry); + installHandlers({ default: handler }); + + await handler.set('cache-key', Promise.resolve(entry)); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not remember fills whose entry rejected even though the write resolved', async () => { + // Custom handlers can swallow a failed entry and resolve the write anyway. + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.set('cache-key', Promise.reject(new Error('entry failed'))); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not remember fills whose write failed', async () => { + const entry = { timestamp: nowMs() }; + const handler = { + get: vi.fn(() => Promise.resolve({ timestamp: entry.timestamp })), + set: vi.fn(() => Promise.reject(new Error('write failed'))), + }; + installHandlers({ default: handler }); + + await expect(handler.set('cache-key', Promise.resolve(entry))).rejects.toThrow('write failed'); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 61d15736a3d0..7ef42977be47 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -6,6 +6,13 @@ import * as SentryNode from '@sentry/node'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/server'; +import type * as NextSentryPropagatorModule from '../src/server/nextSentryPropagator'; +import { registerNextSentryPropagator } from '../src/server/nextSentryPropagator'; + +vi.mock('../src/server/nextSentryPropagator', async importOriginal => ({ + ...(await importOriginal()), + registerNextSentryPropagator: vi.fn(), +})); // normally this is set as part of the build process, so mock it here (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir: string })._sentryRewriteFramesDistDir = '.next'; @@ -63,6 +70,33 @@ describe('Server init()', () => { ); }); + describe('trace propagator', () => { + it('replaces the propagator when the Node SDK set up OpenTelemetry itself', () => { + const traceProvider = {}; + nodeInit.mockReturnValueOnce({ + on: vi.fn(), + addEventProcessor: vi.fn(), + traceProvider, + } as unknown as ReturnType); + + init({}); + + expect(registerNextSentryPropagator).toHaveBeenCalledWith(traceProvider); + }); + + it('leaves a foreign OpenTelemetry setup alone', () => { + nodeInit.mockReturnValueOnce({ + on: vi.fn(), + addEventProcessor: vi.fn(), + traceProvider: undefined, + } as unknown as ReturnType); + + init({}); + + expect(registerNextSentryPropagator).not.toHaveBeenCalled(); + }); + }); + it("doesn't reinitialize the node SDK if already initialized", () => { expect(nodeInit).toHaveBeenCalledTimes(0); init({}); diff --git a/packages/nextjs/test/tunnel/index.test.ts b/packages/nextjs/test/tunnel/index.test.ts new file mode 100644 index 000000000000..0df557baa736 --- /dev/null +++ b/packages/nextjs/test/tunnel/index.test.ts @@ -0,0 +1,150 @@ +import type * as SentryCore from '@sentry/core'; +import { createEnvelope, makeDsn, serializeEnvelope } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSentryTunnelRoute, POST } from '../../src/tunnel'; + +const { mockGetClient } = vi.hoisted(() => ({ mockGetClient: vi.fn() })); + +vi.mock('@sentry/core', async importOriginal => ({ + ...(await importOriginal()), + getClient: mockGetClient, +})); + +const SERVER_DSN = 'https://public@o1.ingest.sentry.io/1'; +const BROWSER_DSN = 'https://browser@o1.ingest.sentry.io/2'; + +function createEnvelopeRequest(dsn: string): { request: Request; body: Uint8Array } { + const envelope = serializeEnvelope( + createEnvelope({ dsn, event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', message: 'Checkout failed' }], + ]), + ); + const body = typeof envelope === 'string' ? new TextEncoder().encode(envelope) : envelope; + const request = new Request('https://shop.example.com/monitoring', { + method: 'POST', + headers: { + cookie: 'session=8f2c1d', + 'content-type': 'text/plain;charset=UTF-8', + 'content-length': String(body.byteLength), + }, + body, + }); + return { request, body }; +} + +function useClientDsn(dsn: string | undefined): void { + mockGetClient.mockReturnValue(dsn ? { getDsn: () => makeDsn(dsn) } : undefined); +} + +let fetchSpy: ReturnType; + +beforeEach(() => { + vi.restoreAllMocks(); + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 200 })); + useClientDsn(undefined); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('POST', () => { + it('forwards only the envelope to the Sentry host of an allowed DSN', async () => { + useClientDsn(SERVER_DSN); + const { request, body } = createEnvelopeRequest(SERVER_DSN); + + const response = await POST(request); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://o1.ingest.sentry.io/api/1/envelope/?sentry_version=7&sentry_key=public', + { + method: 'POST', + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + body, + }, + ); + }); + + it('responds with 500 when the server SDK has no DSN', async () => { + const { request } = createEnvelopeRequest(SERVER_DSN); + + const response = await POST(request); + + expect(response.status).toBe(500); + await expect(response.text()).resolves.toBe( + 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.', + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects an envelope for a DSN that is not the server DSN', async () => { + useClientDsn(SERVER_DSN); + const { request } = createEnvelopeRequest(BROWSER_DSN); + + const response = await POST(request); + + expect(response.status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does not accept DSNs from environment variables', async () => { + useClientDsn(SERVER_DSN); + vi.stubEnv('SENTRY_DSN', BROWSER_DSN); + vi.stubEnv('NEXT_PUBLIC_SENTRY_DSN', BROWSER_DSN); + const { request } = createEnvelopeRequest(BROWSER_DSN); + + const response = await POST(request); + + expect(response.status).toBe(403); + }); +}); + +describe('createSentryTunnelRoute', () => { + it('accepts only the DSNs in `allowedDsns`', async () => { + useClientDsn(SERVER_DSN); + const { POST: handler } = createSentryTunnelRoute({ allowedDsns: [BROWSER_DSN] }); + + const allowedResponse = await handler(createEnvelopeRequest(BROWSER_DSN).request); + const rejectedResponse = await handler(createEnvelopeRequest(SERVER_DSN).request); + + expect(allowedResponse.status).toBe(200); + expect(rejectedResponse.status).toBe(403); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['omitted', {}], + ['empty', { allowedDsns: [] }], + ])('accepts the server DSN when `allowedDsns` is %s', async (_description, options) => { + useClientDsn(SERVER_DSN); + const { POST: handler } = createSentryTunnelRoute(options); + + const response = await handler(createEnvelopeRequest(SERVER_DSN).request); + + expect(response.status).toBe(200); + }); + + it('accepts an allowed DSN with a secret and whitespace', async () => { + const { POST: handler } = createSentryTunnelRoute({ + allowedDsns: [' https://public:secret@o1.ingest.sentry.io/1\n'], + }); + + const response = await handler(createEnvelopeRequest(SERVER_DSN).request); + + expect(response.status).toBe(200); + }); + + it('responds with 500 when no allowed DSN is valid', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + useClientDsn(SERVER_DSN); + const { POST: handler } = createSentryTunnelRoute({ allowedDsns: ['not-a-dsn'] }); + + const response = await handler(createEnvelopeRequest(SERVER_DSN).request); + + expect(response.status).toBe(500); + await expect(response.text()).resolves.toBe('Tunnel route has no valid DSN in allowedDsns.'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts index a635d5d65be5..5fa91525bd68 100644 --- a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -1,9 +1,16 @@ +import { HTTP_TARGET, SERVER_ADDRESS, URL_PATH } from '@sentry/conventions/attributes'; +import { shouldIgnoreSpan } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { dropMiddlewareTunnelRequests } from '../../src/common/utils/dropMiddlewareTunnelRequests'; +import { + dropMiddlewareTunnelRequests, + getTunnelRouteIgnoreSpans, +} from '../../src/common/utils/dropMiddlewareTunnelRequests'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../../src/common/span-attributes-with-logic-attached'; const globalWithInjectedValues = global as typeof global & { _sentryRewritesTunnelPath?: string; + _sentryTunnelRouteMode?: string; }; vi.mock('@sentry/core', async requireActual => { @@ -27,6 +34,7 @@ function createMockSpan(): { setAttribute: ReturnType; attributes: beforeEach(() => { globalWithInjectedValues._sentryRewritesTunnelPath = undefined; + globalWithInjectedValues._sentryTunnelRouteMode = undefined; }); describe('dropMiddlewareTunnelRequests', () => { @@ -152,3 +160,101 @@ describe('dropMiddlewareTunnelRequests', () => { }); }); }); + +describe('getTunnelRouteIgnoreSpans', () => { + function isIgnored(attributes: Record): boolean { + return shouldIgnoreSpan({ description: 'POST', op: 'http.server', attributes }, getTunnelRouteIgnoreSpans()); + } + + // Next.js sets the deprecated `http.target` on its request spans. + function nextRequestSpanAttributes(spanType: string, target: string): Record { + // eslint-disable-next-line typescript/no-deprecated + return { [ATTR_NEXT_SPAN_TYPE]: spanType, [HTTP_TARGET]: target }; + } + + it('returns no entries when there is no tunnel route', () => { + expect(getTunnelRouteIgnoreSpans()).toEqual([]); + }); + + it.each([ + ['BaseServer.handleRequest', '/monitoring?o=123&p=456'], + ['BaseServer.handleRequest', '/monitoring/?o=123&p=456&r=us'], + ['Middleware.execute', '/monitoring?o=123&p=456'], + ])('ignores a %s span for %s in rewrite mode', (spanType, target) => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + + expect(isIgnored(nextRequestSpanAttributes(spanType, target))).toBe(true); + }); + + it.each([ + '/monitoring', + '/monitoring?o=123', + '/monitoring?xo=123&p=456', + '/monitoring?o=123&p=456&o=abc', + '/monitoring?p=456&o=123', + '/monitoring/dashboard?o=123&p=456', + '/monitoring-dashboard?o=123&p=456', + '/api/monitoring?o=123&p=456', + ])('does not ignore a request span for %s in rewrite mode, which the rewrite does not serve', target => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', target))).toBe(false); + }); + + it.each(['/monitoring', '/monitoring/', '/monitoring?cache=0'])( + 'ignores a request span for %s in handler mode', + target => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', target))).toBe(true); + }, + ); + + it('does not ignore a request span for a sub-path in handler mode', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', '/monitoring/dashboard'))).toBe(false); + }); + + it('does not ignore an outgoing request with the tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + + const outgoingRequestAttributes = { + [URL_PATH]: '/monitoring', + // eslint-disable-next-line typescript/no-deprecated + [HTTP_TARGET]: '/monitoring', + [SERVER_ADDRESS]: 'api.example.com', + }; + + expect(isIgnored(outgoingRequestAttributes)).toBe(false); + }); + + it.each(['/sentry-tunnel', '/sentry-tunnel/route'])( + 'ignores the route handler span with next.route %s in handler mode', + nextRoute => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + + expect( + isIgnored({ [ATTR_NEXT_SPAN_TYPE]: 'AppRouteRouteHandlers.runHandler', [ATTR_NEXT_ROUTE]: nextRoute }), + ).toBe(true); + }, + ); + + it('does not ignore the route handler span in rewrite mode', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'rewrite'; + + expect( + isIgnored({ [ATTR_NEXT_SPAN_TYPE]: 'AppRouteRouteHandlers.runHandler', [ATTR_NEXT_ROUTE]: '/sentry-tunnel' }), + ).toBe(false); + }); + + it('escapes regex characters in the tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/a.b'; + + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', '/axb?o=123&p=456'))).toBe(false); + }); +}); diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts index 8382e66ca7d4..30dd3f840ba3 100644 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ b/packages/nextjs/test/utils/tunnelRoute.test.ts @@ -4,10 +4,13 @@ import { applyTunnelRouteOption } from '../../src/client/tunnelRoute'; const globalWithInjectedValues = global as typeof global & { _sentryRewritesTunnelPath?: string; + _sentryTunnelRouteMode?: string; }; beforeEach(() => { + vi.restoreAllMocks(); globalWithInjectedValues._sentryRewritesTunnelPath = undefined; + globalWithInjectedValues._sentryTunnelRouteMode = undefined; }); describe('applyTunnelRouteOption()', () => { @@ -118,3 +121,76 @@ describe('Random tunnel route generation', () => { expect(options.tunnel).toBeUndefined(); }); }); + +describe('applyTunnelRouteOption() credentials', () => { + const SAAS_DSN = 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333'; + + beforeEach(() => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; + }); + + it("sends tunnel requests with `credentials: 'omit'`", () => { + const options: BrowserOptions = { dsn: SAAS_DSN }; + + applyTunnelRouteOption(options); + + expect(options.transportOptions).toEqual({ fetchOptions: { credentials: 'omit' } }); + }); + + it('keeps the fetch options of the user', () => { + const options: BrowserOptions = { + dsn: SAAS_DSN, + transportOptions: { bufferSize: 10, fetchOptions: { credentials: 'same-origin', priority: 'low' } }, + }; + + applyTunnelRouteOption(options); + + expect(options.transportOptions).toEqual({ + bufferSize: 10, + fetchOptions: { credentials: 'same-origin', priority: 'low' }, + }); + }); + + it('does not change the transport options when the events are not tunneled', () => { + const options: BrowserOptions = { dsn: 'https://11111111111111111111111111111111@example.com/3333333' }; + + applyTunnelRouteOption(options); + + expect(options.transportOptions).toBeUndefined(); + }); + + it('does not change the transport options in handler mode', () => { + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + const options: BrowserOptions = { dsn: SAAS_DSN }; + + applyTunnelRouteOption(options); + + expect(options.transportOptions).toBeUndefined(); + }); +}); + +describe('applyTunnelRouteOption() in handler mode', () => { + it.each([ + ['a SaaS DSN', 'https://11111111111111111111111111111111@o2222222.ingest.us.sentry.io/3333333'], + ['a self-hosted DSN', 'https://11111111111111111111111111111111@sentry.example.com/3333333'], + ])('tunnels %s to the tunnel path with no query', (_description, dsn) => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + const options: BrowserOptions = { dsn }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe('/my-error-monitoring-route'); + }); + + it("Doesn't apply `tunnelRoute` when DSN is invalid", () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + const options: BrowserOptions = { dsn: 'invalidDsn' }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBeUndefined(); + }); +}); diff --git a/packages/node/src/integrations/contextlines.ts b/packages/node/src/integrations/contextlines.ts index 12cc79c90366..9ff0d22cc48f 100644 --- a/packages/node/src/integrations/contextlines.ts +++ b/packages/node/src/integrations/contextlines.ts @@ -1,9 +1,10 @@ import { createReadStream } from 'node:fs'; import { createInterface } from 'node:readline'; import type { Event, IntegrationFn, StackFrame } from '@sentry/core'; -import { debug, defineIntegration, LRUMap, snipLine } from '@sentry/core'; +import { debug, defineIntegration, LRUMap, snipLine, stripUrlQueryAndFragment } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +const SOURCE_FILE_EXTENSION = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|mdx)$/; const LRU_FILE_CONTENTS_CACHE = new LRUMap>(10); const LRU_FILE_CONTENTS_FS_READ_FAILED = new LRUMap(20); const DEFAULT_LINES_OF_CONTEXT = 5; @@ -31,6 +32,7 @@ interface ContextLinesOptions { */ export function resetFileContentCache(): void { LRU_FILE_CONTENTS_CACHE.clear(); + LRU_FILE_CONTENTS_FS_READ_FAILED.clear(); } /** @@ -49,19 +51,22 @@ function emplace, K extends string, V>(map: T, key: K, co /** * Determines if context lines should be skipped for a file. - * - .min.(mjs|cjs|js) files are and not useful since they dont point to the original source * - node: prefixed modules are part of the runtime and cannot be resolved to a file * - data: skip json, wasm and inline js https://nodejs.org/api/esm.html#data-imports + * - .min.(mjs|cjs|js) files are and not useful since they dont point to the original source + * - anything that is not a JavaScript or TypeScript source file. Frame filenames come + * from the parsed `error.stack` string, which application code can overwrite with + * external input, so we must not read arbitrary files off the filesystem. */ function shouldSkipContextLinesForFile(path: string): boolean { - // Test the most common prefix and extension first. These are the ones we - // are most likely to see in user applications and are the ones we can break out of first. if (path.startsWith('node:')) return true; - if (path.endsWith('.min.js')) return true; - if (path.endsWith('.min.cjs')) return true; - if (path.endsWith('.min.mjs')) return true; if (path.startsWith('data:')) return true; - return false; + + const pathname = stripUrlQueryAndFragment(path).toLowerCase(); + if (pathname.endsWith('.min.js')) return true; + if (pathname.endsWith('.min.cjs')) return true; + if (pathname.endsWith('.min.mjs')) return true; + return !SOURCE_FILE_EXTENSION.test(pathname); } /** diff --git a/packages/node/test/integrations/contextlines.test.ts b/packages/node/test/integrations/contextlines.test.ts index af49e1da01cc..c8a5d17ce287 100644 --- a/packages/node/test/integrations/contextlines.test.ts +++ b/packages/node/test/integrations/contextlines.test.ts @@ -69,6 +69,44 @@ describe('ContextLines', () => { }); }); + describe('file filtering', () => { + test.each([ + '/var/task/.env', + '/etc/passwd', + '/home/user/.ssh/id_rsa', + 'file:///var/task/config.json', + 'file:///var/task/index.js.bak', + 'file:///var/task/index.min.js?v=abc', + ])('does not read non-source file %s', async filename => { + expect.assertions(1); + const frames: StackFrame[] = [{ colno: 1, filename, lineno: 1, function: 'fxn1' }]; + + const readStreamSpy = vi.spyOn(fs, 'createReadStream'); + await addContext(frames); + expect(readStreamSpy).not.toHaveBeenCalled(); + }); + + test.each([ + 'file:///var/task/index.js', + 'file:///var/task/index.mjs', + 'file:///var/task/index.cjs', + 'file:///var/task/index.ts', + 'file:///var/task/index.tsx', + 'file:///var/task/Index.JS', + 'file:///var/task/index.js?v=abc123', + 'file:///var/task/index.js#hash', + 'file:///var/task/Component.vue', + 'file:///var/task/page.mdx', + ])('reads source file %s', async filename => { + expect.assertions(1); + const frames: StackFrame[] = [{ colno: 1, filename, lineno: 1, function: 'fxn1' }]; + + const readStreamSpy = vi.spyOn(fs, 'createReadStream'); + await addContext(frames); + expect(readStreamSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('lru file cache', () => { test('parseStack when file does not exist', async () => { expect.assertions(4); diff --git a/packages/nuxt/src/vite/sourceMapDeletion.ts b/packages/nuxt/src/vite/sourceMapDeletion.ts index 01b4d21daecf..7f9784e3bd37 100644 --- a/packages/nuxt/src/vite/sourceMapDeletion.ts +++ b/packages/nuxt/src/vite/sourceMapDeletion.ts @@ -2,6 +2,46 @@ import { createSentryBuildPluginManager, globFiles, type Options } from '@sentry import * as fs from 'node:fs'; import * as path from 'node:path'; +/** Nitro's output directories, which depend on the Nitro preset, e.g. `dist` for the public files on Netlify. */ +type NitroOutputDirs = { publicDir: string; serverDir: string }; + +/** The sides whose source maps only exist because Sentry enabled them, and where Nitro writes them once it is set up. */ +export type DeletionFallback = { client: boolean; server: boolean; outputDirs?: NitroOutputDirs }; + +/** + * Globs for the source maps in Nitro's output directories of the sides whose source maps only exist because Sentry + * enabled them. Empty until Nitro is set up. + */ +export function getFallbackFilesToDelete(fallback: DeletionFallback | undefined): string[] { + if (!fallback?.outputDirs) { + return []; + } + + const { client, server, outputDirs } = fallback; + const { publicDir, serverDir } = outputDirs; + return [ + ...(client ? getPublicSourceMapGlobs(publicDir, server ? undefined : serverDir) : []), + ...(server ? [`${toGlobPath(serverDir)}/**/*.map`] : []), + ]; +} + +/** Some presets write the server into the public dir, e.g. Cloudflare Pages into `dist/_worker.js`. */ +function getPublicSourceMapGlobs(publicDir: string, keptServerDir: string | undefined): string[] { + const publicGlob = toGlobPath(publicDir); + + if (keptServerDir === undefined || !isInDirectory(keptServerDir, publicDir)) { + return [`${publicGlob}/**/*.map`]; + } + + const [serverDirName] = path.relative(publicDir, keptServerDir).split(path.sep); + return [`${publicGlob}/*.map`, `${publicGlob}/!(${serverDirName})/**/*.map`]; +} + +/** `filesToDeleteAfterUpload` is matched from the working directory and needs forward slashes, also on Windows. */ +function toGlobPath(directory: string): string { + return path.relative(process.cwd(), directory).split(path.sep).join('/') || '.'; +} + export function withoutSourceMapDeletion(options: Options): Options { return { ...options, diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index 94cae5a3f506..891a81602aac 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -5,7 +5,13 @@ import { warnOnRemovedBuildOptions } from '@sentry/core'; import type { NitroConfig } from 'nitropack'; import type { Plugin } from 'vite'; import type { SentryNuxtModuleOptions } from '../common/types'; -import { deletePublicSourceMaps, deleteSourceMapsAfterBuild, withoutSourceMapDeletion } from './sourceMapDeletion'; +import { + deletePublicSourceMaps, + deleteSourceMapsAfterBuild, + type DeletionFallback, + getFallbackFilesToDelete, + withoutSourceMapDeletion, +} from './sourceMapDeletion'; import { validateSourceMapsOptionsPlugin } from './sentryVitePlugin'; /** @@ -33,7 +39,7 @@ export function setupSourceMaps( const sourceMapsEnabled = moduleOptions.sourcemaps?.disable !== true; // In case we overwrite the source map settings, we default to deleting the files - const shouldDeleteFilesFallback = { client: true, server: true }; + const shouldDeleteFilesFallback: DeletionFallback = { client: true, server: true }; nuxt.hook('modules:done', () => { if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) { @@ -99,6 +105,9 @@ export function setupSourceMaps( validateNitroSourceMapSettings(nuxt, nitroConfig, moduleOptions); + // `rollupConfig.output.sourcemap` overrides `sourcemap.server`, so the server source maps are the user's. + shouldDeleteFilesFallback.server &&= nitroConfig.rollupConfig.output?.sourcemap === undefined; + if (isDebug) { // eslint-disable-next-line no-console console.log('[Sentry] Adding Sentry Rollup plugin to the server runtime.'); @@ -115,6 +124,7 @@ export function setupSourceMaps( nuxt.hook('nitro:init', nitro => { if (sourceMapsEnabled && !nitro.options.dev && !nuxt.options?._prepare) { const publicDir = nitro.options.output.publicDir; + shouldDeleteFilesFallback.outputDirs = { publicDir, serverDir: nitro.options.output.serverDir }; const rollupConfig = (nitro.options.rollupConfig ??= { output: {} }); rollupConfig.plugins = [ @@ -151,7 +161,7 @@ function normalizePath(path: string): string { */ export function getPluginOptions( moduleOptions: SentryNuxtModuleOptions, - shouldDeleteFilesFallback?: { client: boolean; server: boolean }, + shouldDeleteFilesFallback?: DeletionFallback, ): SentryVitePluginOptions | SentryRollupPluginOptions { const sourcemapsOptions = moduleOptions.sourcemaps || {}; const filesToDeleteAfterUpload = resolveFilesToDeleteAfterUpload(moduleOptions, shouldDeleteFilesFallback); @@ -200,19 +210,15 @@ export function getPluginOptions( * (client/server) whose `sourcemap` setting resolved to `undefined`, where Sentry stepped in and * enabled `'hidden'`. Given Nuxt's default of `{ server: true, client: false }`, this isn't the * default case: it only kicks in when a side is left `undefined` (whole config or a single sub-key). + * Sentry deletes them in Nitro's output directories, which are only known once Nitro is set up. * @see https://nuxt.com/docs/4.x/api/nuxt-config#sourcemap */ function resolveFilesToDeleteAfterUpload( moduleOptions: SentryNuxtModuleOptions, - shouldDeleteFilesFallback?: { client: boolean; server: boolean }, + shouldDeleteFilesFallback?: DeletionFallback, ): string | Array | undefined { - const shouldDeleteFilesAfterUpload = shouldDeleteFilesFallback?.client || shouldDeleteFilesFallback?.server; - const fallbackFilesToDelete = [ - ...(shouldDeleteFilesFallback?.client ? ['.*/**/public/**/*.map'] : []), - ...(shouldDeleteFilesFallback?.server - ? ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'] - : []), - ]; + const fallbackFilesToDelete = getFallbackFilesToDelete(shouldDeleteFilesFallback); + const shouldDeleteFilesAfterUpload = fallbackFilesToDelete.length > 0; const filesToDeleteAfterUpload = moduleOptions.sourcemaps?.filesToDeleteAfterUpload; diff --git a/packages/nuxt/test/vite/sourceMapDeletion.test.ts b/packages/nuxt/test/vite/sourceMapDeletion.test.ts index 1599aebcdf9f..4bc4427a4569 100644 --- a/packages/nuxt/test/vite/sourceMapDeletion.test.ts +++ b/packages/nuxt/test/vite/sourceMapDeletion.test.ts @@ -1,8 +1,9 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { deletePublicSourceMaps } from '../../src/vite/sourceMapDeletion'; +import { globFiles } from '@sentry/bundler-plugins/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { deletePublicSourceMaps, getFallbackFilesToDelete } from '../../src/vite/sourceMapDeletion'; const BUILD_FILES = [ '.output/public/_nuxt/entry.DxB3kLmA.js', @@ -93,3 +94,117 @@ describe('deletePublicSourceMaps', () => { expect(remainingFiles()).toEqual(BUILD_FILES); }); }); + +/** Output directories of Nitro presets and the source maps a build writes there, relative to the Nuxt root. */ +const PRESETS = { + 'node-server': { + outputDirs: { publicDir: '.output/public', serverDir: '.output/server' }, + clientMaps: ['.output/public/_nuxt/entry.DxB3kLmA.js.map'], + serverMaps: ['.output/server/chunks/nitro/nitro.mjs.map'], + }, + netlify: { + outputDirs: { publicDir: 'dist', serverDir: '.netlify/functions-internal/server' }, + clientMaps: ['dist/_nuxt/entry.DxB3kLmA.js.map'], + serverMaps: ['.netlify/functions-internal/server/chunks/nitro/nitro.mjs.map'], + }, + vercel: { + outputDirs: { publicDir: '.vercel/output/static', serverDir: '.vercel/output/functions/__fallback.func' }, + clientMaps: ['.vercel/output/static/_nuxt/entry.DxB3kLmA.js.map'], + serverMaps: ['.vercel/output/functions/__fallback.func/chunks/nitro/nitro.mjs.map'], + }, + 'cloudflare-pages': { + outputDirs: { publicDir: 'dist', serverDir: 'dist/_worker.js' }, + clientMaps: ['dist/_nuxt/entry.DxB3kLmA.js.map', 'dist/sw.js.map'], + serverMaps: ['dist/_worker.js/chunks/nitro/nitro.mjs.map'], + }, +}; + +type Preset = keyof typeof PRESETS; + +/** Never deployed, so Sentry keeps them. */ +const UNDEPLOYED_MAPS = [ + '.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map', + 'node_modules/vue/dist/vue.runtime.esm-bundler.js.map', +]; + +describe('getFallbackFilesToDelete', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nuxt-fallback-deletion-')); + vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + function writeFiles(files: string[], nuxtRoot = projectDir): void { + for (const file of files) { + fs.mkdirSync(path.dirname(path.join(nuxtRoot, file)), { recursive: true }); + fs.writeFileSync(path.join(nuxtRoot, file), ''); + } + } + + /** Writes the build output of a preset and returns the files that the fallback globs match, relative to the root. */ + async function matchFallbackFiles( + preset: Preset, + sides: { client: boolean; server: boolean }, + nuxtRoot = projectDir, + ): Promise { + const { outputDirs, clientMaps, serverMaps } = PRESETS[preset]; + writeFiles([...clientMaps, ...serverMaps, ...UNDEPLOYED_MAPS], nuxtRoot); + + const globs = getFallbackFilesToDelete({ + ...sides, + outputDirs: { + publicDir: path.join(nuxtRoot, outputDirs.publicDir), + serverDir: path.join(nuxtRoot, outputDirs.serverDir), + }, + }); + const files = globs.length > 0 ? await globFiles(globs) : []; + return files.map(file => path.relative(nuxtRoot, file).split(path.sep).join('/')).sort(); + } + + it.each(Object.keys(PRESETS) as Preset[])( + 'matches the client and server source maps in the output of the %s preset', + async preset => { + const { clientMaps, serverMaps } = PRESETS[preset]; + + await expect(matchFallbackFiles(preset, { client: true, server: true })).resolves.toEqual( + [...clientMaps, ...serverMaps].sort(), + ); + }, + ); + + it.each(Object.keys(PRESETS) as Preset[])( + 'matches only the client source maps of the %s preset when Sentry enabled only those', + async preset => { + await expect(matchFallbackFiles(preset, { client: true, server: false })).resolves.toEqual( + [...PRESETS[preset].clientMaps].sort(), + ); + }, + ); + + it.each(Object.keys(PRESETS) as Preset[])( + 'matches only the server source maps of the %s preset when Sentry enabled only those', + async preset => { + await expect(matchFallbackFiles(preset, { client: false, server: true })).resolves.toEqual( + PRESETS[preset].serverMaps, + ); + }, + ); + + it('matches the output of a Nuxt app in a subdirectory of the working directory', async () => { + const nuxtRoot = path.join(projectDir, 'apps', 'shop'); + + await expect(matchFallbackFiles('node-server', { client: true, server: false }, nuxtRoot)).resolves.toEqual( + PRESETS['node-server'].clientMaps, + ); + }); + + it('returns no globs before Nitro reported its output directories', () => { + expect(getFallbackFilesToDelete({ client: true, server: true })).toEqual([]); + }); +}); diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index 2059462d1490..b6bc0b5109ba 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -42,6 +42,23 @@ function createMockAddVitePlugin() { type HookCallback = (...args: unknown[]) => void | Promise; +/** Nitro's output directories of the `node-server` preset, relative to the working directory. */ +const NODE_SERVER_OUTPUT = { publicDir: '.output/public', serverDir: '.output/server' }; + +function createMockNitro(options: { dev?: boolean; output?: { publicDir: string; serverDir: string } } = {}) { + const output = options.output ?? NODE_SERVER_OUTPUT; + return { + options: { + dev: options.dev ?? false, + output: { + publicDir: path.join(process.cwd(), output.publicDir), + serverDir: path.join(process.cwd(), output.serverDir), + }, + rollupConfig: { plugins: [{ name: 'sentry-rollup-plugin' }] as unknown }, + }, + }; +} + function createMockNuxt(options: { _prepare?: boolean; dev?: boolean; @@ -70,12 +87,7 @@ function createMockNuxt(options: { } describe('setupSourceMaps hooks', () => { - const defaultFilesToDeleteAfterUpload = [ - '.*/**/public/**/*.map', - '.*/**/server/**/*.map', - '.*/**/output/**/*.map', - '.*/**/function/**/*.map', - ]; + const defaultFilesToDeleteAfterUpload = ['.output/public/**/*.map', '.output/server/**/*.map']; const consoleLogSpy = vi.spyOn(console, 'log'); const consoleWarnSpy = vi.spyOn(console, 'warn'); @@ -273,6 +285,7 @@ describe('setupSourceMaps hooks', () => { setupSourceMaps({ debug: false }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:init', createMockNitro()); await mockNuxt.triggerHook('close'); expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith( @@ -284,6 +297,24 @@ describe('setupSourceMaps hooks', () => { expect(mockDeleteArtifacts).toHaveBeenCalledTimes(1); }); + it('keeps the server source maps that `nitro.rollupConfig.output.sourcemap` enables', async () => { + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: undefined } }); + const nitroConfig = { rollupConfig: { plugins: [] as unknown[], output: { sourcemap: true } }, dev: false }; + + setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + await mockNuxt.triggerHook('nitro:init', createMockNitro()); + await mockNuxt.triggerHook('close'); + + expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ filesToDeleteAfterUpload: ['.output/public/**/*.map'] }), + }), + { buildTool: 'nuxt', loggerPrefix: '[Sentry Nuxt]' }, + ); + }); + it('uses user-provided deletion patterns after the build', async () => { const mockNuxt = createMockNuxt({ _prepare: false, @@ -397,16 +428,6 @@ describe('setupSourceMaps hooks', () => { fs.rmSync(rootDir, { recursive: true, force: true }); }); - function createMockNitro(options: { dev?: boolean } = {}) { - return { - options: { - dev: options.dev ?? false, - output: { publicDir: path.join(rootDir, '.output', 'public') }, - rollupConfig: { plugins: [{ name: 'sentry-rollup-plugin' }] as unknown }, - }, - }; - } - function findDeletionPlugin(nitro: ReturnType) { return [nitro.options.rollupConfig.plugins] .flat(2) @@ -454,6 +475,23 @@ describe('setupSourceMaps hooks', () => { expect(remainingFiles()).toEqual([PUBLIC_CHUNK, SSR_BUILD_MAP]); }); + it('deletes the public source maps of a preset that writes the public files outside of `.output`', async () => { + const netlifyPublicMap = 'dist/_nuxt/entry.DxB3kLmA.js.map'; + fs.mkdirSync(path.dirname(path.join(rootDir, netlifyPublicMap)), { recursive: true }); + fs.writeFileSync(path.join(rootDir, netlifyPublicMap), ''); + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: true } }); + const nitro = createMockNitro({ + output: { publicDir: 'dist', serverDir: '.netlify/functions-internal/server' }, + }); + + setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:init', nitro); + await findDeletionPlugin(nitro)?.buildStart(); + + expect(fs.existsSync(path.join(rootDir, netlifyPublicMap))).toBe(false); + }); + it.each([ { label: 'prepare mode', nuxtOptions: { _prepare: true }, nitroOptions: {} }, { label: 'dev mode', nuxtOptions: { dev: true }, nitroOptions: { dev: true } }, diff --git a/packages/nuxt/test/vite/sourceMaps.test.ts b/packages/nuxt/test/vite/sourceMaps.test.ts index db00688a14d1..b767aa19d458 100644 --- a/packages/nuxt/test/vite/sourceMaps.test.ts +++ b/packages/nuxt/test/vite/sourceMaps.test.ts @@ -1,4 +1,5 @@ import type { Nuxt } from '@nuxt/schema'; +import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SentryNuxtModuleOptions } from '../../src/common/types'; import type { SourceMapSetting } from '../../src/vite/sourceMaps'; @@ -19,6 +20,11 @@ vi.mock('@sentry/core', async importOriginal => { }; }); +const NODE_SERVER_OUTPUT_DIRS = { + publicDir: path.join(process.cwd(), '.output', 'public'), + serverDir: path.join(process.cwd(), '.output', 'server'), +}; + describe('getPluginOptions', () => { beforeEach(() => { vi.resetModules(); @@ -213,19 +219,26 @@ describe('getPluginOptions', () => { name: 'both client and server fallback are true', clientFallback: true, serverFallback: true, - expected: ['.*/**/public/**/*.map', '.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], + expected: ['.output/public/**/*.map', '.output/server/**/*.map'], }, { name: 'only client fallback is true', clientFallback: true, serverFallback: false, - expected: ['.*/**/public/**/*.map'], + expected: ['.output/public/**/*.map'], }, { name: 'only server fallback is true', clientFallback: false, serverFallback: true, - expected: ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], + expected: ['.output/server/**/*.map'], + }, + { + name: 'both fallbacks are true, but Nitro is not set up yet', + clientFallback: true, + serverFallback: true, + outputDirs: null, + expected: undefined, }, { name: 'no fallback, but custom filesToDeleteAfterUpload is provided', @@ -245,10 +258,11 @@ describe('getPluginOptions', () => { }, ])( 'sets filesToDeleteAfterUpload correctly when $name', - ({ clientFallback, serverFallback, customOptions = {}, expected }) => { + ({ clientFallback, serverFallback, customOptions = {}, outputDirs = NODE_SERVER_OUTPUT_DIRS, expected }) => { const options = getPluginOptions(customOptions as SentryNuxtModuleOptions, { client: clientFallback, server: serverFallback, + outputDirs: outputDirs ?? undefined, }); expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expected); diff --git a/packages/server-utils/src/orchestrion/bundler/externalRequire.ts b/packages/server-utils/src/orchestrion/bundler/externalRequire.ts new file mode 100644 index 000000000000..971f19c322d6 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/externalRequire.ts @@ -0,0 +1,125 @@ +import { isPlainObject } from '@sentry/core'; +import { readFileSync } from 'node:fs'; +import { createRequire, isBuiltin } from 'node:module'; +import { isAbsolute } from 'node:path'; +import type { Plugin } from 'vite'; + +type ResolveIdHandler = Extract, (...args: never[]) => unknown>; +type ResolveIdParameters = Parameters; + +const NODE_MODULES = '/node_modules/'; + +/** + * Creates a `resolveId` handler that bundles the external packages that bundled CommonJS `require()`s. + * + * Rolldown keeps these calls as `__require()`, which a second bundler over the server output can't follow: + * - https://vite.dev/guide/migration#require-calls-for-externalized-modules + * - https://rolldown.rs/in-depth/bundling-cjs#require-external-modules + * + * Only handles requires from packages bundled for orchestrion, and from packages bundled because of them. + */ +export function createExternalRequireResolver( + moduleNames: readonly string[], +): (this: ThisParameterType, ...args: ResolveIdParameters) => Promise { + const bundledPackagesByEnvironment = new Map>(); + + return async function resolveExternalRequire(source, importer, options) { + if ( + !this.meta.rolldownVersion || + this.environment.mode !== 'build' || + options.kind !== 'require-call' || + !importer || + !isPackageSpecifier(source) + ) { + return null; + } + + const bundledPackages = bundledPackagesByEnvironment.get(this.environment.name) ?? new Set(moduleNames); + bundledPackagesByEnvironment.set(this.environment.name, bundledPackages); + + const owner = ownerPackage(importer); + const name = packageName(source); + if ( + !owner || + !bundledPackages.has(owner.name) || + isUserExternal(this.environment.config.resolve.external, source, name) || + isOptionalDependency(owner.dir, name) + ) { + return null; + } + + const resolved = await this.resolve(source, importer, { ...options, skipSelf: true }); + if (!resolved) { + return null; + } + // Vite bundles it anyway (e.g. under pnpm), so its own requires count too. + if (!resolved.external) { + bundledPackages.add(name); + return null; + } + + const file = resolveWithNode(source, importer); + if (file) { + bundledPackages.add(name); + } + return file ?? null; + }; +} + +function isPackageSpecifier(source: string): boolean { + return !isBuiltin(source) && !isAbsolute(source) && !/^[.\0]/.test(source); +} + +// `pg-types/lib/x` → `pg-types`, `@scope/pkg/x` → `@scope/pkg`. +function packageName(specifier: string): string { + const [first = '', second] = specifier.split('/'); + return first.startsWith('@') && second ? `${first}/${second}` : first; +} + +// Uses the last `node_modules` segment, so nested and pnpm store paths work. +function ownerPackage(file: string): { name: string; dir: string } | undefined { + const path = file.replace(/\\/g, '/'); + const start = path.lastIndexOf(NODE_MODULES); + if (start === -1) { + return undefined; + } + const name = packageName(path.slice(start + NODE_MODULES.length)); + return { name, dir: `${path.slice(0, start)}${NODE_MODULES}${name}` }; +} + +// Matches like Vite. `external: true` is Vite's SSR default, not a choice for this package. +function isUserExternal(external: readonly string[] | true, source: string, name: string): boolean { + return Array.isArray(external) && external.some(entry => entry === source || entry === name); +} + +// These can be missing or native, like `pg-native`. +function isOptionalDependency(packageDir: string, name: string): boolean { + const manifest = readManifest(`${packageDir}/package.json`); + if (!manifest) { + return false; + } + const { peerDependenciesMeta, optionalDependencies } = manifest; + const peerMeta = isPlainObject(peerDependenciesMeta) ? peerDependenciesMeta[name] : undefined; + return ( + (isPlainObject(peerMeta) && peerMeta.optional === true) || + (isPlainObject(optionalDependencies) && name in optionalDependencies) + ); +} + +// The file that `require()` loads at runtime. +function resolveWithNode(source: string, importer: string): string | undefined { + try { + return createRequire(importer).resolve(source); + } catch { + return undefined; + } +} + +function readManifest(file: string): Record | undefined { + try { + const manifest: unknown = JSON.parse(readFileSync(file, 'utf8')); + return isPlainObject(manifest) ? manifest : undefined; + } catch { + return undefined; + } +} diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index d199f7224385..16e642dc76fd 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -5,6 +5,7 @@ export type { Plugin as VitePlugin } from 'vite'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options'; +import { createExternalRequireResolver } from './externalRequire'; import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER } from './resolve'; type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: boolean }) => unknown; @@ -41,6 +42,11 @@ function ssrOnlyTransform(transform: Plugin['transform'], isDuplicate: () => boo return transform; } +// Maps the CJS entry of `@sentry/server-utils` to its ESM entry; any other id is returned unchanged. +function toEsmEntry(id: string): string { + return id.replace(/([/\\])build[/\\]cjs[/\\]index\.js$/, '$1build$1esm$1index.js'); +} + /** * Vite plugin that runs the orchestrion code transform on the bundled output. * @@ -68,6 +74,7 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { ...instrumentedModuleNames(options.instrumentations), '@sentry/server-utils', ]; + const resolveExternalRequire = createExternalRequireResolver(noExternalModules()); const instance = {}; // `true` when an earlier instance of this plugin is in the same build, which then transforms the modules alone. @@ -83,15 +90,24 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { // normal resolution fails, fall back to this package's own resolution so it // gets bundled from its real on-disk path. SSR-gated like the transform: the // specifier only appears in SSR modules. + // + // Always bundle the ESM entry. In a CommonJS module the snippet `require()`s the specifier, + // which resolves to the CJS build. Vite 5-7 turn that build's `require()`s of external + // packages into default imports, and `@sentry/core` has no default export, so the server + // crashes at startup. Vite 8 keeps the `require()`s, which loads a second copy of `@sentry/core`. async resolveId(source, importer, resolveOptions) { - if (source !== SNIPPET_IMPORT_SPECIFIER || !resolveOptions?.ssr) { + if (source !== SNIPPET_IMPORT_SPECIFIER) { + return resolveExternalRequire.call(this, source, importer, resolveOptions); + } + if (!resolveOptions?.ssr) { return null; } const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true }); if (resolved) { - return resolved; + return resolved.external ? resolved : { ...resolved, id: toEsmEntry(resolved.id) }; } - return resolveOrchestrionRuntimeRequest(source) ?? null; + const fallback = resolveOrchestrionRuntimeRequest(source); + return fallback ? toEsmEntry(fallback) : null; }, applyToEnvironment(environment) { // Orchestrion splices `node:diagnostics_channel` calls into instrumented modules, which only diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index 59c74f664ef4..b0690ab74903 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -402,6 +402,71 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(typeof fallback).toBe('string'); expect(fallback).toContain('server-utils'); }); + + it('resolves the snippet import to the ESM entry, also for require() calls', async () => { + const plugin = vitePlugin(); + const resolveId = plugin.resolveId as ( + this: unknown, + source: string, + importer: string | undefined, + opts?: { ssr?: boolean }, + ) => Promise; + + const resolve = vi.fn().mockResolvedValue(null); + // How Vite 5-7 resolve a `require()` in a CommonJS module. + const requireOptions = { ssr: true, custom: { 'node-resolve': { isRequire: true } } }; + + resolve.mockResolvedValueOnce({ + id: '/app/node_modules/@sentry/server-utils/build/cjs/index.js', + moduleSideEffects: false, + }); + await expect( + resolveId.call({ resolve }, '@sentry/server-utils', '/app/node_modules/mysql/index.js', requireOptions), + ).resolves.toEqual({ + id: '/app/node_modules/@sentry/server-utils/build/esm/index.js', + moduleSideEffects: false, + }); + expect(resolve).toHaveBeenCalledWith('@sentry/server-utils', '/app/node_modules/mysql/index.js', { + ...requireOptions, + skipSelf: true, + }); + + resolve.mockResolvedValueOnce({ id: 'C:\\app\\node_modules\\@sentry\\server-utils\\build\\cjs\\index.js' }); + await expect(resolveId.call({ resolve }, '@sentry/server-utils', '/x.js', requireOptions)).resolves.toEqual({ + id: 'C:\\app\\node_modules\\@sentry\\server-utils\\build\\esm\\index.js', + }); + + resolve.mockResolvedValueOnce({ id: '@sentry/server-utils', external: true }); + await expect(resolveId.call({ resolve }, '@sentry/server-utils', '/x.js', requireOptions)).resolves.toEqual({ + id: '@sentry/server-utils', + external: true, + }); + + const fallback = await resolveId.call({ resolve }, '@sentry/server-utils', '/x.js', requireOptions); + expect(fallback).toMatch(/[/\\]build[/\\]esm[/\\]index\.js$/); + expect(existsSync(fallback as string)).toBe(true); + }); + + it('resolves the snippet require() to the ESM entry before it handles other requires on Rolldown', async () => { + const resolveId = vitePlugin().resolveId as ( + this: unknown, + source: string, + importer: string, + opts: { ssr: boolean; kind: string }, + ) => Promise; + const context = { + meta: { rolldownVersion: '1.2.14' }, + environment: { name: 'ssr', mode: 'build', config: { resolve: { external: [] } } }, + resolve: vi.fn().mockResolvedValue({ id: '/app/node_modules/@sentry/server-utils/build/cjs/index.js' }), + }; + + const resolved = await resolveId.call(context, '@sentry/server-utils', '/app/node_modules/pg/lib/client.js', { + ssr: true, + kind: 'require-call', + }); + + expect(resolved).toEqual({ id: '/app/node_modules/@sentry/server-utils/build/esm/index.js' }); + }); }); describe('sentryOrchestrionPlugin (bun)', () => { diff --git a/packages/server-utils/test/orchestrion/externalRequire.test.ts b/packages/server-utils/test/orchestrion/externalRequire.test.ts new file mode 100644 index 000000000000..73951a3b971e --- /dev/null +++ b/packages/server-utils/test/orchestrion/externalRequire.test.ts @@ -0,0 +1,173 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createExternalRequireResolver } from '../../src/orchestrion/bundler/externalRequire'; + +interface ContextOptions { + environment?: string; + meta?: Record; + mode?: string; + external?: string[] | true; + resolved?: { id: string; external?: boolean } | null; +} + +describe('createExternalRequireResolver', () => { + let root: string; + let pgFile: string; + + function installPackage(name: string, manifest: Record = {}, parentDir = root): string { + const dir = join(parentDir, 'node_modules', name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, main: 'index.js', ...manifest })); + writeFileSync(join(dir, 'index.js'), ''); + return join(dir, 'index.js'); + } + + // By default, a Rolldown build in which Vite keeps the required package external. + function context({ + environment = 'ssr', + meta = { rolldownVersion: '1.2.14' }, + mode = 'build', + external = [], + resolved = { id: 'external', external: true }, + }: ContextOptions = {}): never { + return { + meta, + environment: { name: environment, mode, config: { resolve: { external } } }, + resolve: vi.fn().mockResolvedValue(resolved), + } as never; + } + + function resolveRequire( + resolver: ReturnType, + source: string, + importer: string, + contextOptions?: ContextOptions, + kind = 'require-call', + ): Promise { + return resolver.call(context(contextOptions), source, importer, { kind, isEntry: false } as never); + } + + beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orch-external-require-'))); + pgFile = join(root, 'node_modules/pg/lib/client.js'); + installPackage('pg'); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('bundles an external package that an instrumented package requires', async () => { + const pgTypes = installPackage('pg-types'); + + await expect(resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', pgFile)).resolves.toBe(pgTypes); + }); + + it.each([ + ['it bundled', { id: 'external', external: true }], + ['Vite bundles itself', { id: 'pg-types/index.js' }], + ])('follows the requires of a package that %s', async (_, resolved) => { + const pgTypes = installPackage('pg-types'); + const postgresArray = installPackage('postgres-array'); + const resolver = createExternalRequireResolver(['pg']); + + await resolveRequire(resolver, 'pg-types', pgFile, { resolved }); + + await expect(resolveRequire(resolver, 'postgres-array', pgTypes)).resolves.toBe(postgresArray); + }); + + // Every require() here resolves to a real file, so only the filter can return `null`. + it.each([ + ['app code', 'pg-types', 'src/db.js'], + ['a package that orchestrion does not bundle', 'pg-types', 'node_modules/other/index.js'], + ['a builtin', 'node:net', 'node_modules/pg/lib/client.js'], + ['a relative path', './connection', 'node_modules/pg/lib/client.js'], + ])('ignores a require() of %s', async (_, source, importer) => { + installPackage('pg-types'); + installPackage('other'); + mkdirSync(join(root, 'node_modules/pg/lib'), { recursive: true }); + writeFileSync(join(root, 'node_modules/pg/lib/connection.js'), ''); + + await expect( + resolveRequire(createExternalRequireResolver(['pg']), source, join(root, importer)), + ).resolves.toBeNull(); + }); + + it('bundles a require() from a pnpm store path', async () => { + const store = join(root, 'node_modules/.pnpm/pg@8.23.1'); + installPackage('pg', {}, store); + const pgTypes = installPackage('pg-types', {}, store); + + await expect( + resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', join(store, 'node_modules/pg/lib/client.js')), + ).resolves.toBe(pgTypes); + }); + + it('tracks bundled packages for each environment separately', async () => { + const pgTypes = installPackage('pg-types'); + installPackage('postgres-array'); + const resolver = createExternalRequireResolver(['pg']); + + await resolveRequire(resolver, 'pg-types', pgFile, { environment: 'ssr' }); + + await expect(resolveRequire(resolver, 'postgres-array', pgTypes, { environment: 'nitro' })).resolves.toBeNull(); + }); + + it.each([ + ['on Rollup', { meta: { rollupVersion: '4.0.0' } }, 'require-call'], + ['on the dev server', { mode: 'dev' }, 'require-call'], + ['for an import', {}, 'import-statement'], + ])('leaves resolution to the bundler %s', async (_, contextOptions, kind) => { + installPackage('pg-types'); + + await expect( + resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', pgFile, contextOptions, kind), + ).resolves.toBeNull(); + }); + + it.each([ + ['an optional peer', { peerDependenciesMeta: { 'pg-native': { optional: true } } }], + ['an optional dependency', { optionalDependencies: { 'pg-native': '^3' } }], + ])('keeps %s external', async (_, manifest) => { + installPackage('pg', manifest); + installPackage('pg-native'); + + await expect(resolveRequire(createExternalRequireResolver(['pg']), 'pg-native', pgFile)).resolves.toBeNull(); + }); + + it('keeps a scoped subpath external when the user lists its package', async () => { + installPackage('@scope/pkg'); + writeFileSync(join(root, 'node_modules/@scope/pkg/sub.js'), ''); + + await expect( + resolveRequire(createExternalRequireResolver(['pg']), '@scope/pkg/sub', pgFile, { external: ['@scope/pkg'] }), + ).resolves.toBeNull(); + }); + + it('keeps a package external that the user lists in `external`', async () => { + installPackage('pg-types'); + + await expect( + resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', pgFile, { external: ['pg-types'] }), + ).resolves.toBeNull(); + }); + + it.each([ + ['only a subpath of the package', ['pg-types/lib/arrays']], + ['`true`, the SSR default', true], + ] as const)('bundles a package despite an external entry that names %s', async (_, external) => { + const pgTypes = installPackage('pg-types'); + + await expect( + resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', pgFile, { + external: external === true ? true : [...external], + }), + ).resolves.toBe(pgTypes); + }); + + it('keeps a package external that is not installed', async () => { + await expect(resolveRequire(createExternalRequireResolver(['pg']), 'pg-types', pgFile)).resolves.toBeNull(); + }); +}); diff --git a/packages/solidstart/src/server/withServerActionInstrumentation.ts b/packages/solidstart/src/server/withServerActionInstrumentation.ts index 785ab1432f91..68d4ff5221c7 100644 --- a/packages/solidstart/src/server/withServerActionInstrumentation.ts +++ b/packages/solidstart/src/server/withServerActionInstrumentation.ts @@ -1,6 +1,6 @@ import { handleCallbackErrors, SPAN_STATUS_ERROR } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; -import { captureException, getActiveSpan, spanToJSON, startSpan } from '@sentry/node'; +import { captureException, getActiveSpan, getRootSpan, spanToJSON, startSpan } from '@sentry/node'; import { isRedirect } from './utils'; import { SENTRY_SEGMENT_NAME_SOURCE, @@ -25,7 +25,9 @@ export async function withServerActionInstrumentation { expect(mockSpanSetAttribute).to.toHaveBeenCalledWith(SENTRY_SEGMENT_NAME_SOURCE, 'route'); }); + it('sets a server action name on the root span when the active span is a nested handler span', async () => { + const rootSpan = new SentryCore.SentrySpan({ + attributes: { + 'url.path': '/_server', + [SENTRY_OP]: 'http.server', + }, + }); + const handlerSpan = new SentryCore.SentrySpan({ + attributes: { + [SENTRY_OP]: 'handler', + }, + }); + SentryCore.addChildSpanToSpan(rootSpan, handlerSpan); + mockGetActiveSpan.mockReturnValue(handlerSpan); + const mockRootSpanSetAttribute = vi.spyOn(rootSpan, 'setAttribute'); + + await serverActionGetPrefecture(); + + expect(mockRootSpanSetAttribute).toHaveBeenCalledWith('http.route', 'getPrefecture'); + expect(mockRootSpanSetAttribute).toHaveBeenCalledWith(SENTRY_SEGMENT_NAME_SOURCE, 'route'); + }); + it('does not set a server action name if the active span had a non `/_server` `url.path`', async () => { const span = new SentryCore.SentrySpan(); span.setAttribute('url.path', '/users/5'); diff --git a/scripts/dependency-hash-key.js b/scripts/dependency-hash-key.js deleted file mode 100644 index 9af70a756561..000000000000 --- a/scripts/dependency-hash-key.js +++ /dev/null @@ -1,75 +0,0 @@ -const crypto = require('crypto'); -const fs = require('fs'); -const path = require('path'); - -/** - * Build a cache key for the dependencies of the monorepo. - * In addition to the content of the pnpm-lock.yaml and pnpm-workspace.yaml files, we also include - * dependencies of all workspace packages in the cache key. - * This ensures that we get a consistent cache key even if a dependency change does not affect - * the pnpm-lock.yaml file. - */ -function outputDependencyCacheKey() { - const lockfileContent = fs.readFileSync(path.join(process.cwd(), 'pnpm-lock.yaml'), 'utf8'); - - const workspaceContent = fs.readFileSync(path.join(process.cwd(), 'pnpm-workspace.yaml'), 'utf8'); - - const hashParts = [lockfileContent, workspaceContent]; - - const packageJson = require(path.join(process.cwd(), 'package.json')); - - const workspacePackages = packageJson.workspaces || []; - - // Get the package name (e.g. @sentry/browser) of all workspace packages - // we want to ignore their version numbers later - const workspacePackageNames = getWorkspacePackageNames(workspacePackages); - - // Add the dependencies of the workspace itself - hashParts.push(getNormalizedDependencies(packageJson, workspacePackageNames)); - - // Now for each workspace package, add the dependencies - workspacePackages.forEach(workspace => { - const packageJsonPath = path.join(process.cwd(), workspace, 'package.json'); - const packageJson = require(packageJsonPath); - hashParts.push(getNormalizedDependencies(packageJson, workspacePackageNames)); - }); - - const hash = crypto.createHash('md5').update(hashParts.join('\n')).digest('hex'); - // We log the output in a way that the GitHub Actions can append it to the output - // We prefix it with `dependencies-` so it is easier to identify in the logs - // eslint-disable-next-line no-console - console.log(`hash=dependencies-${hash}`); -} - -function getNormalizedDependencies(packageJson, workspacePackageNames) { - const { dependencies, devDependencies } = packageJson; - - const mergedDependencies = { - ...devDependencies, - ...dependencies, - }; - - const normalizedDependencies = {}; - - // Sort the keys to ensure a consistent order - Object.keys(mergedDependencies) - .sort() - .forEach(key => { - // If the dependency is a workspace package, ignore the version - // No need to invalidate a cache after every release - const version = workspacePackageNames.includes(key) ? '**workspace**' : mergedDependencies[key]; - normalizedDependencies[key] = version; - }); - - return JSON.stringify(normalizedDependencies); -} - -function getWorkspacePackageNames(workspacePackages) { - return workspacePackages.map(workspace => { - const packageJsonPath = path.join(process.cwd(), workspace, 'package.json'); - const packageJson = require(packageJsonPath); - return packageJson.name; - }); -} - -outputDependencyCacheKey();