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